@@ -14,7 +14,7 @@ import zio.Chunk
1414 * Key types:
1515 * - SessionStateMachine: Abstract base class (template pattern)
1616 * - SessionSchema: Fixed 4-prefix schema for session management
17- * - CombinedSchema : Type-level concatenation of SessionSchema + UserSchema
17+ * - Schema : Type-level concatenation of SessionSchema + UserSchema
1818 * - SessionCommand: ADT of commands the session state machine accepts
1919 */
2020package object sessionstatemachine {
@@ -73,49 +73,94 @@ package object sessionstatemachine {
7373 *
7474 * This schema defines 4 prefixes with their key and value types:
7575 * - "metadata": (SessionId, SessionMetadata) - session information
76- * - "cache": (SessionId, Map[ RequestId, Any] ) - cached responses per session for idempotency
77- * - "serverRequests": (SessionId, List[ PendingServerRequest[?]] ) - pending requests per session
76+ * - "cache": (( SessionId, RequestId) , Any) - cached responses with composite key for efficient range queries and streaming
77+ * - "serverRequests": (( SessionId, RequestId), PendingServerRequest[?]) - pending requests with composite key for efficiency
7878 * - "lastServerRequestId": (SessionId, RequestId) - last assigned server request ID per session
7979 *
80- * All keys use SessionId for type safety and efficiency.
81- *
82- * The cache design groups responses by session, making cleanup O(1) instead of O(n):
83- * - Cache lookup: get session's map, then lookup requestId
84- * - Cache cleanup: get session's map, filter by lowestRequestId, update once
85- * - Session expiration: remove single cache entry (not iterate all requests)
86- *
87- * The SessionStateMachine base class uses this schema to manage session state
88- * automatically. Users don't interact with this schema directly - it's an
89- * implementation detail of the template pattern.
80+ * Both cache and serverRequests use composite keys (SessionId, RequestId) for better performance:
81+ * - Direct key access: O(log n) lookups
82+ * - Range queries: Efficient iteration over session-specific entries
83+ * - Session expiration: Use range to find all entries for a session
84+ * - Streaming-friendly: Each entry is a separate key-value pair, not nested collections
85+ * - Proper ordering: RequestId ordering is numeric (big-endian encoding), not lexicographic
86+ * - No data duplication: sessionId and requestId are not stored in the value, only in the key
9087 */
9188 type SessionSchema =
9289 (" metadata" , SessionId , SessionMetadata ) *:
93- (" cache" , SessionId , Map [ RequestId , Any ] ) *:
94- (" serverRequests" , SessionId , List [ PendingServerRequest [? ] ]) *:
90+ (" cache" , ( SessionId , RequestId ) , Any ) *:
91+ (" serverRequests" , ( SessionId , RequestId ), PendingServerRequest [? ]) *:
9592 (" lastServerRequestId" , SessionId , RequestId ) *:
9693 EmptyTuple
9794
9895 /**
99- * Combined schema that concatenates SessionSchema and UserSchema.
100- *
101- * This type alias uses Tuple.Concat to merge the session management prefixes
102- * with the user-defined schema at the type level. The result is a schema with
103- * both session prefixes and user prefixes, all with compile-time type safety.
104- *
105- * @tparam UserSchema The user-defined schema (tuple of (Prefix, KeyType, ValueType) triples)
106- *
107- * Example:
108- * {{{
109- * type MyUserSchema = ("counter", CounterId, Int) *: ("name", NameId, String) *: EmptyTuple
110- * type MyCombined = CombinedSchema[MyUserSchema]
111- * // MyCombined has all 4 SessionSchema prefixes plus "counter" and "name"
112- * }}}
96+ * KeyLike instance for SessionId keys.
97+ * Used by metadata, serverRequests, and lastServerRequestId prefixes.
11398 */
114- type CombinedSchema [ UserSchema <: Tuple ] = Tuple . Concat [ SessionSchema , UserSchema ]
99+ given HMap . KeyLike [ SessionId ] = HMap . KeyLike .forNewtype( SessionId )
115100
116101 /**
117- * KeyLike instance for SessionId keys.
118- * All session management prefixes use SessionId as the key type.
102+ * KeyLike instance for composite (SessionId, RequestId) keys.
103+ * Used by the cache prefix for efficient range queries and proper numeric ordering of RequestIds.
104+ *
105+ * Encoding format:
106+ * - 4 bytes: length of SessionId (big-endian Int)
107+ * - N bytes: SessionId as UTF-8 string
108+ * - 8 bytes: RequestId as big-endian Long
109+ *
110+ * This encoding ensures:
111+ * 1. Sessions are grouped together (sorted by SessionId first)
112+ * 2. Within a session, RequestIds are ordered numerically (not lexicographically)
113+ * 3. Range queries work efficiently: range((sid, rid1), (sid, rid2)) selects requests within the range
119114 */
120- given HMap .KeyLike [SessionId ] = HMap .KeyLike .forNewtype(SessionId )
115+ given HMap .KeyLike [(SessionId , RequestId )] = new HMap .KeyLike [(SessionId , RequestId )]:
116+ import java .nio .charset .StandardCharsets
117+
118+ def asBytes (key : (SessionId , RequestId )): Array [Byte ] =
119+ // Encode SessionId as UTF-8
120+ val sessionBytes = SessionId .unwrap(key._1).getBytes(StandardCharsets .UTF_8 )
121+
122+ // Encode RequestId as 8-byte big-endian long
123+ val requestId = RequestId .unwrap(key._2)
124+ val requestBytes = Array (
125+ (requestId >> 56 ).toByte, (requestId >> 48 ).toByte,
126+ (requestId >> 40 ).toByte, (requestId >> 32 ).toByte,
127+ (requestId >> 24 ).toByte, (requestId >> 16 ).toByte,
128+ (requestId >> 8 ).toByte, requestId.toByte
129+ )
130+
131+ // Length-prefix the SessionId (4 bytes big-endian)
132+ val lengthBytes = Array (
133+ (sessionBytes.length >> 24 ).toByte,
134+ (sessionBytes.length >> 16 ).toByte,
135+ (sessionBytes.length >> 8 ).toByte,
136+ sessionBytes.length.toByte
137+ )
138+
139+ lengthBytes ++ sessionBytes ++ requestBytes
140+
141+ def fromBytes (bytes : Array [Byte ]): (SessionId , RequestId ) =
142+ // Read length prefix
143+ val length =
144+ ((bytes(0 ) & 0xFF ) << 24 ) |
145+ ((bytes(1 ) & 0xFF ) << 16 ) |
146+ ((bytes(2 ) & 0xFF ) << 8 ) |
147+ (bytes(3 ) & 0xFF )
148+
149+ // Extract SessionId
150+ val sessionBytes = bytes.slice(4 , 4 + length)
151+ val sessionId = SessionId (new String (sessionBytes, StandardCharsets .UTF_8 ))
152+
153+ // Extract RequestId (8 bytes big-endian)
154+ val offset = 4 + length
155+ val requestId =
156+ ((bytes(offset).toLong & 0xFF ) << 56 ) |
157+ ((bytes(offset + 1 ).toLong & 0xFF ) << 48 ) |
158+ ((bytes(offset + 2 ).toLong & 0xFF ) << 40 ) |
159+ ((bytes(offset + 3 ).toLong & 0xFF ) << 32 ) |
160+ ((bytes(offset + 4 ).toLong & 0xFF ) << 24 ) |
161+ ((bytes(offset + 5 ).toLong & 0xFF ) << 16 ) |
162+ ((bytes(offset + 6 ).toLong & 0xFF ) << 8 ) |
163+ (bytes(offset + 7 ).toLong & 0xFF )
164+
165+ (sessionId, RequestId (requestId))
121166}
0 commit comments