zeitlich 0.2.45 → 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-CrN-ghLo.d.ts → activities-Bm4TLTid.d.ts} +22 -2
- package/dist/{activities-Coafq5zr.d.cts → activities-CyeiqK_f.d.cts} +22 -2
- 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 +170 -66
- 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 +170 -66
- 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 +226 -27
- 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 +220 -28
- package/dist/index.js.map +1 -1
- package/dist/{proxy-Bf7uI-Hw.d.cts → proxy-BxFyd6cg.d.cts} +1 -1
- package/dist/{proxy-COqA95FW.d.ts → proxy-Cskmj4Yx.d.ts} +1 -1
- package/dist/{thread-manager-BsLO3Fgc.d.cts → thread-manager-9tezUcLW.d.cts} +8 -2
- package/dist/{thread-manager-Bi1XlbpJ.d.ts → thread-manager-B-zy3xrs.d.ts} +8 -2
- package/dist/{thread-manager-wRVVBFgj.d.cts → thread-manager-D33SUmZa.d.cts} +8 -2
- package/dist/{thread-manager-BhkOyQ1I.d.ts → thread-manager-DduoSkvJ.d.ts} +8 -2
- package/dist/{types-CdALEF3z.d.cts → types-CnuN9T6t.d.cts} +22 -0
- package/dist/{types-ChAy_jSP.d.ts → types-CwN6_tAL.d.ts} +22 -0
- package/dist/{types-BkX4HLzi.d.ts → types-L5bvbF-n.d.ts} +17 -1
- package/dist/{types-C66-BVBr.d.cts → types-oxt8GN97.d.cts} +17 -1
- package/dist/{workflow-BwT5EybR.d.ts → workflow-B1TOcHbt.d.ts} +33 -2
- package/dist/{workflow-DMmiaw6w.d.cts → workflow-DIaIV7L2.d.cts} +33 -2
- package/dist/workflow.cjs +14 -1
- 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 +14 -1
- 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 +29 -0
- 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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory fakes used by the thread-storage unit tests.
|
|
3
|
+
*
|
|
4
|
+
* Lives outside `*.test.ts` so multiple test files can share the
|
|
5
|
+
* same fake without copy/pasting. Not exported from the package — kept
|
|
6
|
+
* inside `src/lib/thread` so type imports work and the test runner
|
|
7
|
+
* picks it up directly.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type Redis from "ioredis";
|
|
11
|
+
import type { ColdThreadStore, ThreadSnapshot } from "./cold-store";
|
|
12
|
+
|
|
13
|
+
type Value = string | string[];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal in-memory Redis stub covering the commands the thread
|
|
17
|
+
* manager + snapshot helpers use: get/set/del/exists/expire,
|
|
18
|
+
* lrange/rpush/llen/ltrim, and the `eval`-based idempotent-append Lua
|
|
19
|
+
* script. Behaviour matches Redis closely enough for unit tests; TTLs
|
|
20
|
+
* are stored but never expire automatically.
|
|
21
|
+
*/
|
|
22
|
+
export function createFakeRedis(): Redis & {
|
|
23
|
+
_store: Map<string, Value>;
|
|
24
|
+
_ttls: Map<string, number>;
|
|
25
|
+
} {
|
|
26
|
+
const store = new Map<string, Value>();
|
|
27
|
+
const ttls = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
const isList = (k: string): boolean => Array.isArray(store.get(k));
|
|
30
|
+
const ensureList = (k: string): string[] => {
|
|
31
|
+
const v = store.get(k);
|
|
32
|
+
if (v === undefined) {
|
|
33
|
+
const fresh: string[] = [];
|
|
34
|
+
store.set(k, fresh);
|
|
35
|
+
return fresh;
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(v)) throw new Error(`WRONGTYPE: ${k} is not a list`);
|
|
38
|
+
return v;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const fake = {
|
|
42
|
+
async get(key: string): Promise<string | null> {
|
|
43
|
+
const v = store.get(key);
|
|
44
|
+
if (v === undefined) return null;
|
|
45
|
+
if (Array.isArray(v)) throw new Error(`WRONGTYPE: ${key} is a list`);
|
|
46
|
+
return v;
|
|
47
|
+
},
|
|
48
|
+
async set(
|
|
49
|
+
key: string,
|
|
50
|
+
value: string,
|
|
51
|
+
..._rest: (string | number)[]
|
|
52
|
+
): Promise<"OK"> {
|
|
53
|
+
// NX guard: when the args contain "NX" and the key already exists,
|
|
54
|
+
// Redis returns null. We follow the same contract for tests that
|
|
55
|
+
// need it; existing call sites use this for compare-and-set.
|
|
56
|
+
const rest = _rest.map((x) => (typeof x === "string" ? x.toUpperCase() : x));
|
|
57
|
+
if (rest.includes("NX") && store.has(key)) {
|
|
58
|
+
return null as unknown as "OK";
|
|
59
|
+
}
|
|
60
|
+
store.set(key, String(value));
|
|
61
|
+
const exIdx = rest.indexOf("EX");
|
|
62
|
+
if (exIdx >= 0 && typeof _rest[exIdx + 1] === "number") {
|
|
63
|
+
ttls.set(key, _rest[exIdx + 1] as number);
|
|
64
|
+
}
|
|
65
|
+
return "OK";
|
|
66
|
+
},
|
|
67
|
+
async del(...keys: string[]): Promise<number> {
|
|
68
|
+
let removed = 0;
|
|
69
|
+
for (const k of keys) {
|
|
70
|
+
if (store.delete(k)) removed++;
|
|
71
|
+
ttls.delete(k);
|
|
72
|
+
}
|
|
73
|
+
return removed;
|
|
74
|
+
},
|
|
75
|
+
async exists(...keys: string[]): Promise<number> {
|
|
76
|
+
return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
77
|
+
},
|
|
78
|
+
async expire(key: string, ttl: number): Promise<number> {
|
|
79
|
+
if (!store.has(key)) return 0;
|
|
80
|
+
ttls.set(key, ttl);
|
|
81
|
+
return 1;
|
|
82
|
+
},
|
|
83
|
+
async lrange(key: string, start: number, end: number): Promise<string[]> {
|
|
84
|
+
if (!store.has(key)) return [];
|
|
85
|
+
if (!isList(key)) return [];
|
|
86
|
+
const list = store.get(key) as string[];
|
|
87
|
+
const last = end === -1 ? list.length - 1 : end;
|
|
88
|
+
return list.slice(start, last + 1);
|
|
89
|
+
},
|
|
90
|
+
async rpush(key: string, ...values: string[]): Promise<number> {
|
|
91
|
+
const list = ensureList(key);
|
|
92
|
+
list.push(...values);
|
|
93
|
+
return list.length;
|
|
94
|
+
},
|
|
95
|
+
async llen(key: string): Promise<number> {
|
|
96
|
+
if (!store.has(key)) return 0;
|
|
97
|
+
const list = store.get(key) as string[];
|
|
98
|
+
return list.length;
|
|
99
|
+
},
|
|
100
|
+
async ltrim(key: string, start: number, end: number): Promise<"OK"> {
|
|
101
|
+
if (!store.has(key)) return "OK";
|
|
102
|
+
const list = store.get(key) as string[];
|
|
103
|
+
const last = end === -1 ? list.length - 1 : end;
|
|
104
|
+
store.set(key, list.slice(start, last + 1));
|
|
105
|
+
return "OK";
|
|
106
|
+
},
|
|
107
|
+
async eval(
|
|
108
|
+
_script: string,
|
|
109
|
+
numKeys: number,
|
|
110
|
+
...args: (string | number)[]
|
|
111
|
+
): Promise<number> {
|
|
112
|
+
// Mirrors APPEND_IDEMPOTENT_SCRIPT in src/lib/thread/manager.ts.
|
|
113
|
+
const keys = args.slice(0, numKeys) as string[];
|
|
114
|
+
const argv = args.slice(numKeys) as string[];
|
|
115
|
+
const dedupKey = keys[0];
|
|
116
|
+
const listKey = keys[1];
|
|
117
|
+
const ttl = Number(argv[0]);
|
|
118
|
+
const messages = argv.slice(1);
|
|
119
|
+
if (dedupKey === undefined || listKey === undefined) {
|
|
120
|
+
throw new Error("eval stub: missing keys");
|
|
121
|
+
}
|
|
122
|
+
if (store.has(dedupKey)) return 0;
|
|
123
|
+
const list = ensureList(listKey);
|
|
124
|
+
list.push(...messages);
|
|
125
|
+
ttls.set(listKey, ttl);
|
|
126
|
+
store.set(dedupKey, "1");
|
|
127
|
+
ttls.set(dedupKey, ttl);
|
|
128
|
+
return 1;
|
|
129
|
+
},
|
|
130
|
+
// Chainable pipeline stub. Defers each command to the underlying
|
|
131
|
+
// sync fake methods on `.exec()`, so TTL tracking and store
|
|
132
|
+
// semantics stay identical to the non-pipelined path. `fake` is
|
|
133
|
+
// typed as `Redis` after the cast below, so we narrow it back to
|
|
134
|
+
// the concrete impl shape here to avoid Redis's callback overloads.
|
|
135
|
+
pipeline(): FakePipeline {
|
|
136
|
+
const impl = fake as unknown as {
|
|
137
|
+
set: (key: string, value: string, ...rest: (string | number)[]) => Promise<"OK">;
|
|
138
|
+
del: (...keys: string[]) => Promise<number>;
|
|
139
|
+
rpush: (key: string, ...values: string[]) => Promise<number>;
|
|
140
|
+
expire: (key: string, ttl: number) => Promise<number>;
|
|
141
|
+
};
|
|
142
|
+
const ops: Array<() => Promise<unknown>> = [];
|
|
143
|
+
const chain: FakePipeline = {
|
|
144
|
+
set: (...args) => {
|
|
145
|
+
const [key, value, ...rest] = args as [string, string, ...(string | number)[]];
|
|
146
|
+
ops.push(() => impl.set(key, value, ...rest));
|
|
147
|
+
return chain;
|
|
148
|
+
},
|
|
149
|
+
del: (...keys) => {
|
|
150
|
+
ops.push(() => impl.del(...keys));
|
|
151
|
+
return chain;
|
|
152
|
+
},
|
|
153
|
+
rpush: (key, ...values) => {
|
|
154
|
+
ops.push(() => impl.rpush(key, ...values));
|
|
155
|
+
return chain;
|
|
156
|
+
},
|
|
157
|
+
expire: (key, ttl) => {
|
|
158
|
+
ops.push(() => impl.expire(key, ttl));
|
|
159
|
+
return chain;
|
|
160
|
+
},
|
|
161
|
+
exec: async () => {
|
|
162
|
+
const results: Array<[Error | null, unknown]> = [];
|
|
163
|
+
for (const op of ops) {
|
|
164
|
+
try {
|
|
165
|
+
results.push([null, await op()]);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
results.push([e as Error, null]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return results;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return chain;
|
|
174
|
+
},
|
|
175
|
+
_store: store,
|
|
176
|
+
_ttls: ttls,
|
|
177
|
+
} as unknown as Redis & {
|
|
178
|
+
_store: Map<string, Value>;
|
|
179
|
+
_ttls: Map<string, number>;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return fake;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Minimal chainable surface used by the fake-redis pipeline stub. */
|
|
186
|
+
interface FakePipeline {
|
|
187
|
+
set: (...args: (string | number)[]) => FakePipeline;
|
|
188
|
+
del: (...keys: string[]) => FakePipeline;
|
|
189
|
+
rpush: (key: string, ...values: string[]) => FakePipeline;
|
|
190
|
+
expire: (key: string, ttl: number) => FakePipeline;
|
|
191
|
+
exec: () => Promise<Array<[Error | null, unknown]>>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* In-memory `ColdThreadStore` used by the tiered manager tests. Spies
|
|
196
|
+
* on read/write/delete call counts so tests can assert idempotency
|
|
197
|
+
* and call sequencing.
|
|
198
|
+
*/
|
|
199
|
+
export function createMemoryColdStore(): ColdThreadStore & {
|
|
200
|
+
_snapshots: Map<string, ThreadSnapshot>;
|
|
201
|
+
_calls: { read: number; write: number; delete: number };
|
|
202
|
+
} {
|
|
203
|
+
const snapshots = new Map<string, ThreadSnapshot>();
|
|
204
|
+
const calls = { read: 0, write: 0, delete: 0 };
|
|
205
|
+
const compositeKey = (threadKey: string, threadId: string): string =>
|
|
206
|
+
`${threadKey}::${threadId}`;
|
|
207
|
+
return {
|
|
208
|
+
async read(threadKey: string, threadId: string) {
|
|
209
|
+
calls.read++;
|
|
210
|
+
return snapshots.get(compositeKey(threadKey, threadId)) ?? null;
|
|
211
|
+
},
|
|
212
|
+
async write(threadKey: string, threadId: string, snapshot: ThreadSnapshot) {
|
|
213
|
+
calls.write++;
|
|
214
|
+
// Clone to mirror real-world serialization (S3 round-trips
|
|
215
|
+
// through JSON).
|
|
216
|
+
snapshots.set(
|
|
217
|
+
compositeKey(threadKey, threadId),
|
|
218
|
+
JSON.parse(JSON.stringify(snapshot)) as ThreadSnapshot
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
async delete(threadKey: string, threadId: string) {
|
|
222
|
+
calls.delete++;
|
|
223
|
+
snapshots.delete(compositeKey(threadKey, threadId));
|
|
224
|
+
},
|
|
225
|
+
_snapshots: snapshots,
|
|
226
|
+
_calls: calls,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import type Redis from "ioredis";
|
|
3
|
+
import { createTieredThreadManager } from "./tiered";
|
|
4
|
+
import { createThreadManager } from "./manager";
|
|
5
|
+
import {
|
|
6
|
+
getThreadDedupKey,
|
|
7
|
+
getThreadListKey,
|
|
8
|
+
getThreadMetaKey,
|
|
9
|
+
getThreadStateKey,
|
|
10
|
+
} from "./keys";
|
|
11
|
+
import type { PersistedThreadState } from "../state/types";
|
|
12
|
+
import { createFakeRedis, createMemoryColdStore } from "./test-utils";
|
|
13
|
+
|
|
14
|
+
interface TestMsg {
|
|
15
|
+
id: string;
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
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: 7 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function makeTiered(
|
|
39
|
+
redis: Redis,
|
|
40
|
+
threadId: string,
|
|
41
|
+
coldStore?: ReturnType<typeof createMemoryColdStore>,
|
|
42
|
+
ttlSeconds?: number
|
|
43
|
+
) {
|
|
44
|
+
return createTieredThreadManager<TestMsg>({
|
|
45
|
+
redis,
|
|
46
|
+
threadId,
|
|
47
|
+
idOf: (m) => m.id,
|
|
48
|
+
...(coldStore && { coldStore }),
|
|
49
|
+
...(ttlSeconds !== undefined && { ttlSeconds }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("createTieredThreadManager — no cold store", () => {
|
|
54
|
+
it("hydrate is a no-op when no cold store is wired", async () => {
|
|
55
|
+
const redis = createFakeRedis();
|
|
56
|
+
const tm = makeTiered(redis, "t-1");
|
|
57
|
+
await tm.hydrate();
|
|
58
|
+
expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("flush is a no-op when no cold store is wired", async () => {
|
|
62
|
+
const redis = createFakeRedis();
|
|
63
|
+
const tm = makeTiered(redis, "t-1");
|
|
64
|
+
await tm.initialize();
|
|
65
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
66
|
+
await tm.flush();
|
|
67
|
+
expect(await tm.load()).toEqual([{ id: "m1", text: "hello" }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("delegates all BaseThreadManager ops to the underlying Redis manager", async () => {
|
|
71
|
+
const redis = createFakeRedis();
|
|
72
|
+
const tm = makeTiered(redis, "t-1");
|
|
73
|
+
await tm.initialize();
|
|
74
|
+
await tm.append([
|
|
75
|
+
{ id: "m1", text: "a" },
|
|
76
|
+
{ id: "m2", text: "b" },
|
|
77
|
+
]);
|
|
78
|
+
await tm.saveState(sampleState);
|
|
79
|
+
|
|
80
|
+
expect(await tm.length()).toBe(2);
|
|
81
|
+
expect(await tm.loadState()).toEqual(sampleState);
|
|
82
|
+
expect(await tm.load()).toEqual([
|
|
83
|
+
{ id: "m1", text: "a" },
|
|
84
|
+
{ id: "m2", text: "b" },
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
await tm.truncateFromId("m2");
|
|
88
|
+
expect(await tm.load()).toEqual([{ id: "m1", text: "a" }]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("createTieredThreadManager — with cold store", () => {
|
|
93
|
+
let redis: Redis;
|
|
94
|
+
let cold: ReturnType<typeof createMemoryColdStore>;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
redis = createFakeRedis();
|
|
98
|
+
cold = createMemoryColdStore();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("flush writes messages + state + dedupIds to the cold store", async () => {
|
|
102
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
103
|
+
await tm.initialize();
|
|
104
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
105
|
+
await tm.append([{ id: "m2", text: "world" }]);
|
|
106
|
+
await tm.saveState(sampleState);
|
|
107
|
+
|
|
108
|
+
await tm.flush({ deleteHot: false });
|
|
109
|
+
|
|
110
|
+
const snap = cold._snapshots.get("messages::t-1");
|
|
111
|
+
expect(snap).toBeDefined();
|
|
112
|
+
if (!snap) throw new Error("expected snapshot");
|
|
113
|
+
expect(snap.v).toBe(1);
|
|
114
|
+
expect(snap.messages.map((raw) => JSON.parse(raw) as TestMsg)).toEqual([
|
|
115
|
+
{ id: "m1", text: "hello" },
|
|
116
|
+
{ id: "m2", text: "world" },
|
|
117
|
+
]);
|
|
118
|
+
expect(snap.state).toEqual(sampleState);
|
|
119
|
+
expect(snap.dedupIds).toEqual(["m1", "m2"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("flush is a no-op when the thread is cold (no hot tier present)", async () => {
|
|
123
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
124
|
+
await tm.flush();
|
|
125
|
+
expect(cold._calls.write).toBe(0);
|
|
126
|
+
expect(cold._snapshots.size).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("defaults to deleting the hot tier after a successful flush", async () => {
|
|
130
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
131
|
+
await tm.initialize();
|
|
132
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
133
|
+
await tm.saveState(sampleState);
|
|
134
|
+
|
|
135
|
+
await tm.flush();
|
|
136
|
+
|
|
137
|
+
expect(await redis.exists(getThreadListKey("messages", "t-1"))).toBe(0);
|
|
138
|
+
expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
|
|
139
|
+
expect(await redis.exists(getThreadStateKey("messages", "t-1"))).toBe(0);
|
|
140
|
+
expect(await redis.exists(getThreadDedupKey("t-1", "m1"))).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("flush({ deleteHot: false }) leaves the hot tier intact", async () => {
|
|
144
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
145
|
+
await tm.initialize();
|
|
146
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
147
|
+
|
|
148
|
+
await tm.flush({ deleteHot: false });
|
|
149
|
+
|
|
150
|
+
expect(await tm.load()).toEqual([{ id: "m1", text: "hello" }]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("hydrate restores messages + state + dedup markers from the cold store", async () => {
|
|
154
|
+
// Seed the cold store via a separate manager instance.
|
|
155
|
+
const seed = makeTiered(redis, "t-1", cold);
|
|
156
|
+
await seed.initialize();
|
|
157
|
+
await seed.append([
|
|
158
|
+
{ id: "m1", text: "a" },
|
|
159
|
+
{ id: "m2", text: "b" },
|
|
160
|
+
]);
|
|
161
|
+
await seed.saveState(sampleState);
|
|
162
|
+
await seed.flush(); // archives + drops hot tier
|
|
163
|
+
|
|
164
|
+
expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
|
|
165
|
+
|
|
166
|
+
// Restore into a fresh manager on the same Redis.
|
|
167
|
+
const restored = makeTiered(redis, "t-1", cold);
|
|
168
|
+
await restored.hydrate();
|
|
169
|
+
|
|
170
|
+
expect(await restored.load()).toEqual([
|
|
171
|
+
{ id: "m1", text: "a" },
|
|
172
|
+
{ id: "m2", text: "b" },
|
|
173
|
+
]);
|
|
174
|
+
expect(await restored.loadState()).toEqual(sampleState);
|
|
175
|
+
|
|
176
|
+
// Re-append m1 — should be skipped because dedup keys were re-primed.
|
|
177
|
+
await restored.append([{ id: "m1", text: "a" }]);
|
|
178
|
+
expect(await restored.length()).toBe(2);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("hydrate is idempotent — no-op when the thread is already hot", async () => {
|
|
182
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
183
|
+
await tm.initialize();
|
|
184
|
+
await tm.append([{ id: "existing", text: "in redis" }]);
|
|
185
|
+
|
|
186
|
+
// Pre-seed the cold store with different content.
|
|
187
|
+
await cold.write("messages", "t-1", {
|
|
188
|
+
v: 1,
|
|
189
|
+
messages: [JSON.stringify({ id: "from-cold", text: "different" })],
|
|
190
|
+
state: sampleState,
|
|
191
|
+
dedupIds: ["from-cold"],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await tm.hydrate();
|
|
195
|
+
|
|
196
|
+
expect(await tm.load()).toEqual([{ id: "existing", text: "in redis" }]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("hydrate is a no-op when nothing is archived in the cold store", async () => {
|
|
200
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
201
|
+
await tm.hydrate();
|
|
202
|
+
expect(await redis.exists(getThreadMetaKey("messages", "t-1"))).toBe(0);
|
|
203
|
+
expect(cold._calls.read).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("flush → hydrate is a full round-trip for an empty thread", async () => {
|
|
207
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
208
|
+
await tm.initialize();
|
|
209
|
+
await tm.flush();
|
|
210
|
+
|
|
211
|
+
const restored = makeTiered(redis, "t-1", cold);
|
|
212
|
+
await restored.hydrate();
|
|
213
|
+
expect(await restored.load()).toEqual([]);
|
|
214
|
+
expect(await restored.length()).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("repeated flushes converge (idempotent cold writes)", async () => {
|
|
218
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
219
|
+
await tm.initialize();
|
|
220
|
+
await tm.append([{ id: "m1", text: "hello" }]);
|
|
221
|
+
await tm.flush({ deleteHot: false });
|
|
222
|
+
await tm.flush({ deleteHot: false });
|
|
223
|
+
expect(cold._calls.write).toBe(2);
|
|
224
|
+
const snap = cold._snapshots.get("messages::t-1");
|
|
225
|
+
if (!snap) throw new Error("expected snapshot");
|
|
226
|
+
expect(snap.messages).toHaveLength(1);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("repeated hydrates converge (only the first one writes Redis)", async () => {
|
|
230
|
+
await cold.write("messages", "t-1", {
|
|
231
|
+
v: 1,
|
|
232
|
+
messages: [JSON.stringify({ id: "m1", text: "hello" })],
|
|
233
|
+
state: null,
|
|
234
|
+
dedupIds: ["m1"],
|
|
235
|
+
});
|
|
236
|
+
const tm = makeTiered(redis, "t-1", cold);
|
|
237
|
+
await tm.hydrate();
|
|
238
|
+
await tm.hydrate();
|
|
239
|
+
expect(await tm.load()).toEqual([{ id: "m1", text: "hello" }]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("fork from a hydrated source preserves messages in the new thread", async () => {
|
|
243
|
+
// Archive a source thread into cold storage and drop its hot tier.
|
|
244
|
+
const seed = makeTiered(redis, "src", cold);
|
|
245
|
+
await seed.initialize();
|
|
246
|
+
await seed.append([{ id: "m1", text: "a" }]);
|
|
247
|
+
await seed.append([{ id: "m2", text: "b" }]);
|
|
248
|
+
await seed.flush();
|
|
249
|
+
|
|
250
|
+
// Hydrate source (mirrors what session.ts does for mode:"fork")
|
|
251
|
+
// then fork into a new thread on the same Redis.
|
|
252
|
+
const src = makeTiered(redis, "src", cold);
|
|
253
|
+
await src.hydrate();
|
|
254
|
+
await src.fork("forked");
|
|
255
|
+
|
|
256
|
+
const target = createThreadManager<TestMsg>({
|
|
257
|
+
redis,
|
|
258
|
+
threadId: "forked",
|
|
259
|
+
idOf: (m) => m.id,
|
|
260
|
+
});
|
|
261
|
+
expect(await target.load()).toEqual([
|
|
262
|
+
{ id: "m1", text: "a" },
|
|
263
|
+
{ id: "m2", text: "b" },
|
|
264
|
+
]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("honors a custom ttlSeconds when restoring", async () => {
|
|
268
|
+
await cold.write("messages", "t-1", {
|
|
269
|
+
v: 1,
|
|
270
|
+
messages: [JSON.stringify({ id: "m1", text: "hi" })],
|
|
271
|
+
state: null,
|
|
272
|
+
dedupIds: ["m1"],
|
|
273
|
+
});
|
|
274
|
+
const tm = makeTiered(redis, "t-1", cold, 60);
|
|
275
|
+
await tm.hydrate();
|
|
276
|
+
const ttls = (redis as unknown as { _ttls: Map<string, number> })._ttls;
|
|
277
|
+
expect(ttls.get(getThreadMetaKey("messages", "t-1"))).toBe(60);
|
|
278
|
+
expect(ttls.get(getThreadListKey("messages", "t-1"))).toBe(60);
|
|
279
|
+
expect(ttls.get(getThreadDedupKey("t-1", "m1"))).toBe(60);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiered thread manager: Redis hot tier + pluggable cold tier.
|
|
3
|
+
*
|
|
4
|
+
* Wraps {@link createThreadManager} (Redis-only) and adds two
|
|
5
|
+
* session-boundary operations:
|
|
6
|
+
*
|
|
7
|
+
* - `hydrate()` — when the thread is cold (no meta key in Redis),
|
|
8
|
+
* restore the latest {@link ThreadSnapshot} from the cold store.
|
|
9
|
+
* Idempotent; no-op when the thread is already hot or when no
|
|
10
|
+
* snapshot exists.
|
|
11
|
+
* - `flush({ deleteHot })` — write the current Redis state out to the
|
|
12
|
+
* cold store as one snapshot, then (by default) `DEL` the hot-tier
|
|
13
|
+
* keys so idle threads don't sit in Redis memory.
|
|
14
|
+
*
|
|
15
|
+
* All other operations (`append`, `load`, `fork`, `replaceAll`,
|
|
16
|
+
* `truncateFromId`, state I/O) delegate unchanged to the underlying
|
|
17
|
+
* Redis manager, so adapters and tests that use the
|
|
18
|
+
* `BaseThreadManager<T>` interface keep working with zero changes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createThreadManager } from "./manager";
|
|
22
|
+
import { THREAD_TTL_SECONDS } from "./keys";
|
|
23
|
+
import type { BaseThreadManager, ThreadManagerConfig } from "./types";
|
|
24
|
+
import type { ColdThreadStore } from "./cold-store";
|
|
25
|
+
import {
|
|
26
|
+
applySnapshot,
|
|
27
|
+
clearHotTier,
|
|
28
|
+
encodeSnapshot,
|
|
29
|
+
} from "./snapshot";
|
|
30
|
+
|
|
31
|
+
/** Configuration for {@link createTieredThreadManager}. */
|
|
32
|
+
export interface TieredThreadManagerConfig<T> extends ThreadManagerConfig<T> {
|
|
33
|
+
/**
|
|
34
|
+
* Cold-tier archive. When omitted, `hydrate()` and `flush()` are
|
|
35
|
+
* no-ops and the manager behaves identically to
|
|
36
|
+
* {@link createThreadManager}.
|
|
37
|
+
*/
|
|
38
|
+
coldStore?: ColdThreadStore;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Options for {@link TieredThreadManager.flush}. */
|
|
42
|
+
export interface FlushOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Delete the hot-tier Redis keys after a successful cold-tier
|
|
45
|
+
* write. Defaults to `true` when a cold store is configured —
|
|
46
|
+
* Redis is just a cache and a future continue/fork will
|
|
47
|
+
* re-hydrate in a single round-trip.
|
|
48
|
+
*
|
|
49
|
+
* Set to `false` to keep the hot tier warm (useful for tests or
|
|
50
|
+
* for "hot-after-flush" use cases where another session is expected
|
|
51
|
+
* to pick the thread up immediately).
|
|
52
|
+
*/
|
|
53
|
+
deleteHot?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extension of {@link BaseThreadManager} with the two cold-tier
|
|
58
|
+
* lifecycle methods.
|
|
59
|
+
*/
|
|
60
|
+
export interface TieredThreadManager<T> extends BaseThreadManager<T> {
|
|
61
|
+
/**
|
|
62
|
+
* Restore the latest cold-tier snapshot into Redis when the thread
|
|
63
|
+
* is cold. Idempotent — safe to call from a retried activity.
|
|
64
|
+
*/
|
|
65
|
+
hydrate(): Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* Write the current Redis state to the cold tier and (optionally)
|
|
68
|
+
* drop the hot-tier keys. Idempotent — last-writer-wins on the
|
|
69
|
+
* cold side.
|
|
70
|
+
*/
|
|
71
|
+
flush(opts?: FlushOptions): Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a thread manager backed by Redis (hot) and an optional
|
|
76
|
+
* pluggable cold store. See module docstring for the lifecycle
|
|
77
|
+
* semantics.
|
|
78
|
+
*/
|
|
79
|
+
export function createTieredThreadManager<T>(
|
|
80
|
+
config: TieredThreadManagerConfig<T>
|
|
81
|
+
): TieredThreadManager<T> {
|
|
82
|
+
const {
|
|
83
|
+
redis,
|
|
84
|
+
threadId,
|
|
85
|
+
key = "messages",
|
|
86
|
+
coldStore,
|
|
87
|
+
idOf,
|
|
88
|
+
deserialize = (raw: string): T => JSON.parse(raw) as T,
|
|
89
|
+
ttlSeconds = THREAD_TTL_SECONDS,
|
|
90
|
+
} = config;
|
|
91
|
+
|
|
92
|
+
const base = createThreadManager<T>(config);
|
|
93
|
+
|
|
94
|
+
// Snapshot-time `idOf` operates on raw Redis strings — we deserialize
|
|
95
|
+
// here and forward to the configured (deserialized) `idOf`.
|
|
96
|
+
const rawIdOf = idOf
|
|
97
|
+
? (raw: string): string => idOf(deserialize(raw))
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
return Object.assign(base, {
|
|
101
|
+
async hydrate(): Promise<void> {
|
|
102
|
+
if (!coldStore) return;
|
|
103
|
+
const snapshot = await coldStore.read(key, threadId);
|
|
104
|
+
if (!snapshot) return;
|
|
105
|
+
await applySnapshot({
|
|
106
|
+
redis,
|
|
107
|
+
threadKey: key,
|
|
108
|
+
threadId,
|
|
109
|
+
snapshot,
|
|
110
|
+
ttlSeconds,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async flush(opts?: FlushOptions): Promise<void> {
|
|
115
|
+
if (!coldStore) return;
|
|
116
|
+
const snapshot = await encodeSnapshot({
|
|
117
|
+
redis,
|
|
118
|
+
threadKey: key,
|
|
119
|
+
threadId,
|
|
120
|
+
...(rawIdOf ? { idOf: rawIdOf } : {}),
|
|
121
|
+
});
|
|
122
|
+
if (!snapshot) return;
|
|
123
|
+
await coldStore.write(key, threadId, snapshot);
|
|
124
|
+
const deleteHot = opts?.deleteHot ?? true;
|
|
125
|
+
if (deleteHot) {
|
|
126
|
+
await clearHotTier({
|
|
127
|
+
redis,
|
|
128
|
+
threadKey: key,
|
|
129
|
+
threadId,
|
|
130
|
+
dedupIds: snapshot.dedupIds,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
package/src/lib/thread/types.ts
CHANGED
|
@@ -14,6 +14,17 @@ export interface ThreadManagerConfig<T> {
|
|
|
14
14
|
* When provided, `append` uses an atomic Lua script to skip duplicate writes.
|
|
15
15
|
*/
|
|
16
16
|
idOf?: (message: T) => string;
|
|
17
|
+
/**
|
|
18
|
+
* TTL (in seconds) applied to every Redis key the manager writes
|
|
19
|
+
* (the list, the meta marker, the state slice, and dedup markers).
|
|
20
|
+
*
|
|
21
|
+
* Defaults to {@link THREAD_TTL_SECONDS} (90 days) for back-compat.
|
|
22
|
+
* When the consumer pairs the thread manager with a durable cold
|
|
23
|
+
* tier (see `createTieredThreadManager`), a much shorter TTL — e.g.
|
|
24
|
+
* a few hours — is usually more appropriate since the cold tier is
|
|
25
|
+
* the source of truth and Redis is just a hot cache.
|
|
26
|
+
*/
|
|
27
|
+
ttlSeconds?: number;
|
|
17
28
|
}
|
|
18
29
|
|
|
19
30
|
/** Generic thread manager for any message type */
|
|
@@ -26,6 +37,11 @@ export interface BaseThreadManager<T> {
|
|
|
26
37
|
* Append messages to the thread.
|
|
27
38
|
* When `idOf` is configured, appends are idempotent — retries with the
|
|
28
39
|
* same message ids are atomically skipped via a Redis Lua script.
|
|
40
|
+
*
|
|
41
|
+
* Caveat with tiered storage: multi-message batches write one composite
|
|
42
|
+
* dedup key (`"m1:m2"`); snapshots only persist per-message keys, so a
|
|
43
|
+
* batch retried after `flush` → `hydrate` will not be deduped. Adapter
|
|
44
|
+
* helpers all single-append and are unaffected.
|
|
29
45
|
*/
|
|
30
46
|
append(messages: T[]): Promise<void>;
|
|
31
47
|
/**
|