Redis (RESP) protocol layer — design exploration #187
Replies: 3 comments
-
Item 1 analysis: Split
|
Beta Was this translation helpful? Give feedback.
-
Item 2 analysis: Buffering strategies (deferred)Adding Both are instances of the same concept: buffering strategies that sit between TCP reads and
The core abstraction for a pluggable strategy is simple: The hard part is configuration. How does the enclosing actor tell Lori which strategy to use? How does switching between strategies work mid-stream without creating a messy API surface? The actor would need to know which strategy is active and call the right configuration method ( Decision: Deferred. The current |
Beta Was this translation helpful? Give feedback.
-
Decision: Build concrete protocols first, extract abstractions laterRather than trying to design a generic protocol framework upfront, the plan is to build all 5 target protocols on top of Lori as-is and then look for commonalities across the concrete implementations. The right abstraction will emerge from real code, not from speculative design. This discussion is useful as background research for the design space, but the next step is "build Redis" — not "design a pluggable protocol layer." |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Redis (RESP) Protocol Layer for Lori — Design Exploration
What we're exploring
How should a protocol state machine layer on top of Lori? We use Redis/RESP as the concrete case because it's the simplest of the five target protocols. The goal is to surface design decisions and shape alternatives, not to converge on a single design.
The constraint: Lori's API stays the same. The protocol layer is built on top. However, when a shape would be significantly improved by a Lori API change, we note that — part of the exploration's value is discovering what Lori should eventually provide natively.
RESP protocol overview
RESP (Redis Serialization Protocol) is Redis's wire format. It has five data types:
+OK\r\n-ERR message\r\n:42\r\n$6\r\nfoobar\r\n(length-prefixed, or$-1\r\nfor null)*3\r\n...(count-prefixed, elements are any RESP type, recursive, or*-1\r\nfor null)Commands are sent as arrays of bulk strings:
*2\r\n$3\r\nGET\r\n$3\r\nfoo\r\nNote: Redis commands are binary-safe — values can contain arbitrary bytes, not just text. The command API should use
ByteSeq(orArray[U8] val) for values, not justString. The sketches below useArray[ByteSeq] valfor command arguments to reflect this.State machine
Key behaviors:
PING\r\n) but RESP arrays are the standard client format. We'd probably only support RESP.Design axes
Three independent decisions shape the design:
Axis 1: Who owns the actor?
Option A — Library provides the actor. The Redis library ships a
RedisConnectionactor. The user never implementsTCPConnectionActoror any Lori lifecycle trait. They interact withRedisConnectionvia behaviors and receive responses through some callback mechanism (see Axis 2).Option B — User writes the actor. The user's actor implements
TCPConnectionActor(Lori's ASIO plumbing trait). The protocol layer is a class or trait that handles RESP parsing and state management within the user's actor. The user doesn't implement Lori's lifecycle receiver traits — the protocol layer intercepts those.The key difference: In Option A, Redis protocol state and user application state live in different actors. Correlating them requires message passing. In Option B, they share an actor, so the user's protocol callbacks can directly read and mutate application state.
Axis 2: How are protocol events delivered?
Option X — Trait callbacks. The user's actor implements a trait like
RedisClientCallbackswith methods like_on_redis_response(value: RedisValue). These run inside the user's actor (or the library actor if combined with Option A, but that defeats the purpose).Note: In Pony, this could be a
trait(nominal subtyping — the user declaresis RedisClientCallbacks) or aninterface(structural subtyping — the user just needs the right methods). Interfaces are more composable: an actor could satisfy bothRedisClientCallbacksandHttpClientCallbacksstructurally without diamond inheritance issues. Traits make the relationship explicit and discoverable. This choice matters when multiple protocol layers might coexist on one actor.Option Y — Notifier object. The user creates a class implementing
RedisNotifyand passes it (asiso) to the protocol layer. Callbacks run wherever the protocol layer lives. If the protocol layer is a library-owned actor (Option A), the notifier is trapped inside that actor and needstagreferences to communicate back.Option Z — Actor messages. The protocol layer sends behavior calls to a handler actor provided by the user. This only makes sense with Option A (library-owned actor), since if the user owns the actor, they'd just use trait callbacks.
Axis 3: Where does protocol state live?
Protocol state = RESP parse buffer, current parse position, normal-vs-pubsub mode, pending request queue for pipelining.
Option I — Protocol class the user embeds. Like
TCPConnection, the user stores aRedisProtocolfield and provides access via a method. The class holds all protocol state. Pony'sembedkeyword (inline allocation, no heap indirection) is an option here if the protocol class can be fully initialized in the constructor — see the discussion ofnone()necessity below.Option II — Protocol state in the library actor. If the library owns the actor (Option A), protocol state is just private fields on that actor.
Option III — Split between trait defaults and a state object. A trait provides default implementations for Lori callbacks. Since traits can't have fields in Pony, the actual state lives in a class the user provides access to (like
_connection()provides access toTCPConnection).Concrete shapes
These combine the axes above into complete pictures. Each is internally consistent.
Shape 1: Library-owned actor with handler actors (A + Z + II)
The simplest user experience. The library provides everything. The user sends commands via behaviors and receives responses in a handler actor.
Tradeoffs:
TCPConnectionActor, no_connection(), no_on_received.command()is a behavior (async), so it can't return a token or error synchronously. The user can't know at call time whether the command was accepted. Errors arrive later, asynchronously.command()behaviors. The library would need to buffer internally or drop — the user has no signal at send time.Capability constraint on data types: All data crossing the actor boundary (RedisConnection → MyApp) must be sendable:
val,tag, or value types.RedisValuemust beval. This means response data is immutable once delivered — the user can't receive anisoarray and mutate it in place. In Shapes 2–5, protocol data stays within a single actor, soisoandrefare options. This is a concrete tradeoff: Shape 1 pays for immutable copying; other shapes don't.Full-response buffering for nested types: The
valrequirement also forces structural consequences. A RedisMGETreturning 1000 bulk strings must be fully constructed as anisotree inside arecoverblock before being consumed tovalfor delivery. The parser cannot incrementally deliver array elements — it must buffer the entire response. For largeLRANGE/SMEMBERS/MGETresults, this creates memory spikes. Shapes 2–5, running in the user's actor, could deliverrefdata orisochunks incrementally. This is a concrete performance difference beyond message passing overhead, and it differentially affects protocols with large structured responses.Server side: The library would provide
RedisServerConnectionas an actor. The listener's_on_acceptcreates one per incoming connection:The user still writes a custom
TCPListenerActor— Shape 1 hides the connection actor but not the listener. ARedisListeneractor could also be provided, wrappingTCPListenerActorandTCPListener, so the user only provides a factory for server handlers. But this means the library needs to provide listener actors too, not just connection actors.Design question: How does the send system work? Lori's
send()returns(SendToken | SendError)synchronously. Behind a behavior boundary, this becomes invisible to the user. Options:SendErroritself (retry on unthrottled, fail on disconnect).be command(...)that can fail, but behaviors can't return values, so failure is reported viaredis_error()callback asynchronously.Shape 2: Protocol class as lifecycle interceptor (B + X + I)
Mirrors Lori's own pattern. The protocol is a class that sits between
TCPConnectionand the user, intercepting Lori's lifecycle callbacks and delivering protocol-level callbacks instead.How it works internally:
RedisClientimplementsClientLifecycleEventReceiver. This is possible becauseTCPConnection.client()takesenclosing(theTCPConnectionActorfor ASIO dispatch) andler(the lifecycle event receiver) as separate parameters. The user's actor isenclosing;RedisClientisler.Note that
ClientLifecycleEventReceiverrequiresfun ref _connection(): TCPConnection— it's not a pure callback trait. Any class implementing it must provide access to theTCPConnection. This means the interceptor class necessarily owns (or has access to) the connection. This coupling is inherent to Lori's current design and applies to every protocol interceptor, not just Redis.Two
_connection()methods in play: Shape 2 has two distinct_connection()methods that must return the sameTCPConnection: one on the user's actor (required byTCPConnectionActor, delegates to_redis.connection()) and one onRedisClient(required byClientLifecycleEventReceiver, returns_tcp_connectiondirectly). Both are called by different parts of Lori's internals — the first by ASIO behaviors, the second byTCPConnectioninternally. If they return different objects, behavior is undefined. In the standard Lori pattern (single actor is bothTCPConnectionActorand lifecycle receiver), there's only one_connection(). The interceptor pattern introduces this novel two-method constraint._on_sent/_on_send_failedrouting: BecauseRedisClientis the lifecycle receiver, Lori delivers_on_sent(token: SendToken)and_on_send_failed(token: SendToken)toRedisClient, not to the user's actor. This is actually a design opportunity:RedisClientsees both theSendToken(from_tcp_connection.send()) and theRedisRequestToken(fromcommand()). It can correlate them internally and either:SendTokenentirely, translating TCP send events into protocol-level events._on_redis_sent(token: RedisRequestToken)callback meaning "your command bytes reached the OS."This is distinct from
_on_redis_response, which means "Redis processed the command and sent a reply." The user might care about both (e.g., for latency measurement) or only the response.Server side: Works naturally. A
RedisServerclass implementsServerLifecycleEventReceiverand intercepts server-side callbacks. The user's server actor implementsTCPConnectionActorand aRedisServerCallbackstrait:The listener creates these in
_on_accept:Tradeoffs:
command()is synchronous — returns token or error immediately, just like Lori'ssend().command()returnsRedisErrorwhen throttled.SendTokenis encapsulated — the user never sees Lori's send tracking.TCPConnectionActorand provides_connection(). They know Lori exists.var _redis: RedisClient = RedisClient.none()andfun ref _connection(): TCPConnection => _redis.connection().TCPConnectionvia_connection()— the user could bypass the protocol layer and corrupt state.Variant: trait defaults for ASIO plumbing. Shape 2's main boilerplate is
fun ref _connection(): TCPConnection => _redis.connection(). This exists becauseTCPConnectionActorrequires_connection(). A protocol library could provide a trait that extendsTCPConnectionActorand provides this default:The user writes one accessor (
_redis_client()) instead of two (_connection()+_redis_client()). The override hazard shifts to_connection()and the ASIO behaviors (_event_notify,_read_again, etc.) — but these are Lori internals that no user would think to override, unlike_on_receivedwhich has protocol-level meaning. This combines Shape 2's safety (interceptor class owns lifecycle callbacks) with Shape 3's convenience (trait defaults handle boilerplate). The lifecycle override hazard that makes Shape 3 fragile doesn't apply because the lifecycle callbacks live on the interceptor class, not the trait.none()necessity:TCPConnection.none()is needed because_finish_initialization(a behavior) calls_connection()on the user's actor, and Pony requires all fields to be definitely assigned. ButRedisClient.none()may not actually be needed._finish_initializationcalls_connection()._finish_initialization(), i.e. it goes through the user's_connection()accessor to theTCPConnection. It doesn't touch_redisdirectly. If the user writes:...
_redisis assigned before the constructor completes, and_finish_initializationruns asynchronously (as a behavior) after the constructor. So_redisis initialized by the time anything accesses it. If this holds,RedisClient.none()is unnecessary — one less invalid state to reason about. ButTCPConnection.none()insideRedisClientis still needed (for the same reason it's needed today).embedvsvar: IfRedisClient.none()is unnecessary,embedbecomes viable:embed _redis: RedisClientplaces the object inline in the actor, eliminating heap indirection. This requires thatRedisClientis always fully initialized in the constructor (no reassignment). Worth investigating for performance-sensitive protocol layers.Design question: Which Lori callbacks to expose vs absorb? The protocol interceptor receives all lifecycle callbacks. It must decide which to translate into protocol-level callbacks and which to handle silently.
_on_connected→_on_redis_connectedis obvious. But what about:_on_connecting(inflight_connections)— expose as_on_redis_connecting? Or absorb silently?_on_throttled/_on_unthrottled— the user needs these for backpressure awareness, but should they be renamed to_on_redis_throttledor left out (sincecommand()already returns errors when throttled)?_on_sent/_on_send_failed— absorb and correlate internally, or expose?This "which callbacks to forward" question applies to every protocol layer, not just Redis. It's a design decision that shapes how much Lori leaks through.
Design question: Who manages
expect()? The RESP parser could useexpect()to request exact byte counts (e.g., after parsing$6\r\n, request exactly 8 bytes forfoobar\r\n). Butexpect()can only express "give me exactly N bytes" — it can't express "read until\r\n." RESP's line-based type prefixes (+OK\r\n,:42\r\n,$6\r\n) are variable-length, soexpect()can't help with parsing them — only with the bulk string body after the length is known.This means the protocol layer needs its own buffering regardless.
expect()provides marginal benefit for RESP — it helps with bulk string bodies but not with the variable-length framing headers. This finding generalizes: any protocol with variable-length framing (HTTP headers, SMTP commands, WebSocket after the initial frame header) can't fully leverageexpect(). The protocol layer always needs its own buffer.Design question: Token correlation.
RedisRequestTokentracks which command a response belongs to. Lori'sSendTokentracks "bytes reached the OS." These are independent — a single command might fragment across multiple TCP sends, or multiple pipelined commands might share one send. Since the interceptor sees both (it callssend()and receives_on_sent), it can correlate them or keep them separate. The user probably doesn't needSendTokenat all —RedisRequestTokenplus_on_redis_responseis sufficient.Shape 3: Trait with defaults (B + X + III)
The protocol is a trait that extends Lori's traits and provides default implementations for the lifecycle callbacks. The user implements the protocol trait, which gives them protocol-level callbacks "for free."
Tradeoffs:
is RedisClientActorgives you everything._on_received()(which is a valid method on the trait), the protocol breaks silently. Pony has nofinalkeyword to prevent this. There is no mechanism in the language to make a trait method non-overridable. This is a fundamental weakness, not a fixable one. It makes Shape 3 strictly more fragile than Shape 2, where the interceptor class owns the lifecycle callbacks and the user can't replace them._connection()(Lori's requirement) and_redis_state()(protocol state).TCPConnectionis created withthisas bothenclosingandler. The user's actor IS the lifecycle receiver. The trait provides defaults, but the user can silently override them._on_sent/_on_send_failedhandling is awkward. The trait could provide defaults that correlate tokens, but the state needed for correlation lives inRedisState(accessible only via_redis_state()). The indirection through accessors makes the logic less direct than Shape 2 where the interceptor class holds everything.Server side: A
RedisServerActortrait extendsTCPConnectionActor & ServerLifecycleEventReceiverwith the same pattern. The listener creates the user's actor in_on_acceptas usual. Works, but has the same override hazard.Design question: Can the override hazard be mitigated? Not really. Documentation ("don't override
_on_received") is the only option. Making the trait's_on_receivedcall_redis_state().feed()which then calls back via a second trait doesn't help — it just re-introduces the interceptor pattern (Shape 2) with extra steps. The override hazard is an inherent cost of the trait-with-defaults approach.Shape 4: Delegation without interception (B + X + I)
A hybrid between Shape 2 (interceptor) and the parser-only approach. The user remains the lifecycle receiver but delegates
_on_receivedto a protocol class. The protocol class doesn't implement any Lori trait — it's a pure state machine that accepts bytes, produces protocol events, and receives aTCPConnection refwhen it needs to send.How it differs from Shape 2: The protocol class doesn't implement
ClientLifecycleEventReceiver. It doesn't own theTCPConnection. It doesn't need_connection()ornone(). The user is the lifecycle receiver and writes a few lines of delegation boilerplate.How it differs from the parser-only approach (Shape 5): The protocol class manages state machine logic — pub/sub mode tracking, pipelining request queue, command validation — not just byte parsing.
Tradeoffs:
conn.send(). More reusable, easier to test.none()needed — the protocol class is fully initialized immediately._connection()exposure problem — the user passesTCPConnection refexplicitly when callingcommand(), so there's no accessor for the protocol class to expose.embedis viable since there's nonone()pattern._on_received,_on_connected,_on_closeddelegation manually (3-4 one-line methods).TCPConnectionActorandClientLifecycleEventReceiver. They know Lori exists._on_sent/_on_send_failedland on the user's actor, not the protocol class. The protocol class has no way to correlateSendTokenwithRedisRequestTokenbecause it never sees the callback. The user would need to implement_on_sent, look up theSendTokenin a mapping exposed by the protocol class, and feed it back. This isn't just "harder" — the protocol class structurally cannot provide "your command bytes reached the OS" without the user doing extra wiring. Shape 2's interceptor handles this naturally because it receives both theSendToken(fromsend()) and the_on_sentcallback._on_closed). Shape 2's interceptor guarantees correct delegation by owning the callbacks.Server side: Same pattern — a
RedisServerProtocolclass withreceived()/command_received()methods. The user's server actor delegates_on_receivedand_on_started.Design question: Is the delegation boilerplate a real cost? Shape 4 requires ~4 extra one-line methods compared to Shape 2. But those lines make the lifecycle flow explicit — the user can see that
_on_receivedgoes to the Redis protocol while_on_throttledis handled differently. Whether this explicitness is a feature or noise depends on taste.Shape 5: Parser library only — no state machine driving (B + manual + I)
The protocol library provides RESP parsing and serialization as standalone classes. No lifecycle interception, no callback traits. The user writes a normal Lori actor and calls the parser manually.
Tradeoffs:
But it's a useful building block. Shapes 1–4 all need a RESP parser internally. Shape 5's parser could be the foundation that the other shapes build on. The question is whether Lori should provide the driving layer on top.
Cross-cutting design questions
How does the pub/sub mode switch surface in the API?
In Redis,
SUBSCRIBEchanges what the connection can do. Options:Single type, runtime checks:
RedisClient(or equivalent) has bothcommand()andsubscribe(). Callingcommand()in pub/sub mode returns an error. Simple, but the type system doesn't prevent misuse.Mode-typed connections:
subscribe()returns aRedisPubSubConnectionwith a different API. The original connection becomes unusable. Type-safe, but awkward — the connection object changes out from under you. In the class-in-actor shapes (2, 3), this means swapping the embedded field.Separate connection types: The user creates a
RedisClientfor command mode or aRedisSubscriberfor pub/sub mode. No mode switching — different tools for different jobs. Clean, but doesn't support the actual Redis pattern of switching mid-connection.How does pipelining surface in the API?
In normal mode, the user might send 10 commands before any responses arrive. The protocol layer needs to match responses to commands (they arrive in order). Options:
Token-based:
command()returns a token._on_redis_response(token, value)delivers the response with the matching token. The user correlates. This mirrors Lori'sSendTokenpattern.Callback-per-command:
command(args, callback)takes a closure or handler. The protocol layer calls the right callback when the response arrives. In Pony, this works within a single actor: a lambda{ref(value: RedisValue) => _counter = _counter + 1}captures the actor'srefand can mutate state. The protocol class stores these closures in anArray[{ref(RedisValue)}]— standard Pony. So the capability concern is tractable within Shapes 2–4 (single-actor). In Shape 1 (cross-actor), closures can't capturerefstate from another actor, so this option doesn't work there.Sequential assumption: No explicit correlation. Responses are delivered in order, and the user is expected to know what they sent. Simplest, matches the raw Redis behavior, but fragile.
Where does AUTH fit?
Redis AUTH is just a command, but it typically runs before any other commands. Options:
_on_redis_connected()fires.This is relevant because it shows how "protocol setup" sequences interact with the state machine. SMTP has a similar pattern (EHLO → AUTH before MAIL FROM).
Command-to-send mapping
Lori's
send(data: ByteSeq)accepts a singleByteSeqand returns a singleSendToken. A Redis command serialized as one buffer is onesend()call. But the protocol layer must decide:send()per command: Eachcommand()call serializes and sends immediately. N pipelined commands = Nsend()calls = NSendTokens. Simple correlation, but more syscalls.send(): Multiple pipelined commands are serialized into one buffer and sent as onesend()call. 1SendTokenfor N commands. Fewer syscalls, but_on_sent/_on_send_failedcan't distinguish which commands in the batch were affected.Option 2 means
_on_send_failedfor a batch can't be mapped to individualRedisRequestTokens — all commands in the batch failed. Option 1 gives clean correlation but may hurt throughput. This decision affects theSendToken-to-RedisRequestTokencorrelation logic in Shape 2 (where the interceptor handles it) and Shape 4 (where the user would need to feed tokens back to the protocol class).Receive-side flow control (mute/unmute)
TCPConnectionhasmute()andunmute()to pause/resume reading. The document discusses send-side backpressure (_on_throttled/_on_unthrottled) but receive-side flow control interacts with protocol buffering differently.When
mute()stops new data delivery, the protocol layer's parse buffer may contain partially or fully parsed frames. Options:For Redis pipelining, a user might want to mute after receiving N responses to process them before accepting more. Whether "mute" means "stop reading from TCP" or "stop delivering parsed responses" is a design choice that applies to all shapes and all five target protocols.
TLS interaction with the protocol layer
Redis supports TLS natively (since Redis 6), and Lori has
ssl_client/ssl_serverconstructors plusstart_tls(). How does TLS interact with each shape?In Shape 2, the lifecycle receiver (
RedisClient) receives_on_tls_ready/_on_tls_failurecallbacks. The protocol layer would need to:TCPConnection.ssl_client()/ssl_server())._on_tls_readyto_on_redis_connected(or a Redis-specific TLS callback).In Shape 4 (delegation), the user receives TLS callbacks directly and must coordinate with the protocol class manually.
This matters more for SMTP than Redis: SMTP's STARTTLS is a protocol command — the protocol state machine itself decides when to upgrade. The protocol layer must call
start_tls()on theTCPConnectionat the right moment in the state machine, then handle the callbacks. This interaction between protocol state machines and TLS should influence which shapes are viable for STARTTLS-heavy protocols.Protocol-level error handling
When the RESP parser encounters malformed data (incomplete frame, invalid type byte, protocol violation), what happens?
hard_close()on theTCPConnection. The user gets_on_redis_closed()but may not understand why._on_redis_error(MalformedData)(or similar) before closing, giving the user a chance to log or react.This is analogous to Lori's own error strategy: SSL errors trigger
hard_close()with specific failure callbacks. The protocol layer should probably follow the same pattern — error callback followed by close — rather than trying to recover from a corrupted byte stream.Multiple connections per actor
Lori's design assumes one connection per actor:
_connection()returns a singleTCPConnection. But Redis applications commonly use multiple connections — one for commands, one for pub/sub (since pub/sub blocks the connection from normal commands).In Shape 1, multiple connections are natural — just create multiple
RedisConnectionactors. In Shapes 2–4, each connection requires its own actor because of the one-_connection()constraint. This means a Redis application using both command mode and pub/sub needs at least two actors in Shapes 2–4, even though the application might prefer to handle both in one place.This is a Lori-level constraint, not a protocol-level one. If Lori's design eventually evolves (e.g., Discussion #174's state object refactoring), multiple connections per actor might become possible, which would change the protocol layer design space significantly.
Should the protocol layer own reconnection?
The current shapes all represent a single connection. Reconnection (detect disconnect, reconnect, re-subscribe to pub/sub channels, replay failed commands) is a common Redis client feature. Options:
This question applies to all five target protocols, not just Redis.
The
_connection()accessor problemIn Shapes 2 and 3, the user's actor implements
TCPConnectionActor, which requiresfun ref _connection(): TCPConnection. But theTCPConnectionlives inside the protocol class. So the user writesfun ref _connection(): TCPConnection => _redis.connection()— delegating through the protocol layer.This has two distinct problems:
Bypass risk: The user could call
_connection().send(raw_bytes)directly, bypassing the protocol layer and corrupting the state machine.Load-bearing dispatch chain: In Shape 2,
_notify_sentand_notify_send_failed(behaviors onTCPConnectionActor) call_connection()._fire_on_sent(token), which dispatches to the lifecycle event receiver (i.e.,RedisClient). This means the user's_connection()delegation (_redis.connection()) is not just an accessor — it's the active dispatch path for send token callbacks. If the user writes_connection()incorrectly (returnsTCPConnection.none(), or some other connection), send token callbacks silently vanish. The protocol layer's correctness depends on the user implementing this one-line delegation correctly. Shape 4 (delegation without interception) avoids this because the user is the lifecycle receiver directly, and_connection()returns theTCPConnectionthey own.Is this:
TCPConnectionActorrequires_connection())?TCPConnection?TCPConnectiondirectly)?A Lori API change could help here: if
TCPConnectionActordidn't require_connection()as a public accessor but instead took theTCPConnectionreference internally (e.g., at construction time), the protocol class could hold the connection without the user having a path to it. This is one of those cases where the exploration suggests Lori's own API might want to evolve.Interception at the ASIO layer vs the lifecycle layer
All shapes that intercept callbacks do so at the lifecycle receiver level (
_on_received,_on_connected, etc.). There's another interception point: theTCPConnectionActorbehaviors (_event_notify,_read_again,_notify_sent,_notify_send_failed). A protocol layer could theoretically wrap these instead.This is almost certainly wrong — these behaviors are ASIO plumbing, not application logic. Intercepting them would mean reimplementing Lori's event loop. The lifecycle receiver layer is the correct interception point because it's the boundary between "TCP connection management" (Lori's job) and "what to do with the bytes" (the protocol's job). Mentioned here to close off the alternative explicitly.
Does this generalize?
The whole point of building Redis first is to discover what Lori should eventually provide as generic protocol infrastructure. Looking at the shapes:
_on_received, parses bytes, calls protocol callbacks" could be a generic framework. Lori could provide aProtocolClient[State, Callbacks]base class.ProtocolDriverclass that the protocol state machine plugs into, reducing the boilerplate.Shape 2 (interceptor) and Shape 4 (delegation) are the most promising for generalization. Shape 2 is more seamless for the user but couples the protocol class to Lori's lifecycle traits. Shape 4 keeps the protocol class Lori-independent, which makes it easier to test and reuse, at the cost of a few lines of boilerplate per actor. The choice between them might vary by protocol: simple protocols (Redis) might prefer Shape 4's simplicity, while complex protocols with TLS interaction (SMTP) might benefit from Shape 2's interceptor having direct access to the lifecycle.
Protocol-aware listeners
All shapes treat the listener as unchanged — the user always writes a
TCPListenerActor. But for server-side protocols, the listener often needs protocol-level configuration: SMTP server banners, HTTP server-level middleware, WebSocket upgrade negotiation, connection limits per protocol state._on_accept(fd: U32): TCPConnectionActorconstrains what the listener can do — it returns aTCPConnectionActor, so the per-connection actor must be created there. The protocol layer needs to flow configuration from the listener to each connection. Options:User's listener, protocol class per connection: The user writes a
TCPListenerActorthat creates protocol-aware connection actors in_on_accept. Configuration lives on the listener and is passed to each connection actor's constructor. This is the pattern all shapes assume today.Library-provided listener: The protocol library provides a listener actor (e.g.,
RedisListener) that wrapsTCPListenerActorandTCPListener. The user provides a connection factory. But this requires the library to provide listener actors for every protocol, and the user loses listener-level customization.Listener trait: A protocol-specific trait extends
TCPListenerActorand provides defaults for_on_accept(creating the right connection actor type). The user implements the trait and provides protocol configuration via accessor methods. Similar to Shape 3's approach at the connection level, with the same override hazard.For the five target protocols, listener integration varies in complexity: Redis and SMTP servers need minimal listener customization, HTTP servers need routing and middleware configuration at the listener level, and WebSocket servers need to coordinate the HTTP upgrade handshake at the listener/connection boundary. The listener integration pattern deserves attention as protocols are built, even though it may not need to be solved generically upfront.
Communication pattern diversity
Redis in normal mode is request-response, but the five target protocols span very different communication patterns:
Any generic "protocol state machine driver" framework would need to handle all of these, not just request-response. The interceptor pattern (Shape 2) is agnostic to communication direction — it just parses incoming bytes and provides methods for sending — so it naturally accommodates all patterns. The trait-with-defaults pattern (Shape 3) is similarly agnostic. The library-actor pattern (Shape 1) would need different actor shapes for different communication patterns (a request-response actor vs a bidirectional actor vs a push-only actor), which is more fragmented.
What Lori API changes would help
While the constraint is "Lori stays the same," the exploration reveals several places where Lori changes would improve protocol layering:
_connection()off the lifecycle receiver traits:ClientLifecycleEventReceiverandServerLifecycleEventReceiverboth require_connection(). This means any class implementing them must own or access theTCPConnection. If the lifecycle receiver were a pure callback trait (no_connection()requirement), interceptor classes would be simpler.expect(N)handles length-prefixed (framed) protocols but not delimiter-based (line-oriented) protocols like RESP's\r\n-terminated headers. Rather than adding a parallelexpect_until(delimiter)API — which creates two competing interfaces that could be mixed in confusing ways — the underlying concept is that both are buffering strategies: ways of telling Lori "don't deliver data to_on_receiveduntil a complete unit is ready." The design question is what a pluggable buffering strategy interface looks like, so Lori can support both framed and line-oriented protocols through one clean abstraction rather than accumulating ad-hoc methods.Beta Was this translation helpful? Give feedback.
All reactions