zeitlich 0.2.44 → 0.2.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -10
- package/dist/{activities-CPIB2v2C.d.ts → activities-Bm4TLTid.d.ts} +24 -4
- package/dist/{activities-DnmNOnq4.d.cts → activities-CyeiqK_f.d.cts} +24 -4
- package/dist/adapters/sandbox/daytona/index.d.cts +2 -2
- package/dist/adapters/sandbox/daytona/index.d.ts +2 -2
- package/dist/adapters/sandbox/daytona/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.ts +1 -1
- package/dist/adapters/sandbox/e2b/index.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/index.d.ts +1 -1
- package/dist/adapters/thread/anthropic/index.cjs +171 -65
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +19 -4
- package/dist/adapters/thread/anthropic/index.d.ts +19 -4
- package/dist/adapters/thread/anthropic/index.js +171 -65
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.cjs +3 -1
- package/dist/adapters/thread/anthropic/workflow.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +4 -4
- package/dist/adapters/thread/anthropic/workflow.d.ts +4 -4
- package/dist/adapters/thread/anthropic/workflow.js +3 -1
- package/dist/adapters/thread/anthropic/workflow.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +171 -69
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +5 -4
- package/dist/adapters/thread/google-genai/index.d.ts +5 -4
- package/dist/adapters/thread/google-genai/index.js +171 -69
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.cjs +3 -1
- package/dist/adapters/thread/google-genai/workflow.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +5 -4
- package/dist/adapters/thread/google-genai/workflow.d.ts +5 -4
- package/dist/adapters/thread/google-genai/workflow.js +3 -1
- package/dist/adapters/thread/google-genai/workflow.js.map +1 -1
- package/dist/adapters/thread/langchain/index.cjs +181 -77
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +18 -4
- package/dist/adapters/thread/langchain/index.d.ts +18 -4
- package/dist/adapters/thread/langchain/index.js +182 -74
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.cjs +3 -1
- package/dist/adapters/thread/langchain/workflow.cjs.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +4 -4
- package/dist/adapters/thread/langchain/workflow.d.ts +4 -4
- package/dist/adapters/thread/langchain/workflow.js +3 -1
- package/dist/adapters/thread/langchain/workflow.js.map +1 -1
- package/dist/cold-store-BC5L5Z8A.d.cts +117 -0
- package/dist/cold-store-CFHwemBJ.d.ts +117 -0
- package/dist/index.cjs +252 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +138 -8
- package/dist/index.d.ts +138 -8
- package/dist/index.js +247 -54
- package/dist/index.js.map +1 -1
- package/dist/{proxy-DTnc5rqT.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
- package/dist/{proxy-B7Xi1znZ.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
- package/dist/{thread-manager-BlX2TwRN.d.cts → thread-manager-9tezUcLW.d.cts} +9 -3
- package/dist/{thread-manager-BAv340mi.d.ts → thread-manager-B-zy3xrs.d.ts} +9 -3
- package/dist/{thread-manager-D2xorI-J.d.ts → thread-manager-D33SUmZa.d.cts} +10 -4
- package/dist/{thread-manager-BWv6ZXI3.d.cts → thread-manager-DduoSkvJ.d.ts} +10 -4
- package/dist/{types-C90VoEpt.d.cts → types-CjY93AWZ.d.cts} +1 -1
- package/dist/{types-4Wmk-wRq.d.cts → types-CnuN9T6t.d.cts} +23 -1
- package/dist/{types-DKsCdAtQ.d.ts → types-CwN6_tAL.d.ts} +23 -1
- package/dist/{types-Clhqautb.d.ts → types-L5bvbF-n.d.ts} +17 -1
- package/dist/{types-DpFD8ofR.d.ts → types-gVa5XCWD.d.ts} +1 -1
- package/dist/{types-DRJt1TMi.d.cts → types-oxt8GN97.d.cts} +17 -1
- package/dist/{workflow-D32TRMr-.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
- package/dist/{workflow-XVt0ww8K.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
- package/dist/workflow.cjs +29 -19
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +2 -2
- package/dist/workflow.d.ts +2 -2
- package/dist/workflow.js +29 -19
- package/dist/workflow.js.map +1 -1
- package/package.json +6 -1
- package/src/adapters/thread/anthropic/activities.ts +72 -36
- package/src/adapters/thread/anthropic/thread-manager.ts +9 -1
- package/src/adapters/thread/google-genai/activities.ts +64 -40
- package/src/adapters/thread/google-genai/thread-manager.ts +9 -1
- package/src/adapters/thread/langchain/activities.ts +63 -36
- package/src/adapters/thread/langchain/thread-manager.ts +9 -1
- package/src/index.ts +20 -1
- package/src/lib/session/session-edge-cases.integration.test.ts +12 -0
- package/src/lib/session/session.integration.test.ts +138 -0
- package/src/lib/session/session.ts +47 -22
- package/src/lib/session/types.ts +22 -0
- package/src/lib/thread/cold-store.test.ts +193 -0
- package/src/lib/thread/cold-store.ts +250 -0
- package/src/lib/thread/index.ts +32 -0
- package/src/lib/thread/keys.ts +20 -0
- package/src/lib/thread/manager.ts +16 -27
- package/src/lib/thread/proxy.ts +2 -0
- package/src/lib/thread/snapshot.test.ts +443 -0
- package/src/lib/thread/snapshot.ts +163 -0
- package/src/lib/thread/test-utils.ts +228 -0
- package/src/lib/thread/tiered.test.ts +281 -0
- package/src/lib/thread/tiered.ts +135 -0
- package/src/lib/thread/types.ts +16 -0
- package/src/lib/.env +0 -1
- package/src/tools/bash/.env +0 -1
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import type Redis from "ioredis";
|
|
3
|
+
import {
|
|
4
|
+
applySnapshot,
|
|
5
|
+
clearHotTier,
|
|
6
|
+
encodeSnapshot,
|
|
7
|
+
} from "./snapshot";
|
|
8
|
+
import { createThreadManager } from "./manager";
|
|
9
|
+
import {
|
|
10
|
+
getThreadDedupKey,
|
|
11
|
+
getThreadListKey,
|
|
12
|
+
getThreadMetaKey,
|
|
13
|
+
getThreadStateKey,
|
|
14
|
+
} from "./keys";
|
|
15
|
+
import type { PersistedThreadState } from "../state/types";
|
|
16
|
+
import type { ThreadSnapshot } from "./cold-store";
|
|
17
|
+
import { createFakeRedis } from "./test-utils";
|
|
18
|
+
|
|
19
|
+
const sampleState: PersistedThreadState = {
|
|
20
|
+
tasks: [
|
|
21
|
+
[
|
|
22
|
+
"t1",
|
|
23
|
+
{
|
|
24
|
+
id: "t1",
|
|
25
|
+
subject: "do thing",
|
|
26
|
+
description: "do it",
|
|
27
|
+
activeForm: "doing",
|
|
28
|
+
status: "pending",
|
|
29
|
+
metadata: {},
|
|
30
|
+
blockedBy: [],
|
|
31
|
+
blocks: [],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
],
|
|
35
|
+
custom: { counter: 3 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("encodeSnapshot", () => {
|
|
39
|
+
let redis: Redis & { _store: Map<string, unknown> };
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
redis = createFakeRedis();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns null when the thread has not been initialized", async () => {
|
|
46
|
+
expect(
|
|
47
|
+
await encodeSnapshot({ redis, threadKey: "messages", threadId: "t-1" })
|
|
48
|
+
).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("captures messages, state, and dedup ids when idOf is provided", async () => {
|
|
52
|
+
const tm = createThreadManager<{ id: string; text: string }>({
|
|
53
|
+
redis,
|
|
54
|
+
threadId: "t-1",
|
|
55
|
+
idOf: (m) => m.id,
|
|
56
|
+
});
|
|
57
|
+
await tm.initialize();
|
|
58
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
59
|
+
await tm.append([{ id: "m2", text: "world" }]);
|
|
60
|
+
await tm.saveState(sampleState);
|
|
61
|
+
|
|
62
|
+
const snap = await encodeSnapshot({
|
|
63
|
+
redis,
|
|
64
|
+
threadKey: "messages",
|
|
65
|
+
threadId: "t-1",
|
|
66
|
+
idOf: (raw) => (JSON.parse(raw) as { id: string }).id,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(snap).not.toBeNull();
|
|
70
|
+
if (!snap) throw new Error("expected snapshot");
|
|
71
|
+
expect(snap.v).toBe(1);
|
|
72
|
+
expect(snap.messages.map((raw) => JSON.parse(raw) as { id: string })).toEqual([
|
|
73
|
+
{ id: "m1", text: "hello" },
|
|
74
|
+
{ id: "m2", text: "world" },
|
|
75
|
+
]);
|
|
76
|
+
expect(snap.state).toEqual(sampleState);
|
|
77
|
+
expect(snap.dedupIds).toEqual(["m1", "m2"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns an empty dedupIds list when idOf is omitted", async () => {
|
|
81
|
+
const tm = createThreadManager<{ id: string }>({
|
|
82
|
+
redis,
|
|
83
|
+
threadId: "t-1",
|
|
84
|
+
idOf: (m) => m.id,
|
|
85
|
+
});
|
|
86
|
+
await tm.initialize();
|
|
87
|
+
await tm.append([{ id: "m1" }]);
|
|
88
|
+
|
|
89
|
+
const snap = await encodeSnapshot({
|
|
90
|
+
redis,
|
|
91
|
+
threadKey: "messages",
|
|
92
|
+
threadId: "t-1",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(snap?.dedupIds).toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("applySnapshot", () => {
|
|
100
|
+
let redis: Redis & { _store: Map<string, unknown> };
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
redis = createFakeRedis();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("restores messages + state + dedup keys into Redis", async () => {
|
|
107
|
+
const snap: ThreadSnapshot = {
|
|
108
|
+
v: 1,
|
|
109
|
+
messages: [
|
|
110
|
+
JSON.stringify({ id: "m1", text: "hello" }),
|
|
111
|
+
JSON.stringify({ id: "m2", text: "world" }),
|
|
112
|
+
],
|
|
113
|
+
state: sampleState,
|
|
114
|
+
dedupIds: ["m1", "m2"],
|
|
115
|
+
};
|
|
116
|
+
await applySnapshot({
|
|
117
|
+
redis,
|
|
118
|
+
threadKey: "messages",
|
|
119
|
+
threadId: "t-1",
|
|
120
|
+
snapshot: snap,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const tm = createThreadManager<{ id: string; text: string }>({
|
|
124
|
+
redis,
|
|
125
|
+
threadId: "t-1",
|
|
126
|
+
idOf: (m) => m.id,
|
|
127
|
+
});
|
|
128
|
+
expect(await tm.load()).toEqual([
|
|
129
|
+
{ id: "m1", text: "hello" },
|
|
130
|
+
{ id: "m2", text: "world" },
|
|
131
|
+
]);
|
|
132
|
+
expect(await tm.loadState()).toEqual(sampleState);
|
|
133
|
+
|
|
134
|
+
// Re-appending m1 should be deduped because dedup key was re-primed.
|
|
135
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
136
|
+
expect(await tm.length()).toBe(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("is idempotent when the thread is already hot", async () => {
|
|
140
|
+
const tm = createThreadManager<{ id: string }>({
|
|
141
|
+
redis,
|
|
142
|
+
threadId: "t-1",
|
|
143
|
+
idOf: (m) => m.id,
|
|
144
|
+
});
|
|
145
|
+
await tm.initialize();
|
|
146
|
+
await tm.append([{ id: "existing" }]);
|
|
147
|
+
|
|
148
|
+
const snap: ThreadSnapshot = {
|
|
149
|
+
v: 1,
|
|
150
|
+
messages: [JSON.stringify({ id: "from-snapshot" })],
|
|
151
|
+
state: null,
|
|
152
|
+
dedupIds: ["from-snapshot"],
|
|
153
|
+
};
|
|
154
|
+
await applySnapshot({
|
|
155
|
+
redis,
|
|
156
|
+
threadKey: "messages",
|
|
157
|
+
threadId: "t-1",
|
|
158
|
+
snapshot: snap,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(await tm.load()).toEqual([{ id: "existing" }]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("handles an empty snapshot (just sets the meta marker)", async () => {
|
|
165
|
+
const snap: ThreadSnapshot = {
|
|
166
|
+
v: 1,
|
|
167
|
+
messages: [],
|
|
168
|
+
state: null,
|
|
169
|
+
dedupIds: [],
|
|
170
|
+
};
|
|
171
|
+
await applySnapshot({
|
|
172
|
+
redis,
|
|
173
|
+
threadKey: "messages",
|
|
174
|
+
threadId: "t-1",
|
|
175
|
+
snapshot: snap,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const metaKey = getThreadMetaKey("messages", "t-1");
|
|
179
|
+
expect(await redis.exists(metaKey)).toBe(1);
|
|
180
|
+
expect(
|
|
181
|
+
await redis.lrange(getThreadListKey("messages", "t-1"), 0, -1)
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("throws and writes nothing when the residue-cleanup DEL fails", async () => {
|
|
186
|
+
// Stub `redis.del` to reject (mimicking ACL deny, CROSSSLOT, etc.).
|
|
187
|
+
// The fix guarantees no writes hit the wire when this happens —
|
|
188
|
+
// the queued data writes never run because DEL is awaited
|
|
189
|
+
// outside the pipeline.
|
|
190
|
+
const wrapped = new Proxy(redis, {
|
|
191
|
+
get(target, prop, receiver): unknown {
|
|
192
|
+
if (prop === "del") {
|
|
193
|
+
return async (..._keys: string[]): Promise<number> => {
|
|
194
|
+
throw new Error("NOPERM: DEL denied by ACL");
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return Reflect.get(target, prop, receiver) as unknown;
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const snap: ThreadSnapshot = {
|
|
202
|
+
v: 1,
|
|
203
|
+
messages: [JSON.stringify({ id: "m1" })],
|
|
204
|
+
state: sampleState,
|
|
205
|
+
dedupIds: ["m1"],
|
|
206
|
+
};
|
|
207
|
+
await expect(
|
|
208
|
+
applySnapshot({
|
|
209
|
+
redis: wrapped as unknown as Redis,
|
|
210
|
+
threadKey: "messages",
|
|
211
|
+
threadId: "t-del-fail",
|
|
212
|
+
snapshot: snap,
|
|
213
|
+
})
|
|
214
|
+
).rejects.toThrow("NOPERM: DEL denied by ACL");
|
|
215
|
+
|
|
216
|
+
// No data writes happened — list, state, meta, and dedup are all
|
|
217
|
+
// still untouched on the underlying store.
|
|
218
|
+
expect(
|
|
219
|
+
await redis.exists(getThreadListKey("messages", "t-del-fail"))
|
|
220
|
+
).toBe(0);
|
|
221
|
+
expect(
|
|
222
|
+
await redis.exists(getThreadStateKey("messages", "t-del-fail"))
|
|
223
|
+
).toBe(0);
|
|
224
|
+
expect(
|
|
225
|
+
await redis.exists(getThreadMetaKey("messages", "t-del-fail"))
|
|
226
|
+
).toBe(0);
|
|
227
|
+
expect(
|
|
228
|
+
await redis.exists(getThreadDedupKey("t-del-fail", "m1"))
|
|
229
|
+
).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("clears list/state/dedup residue when a pipelined write fails partway", async () => {
|
|
233
|
+
// Pipeline stub that *applies* non-failing commands to the
|
|
234
|
+
// underlying fake (so residue is observable) and errors on rpush
|
|
235
|
+
// — mimicking a partial OOM where the list write fails but the
|
|
236
|
+
// dedup SETs queued after it still land. Without compensating
|
|
237
|
+
// cleanup, those stale dedup keys could silently skip a future
|
|
238
|
+
// append with the same id.
|
|
239
|
+
const wrapped = new Proxy(redis, {
|
|
240
|
+
get(target, prop, receiver): unknown {
|
|
241
|
+
if (prop === "pipeline") {
|
|
242
|
+
return (): Record<string, unknown> => {
|
|
243
|
+
type Op = { method: string; args: unknown[] };
|
|
244
|
+
const ops: Op[] = [];
|
|
245
|
+
const chain: Record<string, unknown> = {};
|
|
246
|
+
for (const m of ["set", "del", "rpush", "expire"]) {
|
|
247
|
+
chain[m] = (...args: unknown[]): Record<string, unknown> => {
|
|
248
|
+
ops.push({ method: m, args });
|
|
249
|
+
return chain;
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
chain.exec = async (): Promise<Array<[Error | null, unknown]>> => {
|
|
253
|
+
const out: Array<[Error | null, unknown]> = [];
|
|
254
|
+
const callable = target as unknown as Record<
|
|
255
|
+
string,
|
|
256
|
+
(...a: unknown[]) => Promise<unknown>
|
|
257
|
+
>;
|
|
258
|
+
for (const op of ops) {
|
|
259
|
+
if (op.method === "rpush") {
|
|
260
|
+
out.push([new Error("OOM"), null]);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const fn = callable[op.method];
|
|
264
|
+
if (!fn) throw new Error(`stub: unknown ${op.method}`);
|
|
265
|
+
const result = await fn(...op.args);
|
|
266
|
+
out.push([null, result]);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
};
|
|
270
|
+
return chain;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return Reflect.get(target, prop, receiver) as unknown;
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const snap: ThreadSnapshot = {
|
|
278
|
+
v: 1,
|
|
279
|
+
messages: [JSON.stringify({ id: "m1" })],
|
|
280
|
+
state: sampleState,
|
|
281
|
+
dedupIds: ["m1", "m2"],
|
|
282
|
+
};
|
|
283
|
+
await expect(
|
|
284
|
+
applySnapshot({
|
|
285
|
+
redis: wrapped as unknown as Redis,
|
|
286
|
+
threadKey: "messages",
|
|
287
|
+
threadId: "t-residue",
|
|
288
|
+
snapshot: snap,
|
|
289
|
+
})
|
|
290
|
+
).rejects.toThrow("OOM");
|
|
291
|
+
|
|
292
|
+
// Every key the failed pipeline touched is cleared on the throw
|
|
293
|
+
// path — no list, state, meta, or dedup residue survives.
|
|
294
|
+
expect(
|
|
295
|
+
await redis.exists(getThreadListKey("messages", "t-residue"))
|
|
296
|
+
).toBe(0);
|
|
297
|
+
expect(
|
|
298
|
+
await redis.exists(getThreadStateKey("messages", "t-residue"))
|
|
299
|
+
).toBe(0);
|
|
300
|
+
expect(
|
|
301
|
+
await redis.exists(getThreadMetaKey("messages", "t-residue"))
|
|
302
|
+
).toBe(0);
|
|
303
|
+
expect(await redis.exists(getThreadDedupKey("t-residue", "m1"))).toBe(0);
|
|
304
|
+
expect(await redis.exists(getThreadDedupKey("t-residue", "m2"))).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("throws and leaves the meta key unset when a pipelined command fails", async () => {
|
|
308
|
+
// Wrap the fake's `pipeline()` so `exec()` returns a tuple list
|
|
309
|
+
// containing a per-command error — mimicking ioredis's behaviour
|
|
310
|
+
// when Redis runtime errors (OOM, ACL, WRONGTYPE) occur inside a
|
|
311
|
+
// pipeline. The top-level promise resolves; the error lives in the
|
|
312
|
+
// result tuple, and applySnapshot must surface it.
|
|
313
|
+
const wrapped = new Proxy(redis, {
|
|
314
|
+
get(target, prop, receiver): unknown {
|
|
315
|
+
if (prop === "pipeline") {
|
|
316
|
+
return (): Record<string, unknown> => {
|
|
317
|
+
type Op = { method: string; args: unknown[] };
|
|
318
|
+
const ops: Op[] = [];
|
|
319
|
+
const chain: Record<string, unknown> = {};
|
|
320
|
+
for (const m of ["set", "del", "rpush", "expire"]) {
|
|
321
|
+
chain[m] = (...args: unknown[]): Record<string, unknown> => {
|
|
322
|
+
ops.push({ method: m, args });
|
|
323
|
+
return chain;
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
chain.exec = async (): Promise<Array<[Error | null, unknown]>> =>
|
|
327
|
+
ops.map((op, i) =>
|
|
328
|
+
op.method === "rpush"
|
|
329
|
+
? [new Error("OOM command not allowed"), null]
|
|
330
|
+
: [null, i]
|
|
331
|
+
);
|
|
332
|
+
return chain;
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return Reflect.get(target, prop, receiver) as unknown;
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
const snap: ThreadSnapshot = {
|
|
340
|
+
v: 1,
|
|
341
|
+
messages: [JSON.stringify({ id: "m1" })],
|
|
342
|
+
state: sampleState,
|
|
343
|
+
dedupIds: ["m1"],
|
|
344
|
+
};
|
|
345
|
+
await expect(
|
|
346
|
+
applySnapshot({
|
|
347
|
+
redis: wrapped as unknown as Redis,
|
|
348
|
+
threadKey: "messages",
|
|
349
|
+
threadId: "t-fail",
|
|
350
|
+
snapshot: snap,
|
|
351
|
+
})
|
|
352
|
+
).rejects.toThrow("OOM command not allowed");
|
|
353
|
+
|
|
354
|
+
expect(await redis.exists(getThreadMetaKey("messages", "t-fail"))).toBe(0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("issues a single pipeline.exec() rather than per-key writes", async () => {
|
|
358
|
+
let pipelineCalls = 0;
|
|
359
|
+
const wrapped = new Proxy(redis, {
|
|
360
|
+
get(target, prop, receiver): unknown {
|
|
361
|
+
if (prop === "pipeline") {
|
|
362
|
+
return (): unknown => {
|
|
363
|
+
pipelineCalls++;
|
|
364
|
+
return (target as unknown as { pipeline: () => unknown }).pipeline();
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
return Reflect.get(target, prop, receiver) as unknown;
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const snap: ThreadSnapshot = {
|
|
372
|
+
v: 1,
|
|
373
|
+
messages: Array.from({ length: 10 }, (_, i) =>
|
|
374
|
+
JSON.stringify({ id: `m${i}` })
|
|
375
|
+
),
|
|
376
|
+
state: sampleState,
|
|
377
|
+
dedupIds: Array.from({ length: 10 }, (_, i) => `m${i}`),
|
|
378
|
+
};
|
|
379
|
+
await applySnapshot({
|
|
380
|
+
redis: wrapped as unknown as Redis,
|
|
381
|
+
threadKey: "messages",
|
|
382
|
+
threadId: "t-1",
|
|
383
|
+
snapshot: snap,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(pipelineCalls).toBe(1);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("clears any partial residue from a previous failed restore", async () => {
|
|
390
|
+
const listKey = getThreadListKey("messages", "t-1");
|
|
391
|
+
const stateKey = getThreadStateKey("messages", "t-1");
|
|
392
|
+
await redis.rpush(listKey, "stale-message");
|
|
393
|
+
await redis.set(stateKey, JSON.stringify({ stale: true }));
|
|
394
|
+
// Note: meta is intentionally absent — simulates a half-written restore.
|
|
395
|
+
|
|
396
|
+
const snap: ThreadSnapshot = {
|
|
397
|
+
v: 1,
|
|
398
|
+
messages: [JSON.stringify({ id: "fresh" })],
|
|
399
|
+
state: sampleState,
|
|
400
|
+
dedupIds: ["fresh"],
|
|
401
|
+
};
|
|
402
|
+
await applySnapshot({
|
|
403
|
+
redis,
|
|
404
|
+
threadKey: "messages",
|
|
405
|
+
threadId: "t-1",
|
|
406
|
+
snapshot: snap,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const tm = createThreadManager<{ id: string }>({
|
|
410
|
+
redis,
|
|
411
|
+
threadId: "t-1",
|
|
412
|
+
idOf: (m) => m.id,
|
|
413
|
+
});
|
|
414
|
+
expect(await tm.load()).toEqual([{ id: "fresh" }]);
|
|
415
|
+
expect(await tm.loadState()).toEqual(sampleState);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe("clearHotTier", () => {
|
|
420
|
+
it("removes list, meta, state, and dedup keys", async () => {
|
|
421
|
+
const redis = createFakeRedis();
|
|
422
|
+
const tm = createThreadManager<{ id: string }>({
|
|
423
|
+
redis,
|
|
424
|
+
threadId: "t-1",
|
|
425
|
+
idOf: (m) => m.id,
|
|
426
|
+
});
|
|
427
|
+
await tm.initialize();
|
|
428
|
+
await tm.append([{ id: "m1" }]);
|
|
429
|
+
await tm.saveState(sampleState);
|
|
430
|
+
|
|
431
|
+
await clearHotTier({
|
|
432
|
+
redis,
|
|
433
|
+
threadKey: "messages",
|
|
434
|
+
threadId: "t-1",
|
|
435
|
+
dedupIds: ["m1"],
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(await redis.exists(getThreadListKey("messages", "t-1"))).toBe(0);
|
|
439
|
+
expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
|
|
440
|
+
expect(await redis.exists(getThreadStateKey("messages", "t-1"))).toBe(0);
|
|
441
|
+
expect(await redis.exists(getThreadDedupKey("t-1", "m1"))).toBe(0);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Redis I/O helpers for moving a thread between the hot tier
|
|
3
|
+
* (Redis lists + meta + state + dedup markers) and the cold tier
|
|
4
|
+
* (a single {@link ThreadSnapshot} blob in a {@link ColdThreadStore}).
|
|
5
|
+
*
|
|
6
|
+
* These helpers know nothing about S3 or the adapter-specific message
|
|
7
|
+
* envelope — they operate on the raw Redis representation. The
|
|
8
|
+
* tiered thread manager in `tiered.ts` is the only consumer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type Redis from "ioredis";
|
|
12
|
+
import type { PersistedThreadState } from "../state/types";
|
|
13
|
+
import type { ThreadSnapshot } from "./cold-store";
|
|
14
|
+
import {
|
|
15
|
+
THREAD_TTL_SECONDS,
|
|
16
|
+
getThreadDedupKey,
|
|
17
|
+
getThreadListKey,
|
|
18
|
+
getThreadMetaKey,
|
|
19
|
+
getThreadStateKey,
|
|
20
|
+
} from "./keys";
|
|
21
|
+
|
|
22
|
+
/** Inputs shared by every snapshot operation. */
|
|
23
|
+
interface SnapshotCommon {
|
|
24
|
+
redis: Redis;
|
|
25
|
+
threadKey: string;
|
|
26
|
+
threadId: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Configuration for {@link encodeSnapshot}. */
|
|
30
|
+
export interface EncodeSnapshotConfig extends SnapshotCommon {
|
|
31
|
+
/**
|
|
32
|
+
* Extract a dedup id from each raw-serialized message currently in
|
|
33
|
+
* the thread's Redis list. When omitted, the resulting snapshot has
|
|
34
|
+
* an empty `dedupIds` array — idempotency guarantees are best-effort
|
|
35
|
+
* once a thread crosses the hot/cold boundary.
|
|
36
|
+
*/
|
|
37
|
+
idOf?: (raw: string) => string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a {@link ThreadSnapshot} from the current hot-tier state.
|
|
42
|
+
*
|
|
43
|
+
* Returns `null` when no thread exists in the hot tier (the meta key
|
|
44
|
+
* is absent) — callers should treat that as "nothing to flush".
|
|
45
|
+
*/
|
|
46
|
+
export async function encodeSnapshot(
|
|
47
|
+
config: EncodeSnapshotConfig
|
|
48
|
+
): Promise<ThreadSnapshot | null> {
|
|
49
|
+
const { redis, threadKey, threadId, idOf } = config;
|
|
50
|
+
const metaKey = getThreadMetaKey(threadKey, threadId);
|
|
51
|
+
if ((await redis.exists(metaKey)) === 0) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const listKey = getThreadListKey(threadKey, threadId);
|
|
55
|
+
const stateKey = getThreadStateKey(threadKey, threadId);
|
|
56
|
+
const messages = await redis.lrange(listKey, 0, -1);
|
|
57
|
+
const stateRaw = await redis.get(stateKey);
|
|
58
|
+
const state =
|
|
59
|
+
stateRaw == null ? null : (JSON.parse(stateRaw) as PersistedThreadState);
|
|
60
|
+
const dedupIds = idOf ? messages.map(idOf) : [];
|
|
61
|
+
return { v: 1, messages, state, dedupIds };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Configuration for {@link applySnapshot}. */
|
|
65
|
+
export interface ApplySnapshotConfig extends SnapshotCommon {
|
|
66
|
+
snapshot: ThreadSnapshot;
|
|
67
|
+
/** TTL applied to every Redis key. Defaults to {@link THREAD_TTL_SECONDS}. */
|
|
68
|
+
ttlSeconds?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Restore a {@link ThreadSnapshot} into the hot tier.
|
|
73
|
+
*
|
|
74
|
+
* Idempotent — if the meta key already exists the thread is already
|
|
75
|
+
* hot and this is a no-op. The meta key is written **last** so a
|
|
76
|
+
* crash mid-restore leaves the thread cold (`load` / `append` will
|
|
77
|
+
* see "thread does not exist") and the next session's hydrate retries
|
|
78
|
+
* cleanly.
|
|
79
|
+
*/
|
|
80
|
+
export async function applySnapshot(
|
|
81
|
+
config: ApplySnapshotConfig
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const {
|
|
84
|
+
redis,
|
|
85
|
+
threadKey,
|
|
86
|
+
threadId,
|
|
87
|
+
snapshot,
|
|
88
|
+
ttlSeconds = THREAD_TTL_SECONDS,
|
|
89
|
+
} = config;
|
|
90
|
+
const metaKey = getThreadMetaKey(threadKey, threadId);
|
|
91
|
+
if ((await redis.exists(metaKey)) === 1) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const listKey = getThreadListKey(threadKey, threadId);
|
|
95
|
+
const stateKey = getThreadStateKey(threadKey, threadId);
|
|
96
|
+
|
|
97
|
+
// Clear partial residue from any prior half-restored attempt.
|
|
98
|
+
// Awaited *outside* the pipeline so a DEL failure (ACL deny,
|
|
99
|
+
// CROSSSLOT, …) short-circuits before any writes hit the wire —
|
|
100
|
+
// pipelines are non-atomic, so a queued DEL wouldn't stop later
|
|
101
|
+
// commands from accumulating data behind a missing meta marker.
|
|
102
|
+
await redis.del(listKey, stateKey);
|
|
103
|
+
|
|
104
|
+
// Pipeline the data writes (list/state/dedup) in one round-trip.
|
|
105
|
+
// Meta is written separately, only after every queued command
|
|
106
|
+
// succeeded, preserving the "meta-last" crash-safety invariant —
|
|
107
|
+
// a partial restore must leave meta absent so the next hydrate
|
|
108
|
+
// retries cleanly.
|
|
109
|
+
const pipeline = redis.pipeline();
|
|
110
|
+
if (snapshot.messages.length > 0) {
|
|
111
|
+
pipeline.rpush(listKey, ...snapshot.messages);
|
|
112
|
+
pipeline.expire(listKey, ttlSeconds);
|
|
113
|
+
}
|
|
114
|
+
if (snapshot.state != null) {
|
|
115
|
+
pipeline.set(stateKey, JSON.stringify(snapshot.state), "EX", ttlSeconds);
|
|
116
|
+
}
|
|
117
|
+
for (const id of snapshot.dedupIds) {
|
|
118
|
+
pipeline.set(getThreadDedupKey(threadId, id), "1", "EX", ttlSeconds);
|
|
119
|
+
}
|
|
120
|
+
const results = await pipeline.exec();
|
|
121
|
+
if (results) {
|
|
122
|
+
const firstErr = results.find(([err]) => err)?.[0] ?? null;
|
|
123
|
+
if (firstErr) {
|
|
124
|
+
// Compensate: pipelines are non-atomic, so writes queued after
|
|
125
|
+
// a failing command (notably dedup SETs) may have landed. Best-
|
|
126
|
+
// effort clear every key we touched so a leftover dedup marker
|
|
127
|
+
// can't silently skip a future append with the same id.
|
|
128
|
+
await redis
|
|
129
|
+
.del(
|
|
130
|
+
listKey,
|
|
131
|
+
stateKey,
|
|
132
|
+
...snapshot.dedupIds.map((id) => getThreadDedupKey(threadId, id))
|
|
133
|
+
)
|
|
134
|
+
.catch(() => undefined);
|
|
135
|
+
throw firstErr;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await redis.set(metaKey, "1", "EX", ttlSeconds);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Configuration for {@link clearHotTier}. */
|
|
142
|
+
export interface ClearHotTierConfig extends SnapshotCommon {
|
|
143
|
+
/** Dedup ids to delete alongside the list / meta / state keys. */
|
|
144
|
+
dedupIds?: string[];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Delete every Redis key the thread manager wrote for `(threadKey,
|
|
149
|
+
* threadId)`. Used by the tiered manager's `flush({ deleteHot: true })`
|
|
150
|
+
* to drop hot-tier memory after a successful archive write.
|
|
151
|
+
*/
|
|
152
|
+
export async function clearHotTier(
|
|
153
|
+
config: ClearHotTierConfig
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const { redis, threadKey, threadId, dedupIds = [] } = config;
|
|
156
|
+
const keys = [
|
|
157
|
+
getThreadListKey(threadKey, threadId),
|
|
158
|
+
getThreadMetaKey(threadKey, threadId),
|
|
159
|
+
getThreadStateKey(threadKey, threadId),
|
|
160
|
+
...dedupIds.map((id) => getThreadDedupKey(threadId, id)),
|
|
161
|
+
];
|
|
162
|
+
await redis.del(...keys);
|
|
163
|
+
}
|