zeitlich 0.2.49 → 0.2.51
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 +26 -23
- package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
- package/dist/adapters/sandbox/daytona/index.d.cts +3 -3
- package/dist/adapters/sandbox/daytona/index.d.ts +3 -3
- package/dist/adapters/sandbox/daytona/index.js.map +1 -1
- package/dist/adapters/sandbox/daytona/workflow.d.cts +2 -2
- package/dist/adapters/sandbox/daytona/workflow.d.ts +2 -2
- package/dist/adapters/sandbox/e2b/index.cjs.map +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/sandbox/e2b/index.js.map +1 -1
- package/dist/adapters/sandbox/e2b/workflow.d.cts +1 -1
- package/dist/adapters/sandbox/e2b/workflow.d.ts +1 -1
- package/dist/adapters/thread/anthropic/index.cjs +60 -55
- package/dist/adapters/thread/anthropic/index.cjs.map +1 -1
- package/dist/adapters/thread/anthropic/index.d.cts +20 -15
- package/dist/adapters/thread/anthropic/index.d.ts +20 -15
- package/dist/adapters/thread/anthropic/index.js +60 -55
- package/dist/adapters/thread/anthropic/index.js.map +1 -1
- package/dist/adapters/thread/anthropic/workflow.d.cts +7 -7
- package/dist/adapters/thread/anthropic/workflow.d.ts +7 -7
- package/dist/adapters/thread/google-genai/index.cjs +135 -66
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +200 -26
- package/dist/adapters/thread/google-genai/index.d.ts +200 -26
- package/dist/adapters/thread/google-genai/index.js +135 -66
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/workflow.d.cts +8 -8
- package/dist/adapters/thread/google-genai/workflow.d.ts +8 -8
- package/dist/adapters/thread/langchain/index.cjs +67 -55
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +20 -15
- package/dist/adapters/thread/langchain/index.d.ts +20 -15
- package/dist/adapters/thread/langchain/index.js +67 -55
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/adapters/thread/langchain/workflow.d.cts +7 -7
- package/dist/adapters/thread/langchain/workflow.d.ts +7 -7
- package/dist/{cold-store-DKMAO1Dd.d.ts → cold-store-DyHodfAB.d.ts} +1 -1
- package/dist/{cold-store-CkWoNtMh.d.cts → cold-store-YOx9nmgR.d.cts} +1 -1
- package/dist/index.cjs +15050 -420
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -83
- package/dist/index.d.ts +79 -83
- package/dist/index.js +15051 -417
- package/dist/index.js.map +1 -1
- package/dist/{proxy-B7CWEV-T.d.cts → proxy-2htgGQrc.d.cts} +1 -1
- package/dist/{proxy-ByFHMVRX.d.ts → proxy-CmiTP4pp.d.ts} +1 -1
- package/dist/{thread-manager-nK-WcFzM.d.ts → thread-manager-BJ5pz5Cx.d.cts} +6 -7
- package/dist/{thread-manager-7AW4rhfu.d.ts → thread-manager-BQAbrYXH.d.cts} +6 -7
- package/dist/{thread-manager-Cibe0X5m.d.cts → thread-manager-CcvltOuq.d.ts} +6 -7
- package/dist/{thread-manager-B9rtMEVn.d.cts → thread-manager-DHAbncHX.d.ts} +6 -7
- package/dist/{types-gVa5XCWD.d.ts → types-BQvXWcft.d.ts} +1 -1
- package/dist/{types-XUUFvrJ9.d.cts → types-BjdqxKYp.d.cts} +709 -709
- package/dist/{types-CJ7tCdl6.d.ts → types-D8W5TnSa.d.cts} +3 -3
- package/dist/{types-CJ7tCdl6.d.cts → types-D8W5TnSa.d.ts} +3 -3
- package/dist/{types-DO4Tkwxo.d.ts → types-DEbkLA06.d.ts} +3 -3
- package/dist/{types-DeVNWqlb.d.ts → types-DiI7mZhI.d.ts} +709 -709
- package/dist/{types-BR-k7h0e.d.cts → types-N_LTWe4b.d.cts} +3 -3
- package/dist/{types-CjY93AWZ.d.cts → types-OEN1xrFg.d.cts} +1 -1
- package/dist/{workflow-uhOIj9D-.d.ts → workflow-CcgD6EUB.d.cts} +34 -3
- package/dist/{workflow-KbGsxpfh.d.cts → workflow-DBjPOKBr.d.ts} +34 -3
- package/dist/workflow.cjs +15008 -377
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +3 -3
- package/dist/workflow.d.ts +3 -3
- package/dist/workflow.js +15009 -374
- package/dist/workflow.js.map +1 -1
- package/package.json +10 -37
- package/src/adapters/thread/anthropic/activities.test.ts +115 -0
- package/src/adapters/thread/anthropic/activities.ts +11 -19
- package/src/adapters/thread/anthropic/fork-transform.test.ts +17 -11
- package/src/adapters/thread/anthropic/model-invoker.test.ts +54 -3
- package/src/adapters/thread/anthropic/model-invoker.ts +11 -1
- package/src/adapters/thread/anthropic/thread-manager.test.ts +2 -2
- package/src/adapters/thread/anthropic/thread-manager.ts +3 -4
- package/src/adapters/thread/google-genai/activities.test.ts +162 -0
- package/src/adapters/thread/google-genai/activities.ts +38 -15
- package/src/adapters/thread/google-genai/fork-transform.test.ts +17 -11
- package/src/adapters/thread/google-genai/model-invoker.test.ts +386 -0
- package/src/adapters/thread/google-genai/model-invoker.ts +118 -23
- package/src/adapters/thread/google-genai/thread-manager.test.ts +2 -2
- package/src/adapters/thread/google-genai/thread-manager.ts +3 -4
- package/src/adapters/thread/langchain/activities.test.ts +88 -0
- package/src/adapters/thread/langchain/activities.ts +15 -12
- package/src/adapters/thread/langchain/fork-transform.test.ts +17 -11
- package/src/adapters/thread/langchain/model-invoker.test.ts +74 -0
- package/src/adapters/thread/langchain/model-invoker.ts +16 -3
- package/src/adapters/thread/langchain/thread-manager.test.ts +2 -2
- package/src/adapters/thread/langchain/thread-manager.ts +3 -4
- package/src/index.ts +2 -2
- package/src/lib/sandbox/capability-types.test.ts +2 -2
- package/src/lib/sandbox/manager.ts +2 -6
- package/src/lib/sandbox/sandbox.test.ts +1 -1
- package/src/lib/sandbox/types.ts +2 -2
- package/src/lib/session/session.integration.test.ts +92 -0
- package/src/lib/session/session.ts +23 -11
- package/src/lib/thread/keys.test.ts +9 -9
- package/src/lib/thread/keys.ts +1 -1
- package/src/lib/thread/manager.test.ts +24 -14
- package/src/lib/thread/manager.ts +19 -23
- package/src/lib/thread/snapshot.test.ts +51 -43
- package/src/lib/thread/snapshot.ts +54 -32
- package/src/lib/thread/test-utils.ts +106 -59
- package/src/lib/thread/tiered.test.ts +1 -1
- package/src/lib/thread/types.ts +2 -2
- package/src/lib/tool-router/router.integration.test.ts +44 -0
- package/src/lib/tool-router/router.ts +140 -32
- package/src/lib/workflow.ts +49 -0
- package/src/{adapters/sandbox/inmemory/proxy.ts → test-utils/in-memory-sandbox-proxy.ts} +5 -16
- package/src/{adapters/sandbox/inmemory/index.ts → test-utils/in-memory-sandbox.ts} +11 -3
- package/src/tools/bash/bash.test.ts +1 -1
- package/src/tools/edit/handler.test.ts +1 -1
- package/tsup.config.ts +2 -4
- package/dist/activities-7OcT_vdR.d.cts +0 -162
- package/dist/activities-zG_FBoY2.d.ts +0 -162
- package/dist/adapters/sandbox/inmemory/index.cjs +0 -214
- package/dist/adapters/sandbox/inmemory/index.cjs.map +0 -1
- package/dist/adapters/sandbox/inmemory/index.d.cts +0 -40
- package/dist/adapters/sandbox/inmemory/index.d.ts +0 -40
- package/dist/adapters/sandbox/inmemory/index.js +0 -211
- package/dist/adapters/sandbox/inmemory/index.js.map +0 -1
- package/dist/adapters/sandbox/inmemory/workflow.cjs +0 -36
- package/dist/adapters/sandbox/inmemory/workflow.cjs.map +0 -1
- package/dist/adapters/sandbox/inmemory/workflow.d.cts +0 -27
- package/dist/adapters/sandbox/inmemory/workflow.d.ts +0 -27
- package/dist/adapters/sandbox/inmemory/workflow.js +0 -34
- package/dist/adapters/sandbox/inmemory/workflow.js.map +0 -1
|
@@ -1,45 +1,53 @@
|
|
|
1
1
|
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
-
import type
|
|
2
|
+
import type { RedisClientType } from "redis";
|
|
3
3
|
import { createThreadManager } from "./manager";
|
|
4
4
|
import type { PersistedThreadState } from "../state/types";
|
|
5
5
|
|
|
6
|
+
type Keys = string | string[];
|
|
7
|
+
const toKeys = (keys: Keys): string[] => (Array.isArray(keys) ? keys : [keys]);
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
|
-
* Minimal in-memory
|
|
10
|
+
* Minimal in-memory node-redis stub exposing just the commands used by
|
|
8
11
|
* `createThreadManager`'s state methods (get/set/del/exists/expire) plus
|
|
9
|
-
* the list helpers needed for `initialize`.
|
|
12
|
+
* the list helpers needed for `initialize`/`fork`. Uses the node-redis
|
|
13
|
+
* (`redis`) camelCase API and array-or-variadic keys.
|
|
10
14
|
*/
|
|
11
|
-
function createFakeRedis():
|
|
15
|
+
function createFakeRedis(): RedisClientType {
|
|
12
16
|
const store = new Map<string, string>();
|
|
13
17
|
|
|
14
18
|
const redis = {
|
|
15
19
|
async get(key: string): Promise<string | null> {
|
|
16
20
|
return store.has(key) ? (store.get(key) as string) : null;
|
|
17
21
|
},
|
|
18
|
-
async set(
|
|
22
|
+
async set(
|
|
23
|
+
key: string,
|
|
24
|
+
value: string,
|
|
25
|
+
_options?: { EX?: number }
|
|
26
|
+
): Promise<"OK"> {
|
|
19
27
|
store.set(key, String(value));
|
|
20
28
|
return "OK";
|
|
21
29
|
},
|
|
22
|
-
async del(
|
|
30
|
+
async del(keys: Keys): Promise<number> {
|
|
23
31
|
let removed = 0;
|
|
24
|
-
for (const k of keys) {
|
|
32
|
+
for (const k of toKeys(keys)) {
|
|
25
33
|
if (store.delete(k)) removed++;
|
|
26
34
|
}
|
|
27
35
|
return removed;
|
|
28
36
|
},
|
|
29
|
-
async exists(
|
|
30
|
-
return keys.reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
37
|
+
async exists(keys: Keys): Promise<number> {
|
|
38
|
+
return toKeys(keys).reduce((acc, k) => acc + (store.has(k) ? 1 : 0), 0);
|
|
31
39
|
},
|
|
32
40
|
async expire(_key: string, _ttl: number): Promise<number> {
|
|
33
41
|
return 1;
|
|
34
42
|
},
|
|
35
|
-
async
|
|
43
|
+
async lRange(): Promise<string[]> {
|
|
36
44
|
return [];
|
|
37
45
|
},
|
|
38
|
-
async
|
|
46
|
+
async rPush(): Promise<number> {
|
|
39
47
|
return 0;
|
|
40
48
|
},
|
|
41
49
|
_store: store,
|
|
42
|
-
} as unknown as
|
|
50
|
+
} as unknown as RedisClientType & { _store: Map<string, string> };
|
|
43
51
|
|
|
44
52
|
return redis;
|
|
45
53
|
}
|
|
@@ -64,10 +72,12 @@ const baseSlice: PersistedThreadState = {
|
|
|
64
72
|
};
|
|
65
73
|
|
|
66
74
|
describe("createThreadManager state persistence", () => {
|
|
67
|
-
let redis:
|
|
75
|
+
let redis: RedisClientType & { _store: Map<string, string> };
|
|
68
76
|
|
|
69
77
|
beforeEach(() => {
|
|
70
|
-
redis = createFakeRedis() as
|
|
78
|
+
redis = createFakeRedis() as RedisClientType & {
|
|
79
|
+
_store: Map<string, string>;
|
|
80
|
+
};
|
|
71
81
|
});
|
|
72
82
|
|
|
73
83
|
async function initThread(threadId: string): Promise<void> {
|
|
@@ -60,12 +60,12 @@ export function createThreadManager<T>(
|
|
|
60
60
|
return {
|
|
61
61
|
async initialize(): Promise<void> {
|
|
62
62
|
await redis.del(redisKey);
|
|
63
|
-
await redis.set(metaKey, "1",
|
|
63
|
+
await redis.set(metaKey, "1", { EX: ttlSeconds });
|
|
64
64
|
},
|
|
65
65
|
|
|
66
66
|
async load(): Promise<T[]> {
|
|
67
67
|
await assertThreadExists();
|
|
68
|
-
const data = await redis.
|
|
68
|
+
const data = await redis.lRange(redisKey, 0, -1);
|
|
69
69
|
return data.map(deserialize);
|
|
70
70
|
},
|
|
71
71
|
|
|
@@ -75,23 +75,19 @@ export function createThreadManager<T>(
|
|
|
75
75
|
|
|
76
76
|
if (idOf) {
|
|
77
77
|
const dedupId = messages.map(idOf).join(":");
|
|
78
|
-
await redis.eval(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
redisKey,
|
|
83
|
-
String(ttlSeconds),
|
|
84
|
-
...messages.map(serialize)
|
|
85
|
-
);
|
|
78
|
+
await redis.eval(APPEND_IDEMPOTENT_SCRIPT, {
|
|
79
|
+
keys: [dedupKey(dedupId), redisKey],
|
|
80
|
+
arguments: [String(ttlSeconds), ...messages.map(serialize)],
|
|
81
|
+
});
|
|
86
82
|
} else {
|
|
87
|
-
await redis.
|
|
83
|
+
await redis.rPush(redisKey, messages.map(serialize));
|
|
88
84
|
await redis.expire(redisKey, ttlSeconds);
|
|
89
85
|
}
|
|
90
86
|
},
|
|
91
87
|
|
|
92
88
|
async fork(newThreadId: string): Promise<BaseThreadManager<T>> {
|
|
93
89
|
await assertThreadExists();
|
|
94
|
-
const data = await redis.
|
|
90
|
+
const data = await redis.lRange(redisKey, 0, -1);
|
|
95
91
|
const stateRaw = await redis.get(stateKey);
|
|
96
92
|
const forked = createThreadManager({
|
|
97
93
|
...config,
|
|
@@ -100,12 +96,12 @@ export function createThreadManager<T>(
|
|
|
100
96
|
await forked.initialize();
|
|
101
97
|
if (data.length > 0) {
|
|
102
98
|
const newKey = getThreadListKey(key, newThreadId);
|
|
103
|
-
await redis.
|
|
99
|
+
await redis.rPush(newKey, data);
|
|
104
100
|
await redis.expire(newKey, ttlSeconds);
|
|
105
101
|
}
|
|
106
102
|
if (stateRaw != null) {
|
|
107
103
|
const newStateKey = getThreadStateKey(key, newThreadId);
|
|
108
|
-
await redis.set(newStateKey, stateRaw,
|
|
104
|
+
await redis.set(newStateKey, stateRaw, { EX: ttlSeconds });
|
|
109
105
|
}
|
|
110
106
|
return forked;
|
|
111
107
|
},
|
|
@@ -117,23 +113,23 @@ export function createThreadManager<T>(
|
|
|
117
113
|
"replaceAll requires the thread manager to be configured with `idOf`"
|
|
118
114
|
);
|
|
119
115
|
}
|
|
120
|
-
const existing = await redis.
|
|
116
|
+
const existing = await redis.lRange(redisKey, 0, -1);
|
|
121
117
|
const existingIds = existing
|
|
122
118
|
.map((raw) => idOf(deserialize(raw)))
|
|
123
119
|
.filter((id): id is string => typeof id === "string");
|
|
124
120
|
await redis.del(redisKey);
|
|
125
121
|
if (existingIds.length > 0) {
|
|
126
|
-
await redis.del(
|
|
122
|
+
await redis.del(existingIds.map(dedupKey));
|
|
127
123
|
}
|
|
128
124
|
if (messages.length > 0) {
|
|
129
|
-
await redis.
|
|
125
|
+
await redis.rPush(redisKey, messages.map(serialize));
|
|
130
126
|
await redis.expire(redisKey, ttlSeconds);
|
|
131
127
|
}
|
|
132
128
|
await redis.expire(metaKey, ttlSeconds);
|
|
133
129
|
},
|
|
134
130
|
|
|
135
131
|
async delete(): Promise<void> {
|
|
136
|
-
await redis.del(redisKey, metaKey, stateKey);
|
|
132
|
+
await redis.del([redisKey, metaKey, stateKey]);
|
|
137
133
|
},
|
|
138
134
|
|
|
139
135
|
async loadState(): Promise<PersistedThreadState | null> {
|
|
@@ -144,7 +140,7 @@ export function createThreadManager<T>(
|
|
|
144
140
|
|
|
145
141
|
async saveState(state: PersistedThreadState): Promise<void> {
|
|
146
142
|
await assertThreadExists();
|
|
147
|
-
await redis.set(stateKey, JSON.stringify(state),
|
|
143
|
+
await redis.set(stateKey, JSON.stringify(state), { EX: ttlSeconds });
|
|
148
144
|
},
|
|
149
145
|
|
|
150
146
|
async deleteState(): Promise<void> {
|
|
@@ -153,7 +149,7 @@ export function createThreadManager<T>(
|
|
|
153
149
|
|
|
154
150
|
async length(): Promise<number> {
|
|
155
151
|
await assertThreadExists();
|
|
156
|
-
return redis.
|
|
152
|
+
return redis.lLen(redisKey);
|
|
157
153
|
},
|
|
158
154
|
|
|
159
155
|
async truncateFromId(messageId: string): Promise<void> {
|
|
@@ -163,7 +159,7 @@ export function createThreadManager<T>(
|
|
|
163
159
|
"truncateFromId requires the thread manager to be configured with `idOf`"
|
|
164
160
|
);
|
|
165
161
|
}
|
|
166
|
-
const data = await redis.
|
|
162
|
+
const data = await redis.lRange(redisKey, 0, -1);
|
|
167
163
|
let idx = -1;
|
|
168
164
|
const removedIds: string[] = [];
|
|
169
165
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -178,7 +174,7 @@ export function createThreadManager<T>(
|
|
|
178
174
|
await redis.del(redisKey);
|
|
179
175
|
await redis.expire(metaKey, ttlSeconds);
|
|
180
176
|
} else {
|
|
181
|
-
await redis.
|
|
177
|
+
await redis.lTrim(redisKey, 0, idx - 1);
|
|
182
178
|
await redis.expire(redisKey, ttlSeconds);
|
|
183
179
|
}
|
|
184
180
|
// Clear dedup markers for the removed messages so that a rewind
|
|
@@ -186,7 +182,7 @@ export function createThreadManager<T>(
|
|
|
186
182
|
// re-append without the idempotent-append Lua script treating it
|
|
187
183
|
// as a duplicate.
|
|
188
184
|
if (removedIds.length > 0) {
|
|
189
|
-
await redis.del(
|
|
185
|
+
await redis.del(removedIds.map(dedupKey));
|
|
190
186
|
}
|
|
191
187
|
},
|
|
192
188
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
-
import type
|
|
2
|
+
import type { RedisClientType } from "redis";
|
|
3
3
|
import {
|
|
4
4
|
applySnapshot,
|
|
5
5
|
clearHotTier,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./keys";
|
|
15
15
|
import type { PersistedThreadState } from "../state/types";
|
|
16
16
|
import type { ThreadSnapshot } from "./cold-store";
|
|
17
|
-
import { createFakeRedis } from "./test-utils";
|
|
17
|
+
import { createFakeRedis, makeMultiError } from "./test-utils";
|
|
18
18
|
|
|
19
19
|
const sampleState: PersistedThreadState = {
|
|
20
20
|
tasks: [
|
|
@@ -36,7 +36,7 @@ const sampleState: PersistedThreadState = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
describe("encodeSnapshot", () => {
|
|
39
|
-
let redis:
|
|
39
|
+
let redis: RedisClientType & { _store: Map<string, unknown> };
|
|
40
40
|
|
|
41
41
|
beforeEach(() => {
|
|
42
42
|
redis = createFakeRedis();
|
|
@@ -97,7 +97,7 @@ describe("encodeSnapshot", () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
describe("applySnapshot", () => {
|
|
100
|
-
let redis:
|
|
100
|
+
let redis: RedisClientType & { _store: Map<string, unknown> };
|
|
101
101
|
|
|
102
102
|
beforeEach(() => {
|
|
103
103
|
redis = createFakeRedis();
|
|
@@ -178,7 +178,7 @@ describe("applySnapshot", () => {
|
|
|
178
178
|
const metaKey = getThreadMetaKey("messages", "t-1");
|
|
179
179
|
expect(await redis.exists(metaKey)).toBe(1);
|
|
180
180
|
expect(
|
|
181
|
-
await redis.
|
|
181
|
+
await redis.lRange(getThreadListKey("messages", "t-1"), 0, -1)
|
|
182
182
|
).toEqual([]);
|
|
183
183
|
});
|
|
184
184
|
|
|
@@ -190,7 +190,7 @@ describe("applySnapshot", () => {
|
|
|
190
190
|
const wrapped = new Proxy(redis, {
|
|
191
191
|
get(target, prop, receiver): unknown {
|
|
192
192
|
if (prop === "del") {
|
|
193
|
-
return async (
|
|
193
|
+
return async (_keys: string | string[]): Promise<number> => {
|
|
194
194
|
throw new Error("NOPERM: DEL denied by ACL");
|
|
195
195
|
};
|
|
196
196
|
}
|
|
@@ -206,7 +206,7 @@ describe("applySnapshot", () => {
|
|
|
206
206
|
};
|
|
207
207
|
await expect(
|
|
208
208
|
applySnapshot({
|
|
209
|
-
redis: wrapped as unknown as
|
|
209
|
+
redis: wrapped as unknown as RedisClientType,
|
|
210
210
|
threadKey: "messages",
|
|
211
211
|
threadId: "t-del-fail",
|
|
212
212
|
snapshot: snap,
|
|
@@ -230,42 +230,44 @@ describe("applySnapshot", () => {
|
|
|
230
230
|
});
|
|
231
231
|
|
|
232
232
|
it("clears list/state/dedup residue when a pipelined write fails partway", async () => {
|
|
233
|
-
//
|
|
234
|
-
// underlying fake (so residue is observable) and errors on
|
|
233
|
+
// `multi()` stub that *applies* non-failing commands to the
|
|
234
|
+
// underlying fake (so residue is observable) and errors on rPush
|
|
235
235
|
// — mimicking a partial OOM where the list write fails but the
|
|
236
236
|
// dedup SETs queued after it still land. Without compensating
|
|
237
237
|
// cleanup, those stale dedup keys could silently skip a future
|
|
238
|
-
// append with the same id.
|
|
238
|
+
// append with the same id. node-redis rejects `execAsPipeline`
|
|
239
|
+
// with a `MultiErrorReply` carrying per-command errors.
|
|
239
240
|
const wrapped = new Proxy(redis, {
|
|
240
241
|
get(target, prop, receiver): unknown {
|
|
241
|
-
if (prop === "
|
|
242
|
+
if (prop === "multi") {
|
|
242
243
|
return (): Record<string, unknown> => {
|
|
243
244
|
type Op = { method: string; args: unknown[] };
|
|
244
245
|
const ops: Op[] = [];
|
|
245
246
|
const chain: Record<string, unknown> = {};
|
|
246
|
-
for (const m of ["set", "del", "
|
|
247
|
+
for (const m of ["set", "del", "rPush", "expire"]) {
|
|
247
248
|
chain[m] = (...args: unknown[]): Record<string, unknown> => {
|
|
248
249
|
ops.push({ method: m, args });
|
|
249
250
|
return chain;
|
|
250
251
|
};
|
|
251
252
|
}
|
|
252
|
-
chain.
|
|
253
|
-
const
|
|
253
|
+
chain.execAsPipeline = async (): Promise<unknown[]> => {
|
|
254
|
+
const replies: unknown[] = [];
|
|
255
|
+
const errorIndexes: number[] = [];
|
|
254
256
|
const callable = target as unknown as Record<
|
|
255
257
|
string,
|
|
256
258
|
(...a: unknown[]) => Promise<unknown>
|
|
257
259
|
>;
|
|
258
|
-
for (const op of ops) {
|
|
259
|
-
if (op.method === "
|
|
260
|
-
|
|
260
|
+
for (const [i, op] of ops.entries()) {
|
|
261
|
+
if (op.method === "rPush") {
|
|
262
|
+
replies.push(new Error("OOM"));
|
|
263
|
+
errorIndexes.push(i);
|
|
261
264
|
continue;
|
|
262
265
|
}
|
|
263
266
|
const fn = callable[op.method];
|
|
264
267
|
if (!fn) throw new Error(`stub: unknown ${op.method}`);
|
|
265
|
-
|
|
266
|
-
out.push([null, result]);
|
|
268
|
+
replies.push(await fn(...op.args));
|
|
267
269
|
}
|
|
268
|
-
|
|
270
|
+
throw makeMultiError(replies, errorIndexes);
|
|
269
271
|
};
|
|
270
272
|
return chain;
|
|
271
273
|
};
|
|
@@ -282,7 +284,7 @@ describe("applySnapshot", () => {
|
|
|
282
284
|
};
|
|
283
285
|
await expect(
|
|
284
286
|
applySnapshot({
|
|
285
|
-
redis: wrapped as unknown as
|
|
287
|
+
redis: wrapped as unknown as RedisClientType,
|
|
286
288
|
threadKey: "messages",
|
|
287
289
|
threadId: "t-residue",
|
|
288
290
|
snapshot: snap,
|
|
@@ -305,30 +307,36 @@ describe("applySnapshot", () => {
|
|
|
305
307
|
});
|
|
306
308
|
|
|
307
309
|
it("throws and leaves the meta key unset when a pipelined command fails", async () => {
|
|
308
|
-
// Wrap the fake's `
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// result tuple, and applySnapshot must surface it.
|
|
310
|
+
// Wrap the fake's `multi()` so `execAsPipeline()` rejects with a
|
|
311
|
+
// `MultiErrorReply` — mimicking node-redis's behaviour when Redis
|
|
312
|
+
// runtime errors (OOM, ACL, WRONGTYPE) occur inside a pipeline.
|
|
313
|
+
// `applySnapshot` must surface the underlying error.
|
|
313
314
|
const wrapped = new Proxy(redis, {
|
|
314
315
|
get(target, prop, receiver): unknown {
|
|
315
|
-
if (prop === "
|
|
316
|
+
if (prop === "multi") {
|
|
316
317
|
return (): Record<string, unknown> => {
|
|
317
318
|
type Op = { method: string; args: unknown[] };
|
|
318
319
|
const ops: Op[] = [];
|
|
319
320
|
const chain: Record<string, unknown> = {};
|
|
320
|
-
for (const m of ["set", "del", "
|
|
321
|
+
for (const m of ["set", "del", "rPush", "expire"]) {
|
|
321
322
|
chain[m] = (...args: unknown[]): Record<string, unknown> => {
|
|
322
323
|
ops.push({ method: m, args });
|
|
323
324
|
return chain;
|
|
324
325
|
};
|
|
325
326
|
}
|
|
326
|
-
chain.
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
327
|
+
chain.execAsPipeline = async (): Promise<unknown[]> => {
|
|
328
|
+
const replies: unknown[] = [];
|
|
329
|
+
const errorIndexes: number[] = [];
|
|
330
|
+
ops.forEach((op, i) => {
|
|
331
|
+
if (op.method === "rPush") {
|
|
332
|
+
replies.push(new Error("OOM command not allowed"));
|
|
333
|
+
errorIndexes.push(i);
|
|
334
|
+
} else {
|
|
335
|
+
replies.push(i);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
throw makeMultiError(replies, errorIndexes);
|
|
339
|
+
};
|
|
332
340
|
return chain;
|
|
333
341
|
};
|
|
334
342
|
}
|
|
@@ -344,7 +352,7 @@ describe("applySnapshot", () => {
|
|
|
344
352
|
};
|
|
345
353
|
await expect(
|
|
346
354
|
applySnapshot({
|
|
347
|
-
redis: wrapped as unknown as
|
|
355
|
+
redis: wrapped as unknown as RedisClientType,
|
|
348
356
|
threadKey: "messages",
|
|
349
357
|
threadId: "t-fail",
|
|
350
358
|
snapshot: snap,
|
|
@@ -354,14 +362,14 @@ describe("applySnapshot", () => {
|
|
|
354
362
|
expect(await redis.exists(getThreadMetaKey("messages", "t-fail"))).toBe(0);
|
|
355
363
|
});
|
|
356
364
|
|
|
357
|
-
it("issues a single
|
|
358
|
-
let
|
|
365
|
+
it("issues a single multi().execAsPipeline() rather than per-key writes", async () => {
|
|
366
|
+
let multiCalls = 0;
|
|
359
367
|
const wrapped = new Proxy(redis, {
|
|
360
368
|
get(target, prop, receiver): unknown {
|
|
361
|
-
if (prop === "
|
|
369
|
+
if (prop === "multi") {
|
|
362
370
|
return (): unknown => {
|
|
363
|
-
|
|
364
|
-
return (target as unknown as {
|
|
371
|
+
multiCalls++;
|
|
372
|
+
return (target as unknown as { multi: () => unknown }).multi();
|
|
365
373
|
};
|
|
366
374
|
}
|
|
367
375
|
return Reflect.get(target, prop, receiver) as unknown;
|
|
@@ -377,19 +385,19 @@ describe("applySnapshot", () => {
|
|
|
377
385
|
dedupIds: Array.from({ length: 10 }, (_, i) => `m${i}`),
|
|
378
386
|
};
|
|
379
387
|
await applySnapshot({
|
|
380
|
-
redis: wrapped as unknown as
|
|
388
|
+
redis: wrapped as unknown as RedisClientType,
|
|
381
389
|
threadKey: "messages",
|
|
382
390
|
threadId: "t-1",
|
|
383
391
|
snapshot: snap,
|
|
384
392
|
});
|
|
385
393
|
|
|
386
|
-
expect(
|
|
394
|
+
expect(multiCalls).toBe(1);
|
|
387
395
|
});
|
|
388
396
|
|
|
389
397
|
it("clears any partial residue from a previous failed restore", async () => {
|
|
390
398
|
const listKey = getThreadListKey("messages", "t-1");
|
|
391
399
|
const stateKey = getThreadStateKey("messages", "t-1");
|
|
392
|
-
await redis.
|
|
400
|
+
await redis.rPush(listKey, "stale-message");
|
|
393
401
|
await redis.set(stateKey, JSON.stringify({ stale: true }));
|
|
394
402
|
// Note: meta is intentionally absent — simulates a half-written restore.
|
|
395
403
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* tiered thread manager in `tiered.ts` is the only consumer.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type
|
|
11
|
+
import type { RedisClientType } from "redis";
|
|
12
12
|
import type { PersistedThreadState } from "../state/types";
|
|
13
13
|
import type { ThreadSnapshot } from "./cold-store";
|
|
14
14
|
import {
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
|
|
22
22
|
/** Inputs shared by every snapshot operation. */
|
|
23
23
|
interface SnapshotCommon {
|
|
24
|
-
redis:
|
|
24
|
+
redis: RedisClientType;
|
|
25
25
|
threadKey: string;
|
|
26
26
|
threadId: string;
|
|
27
27
|
}
|
|
@@ -53,7 +53,7 @@ export async function encodeSnapshot(
|
|
|
53
53
|
}
|
|
54
54
|
const listKey = getThreadListKey(threadKey, threadId);
|
|
55
55
|
const stateKey = getThreadStateKey(threadKey, threadId);
|
|
56
|
-
const messages = await redis.
|
|
56
|
+
const messages = await redis.lRange(listKey, 0, -1);
|
|
57
57
|
const stateRaw = await redis.get(stateKey);
|
|
58
58
|
const state =
|
|
59
59
|
stateRaw == null ? null : (JSON.parse(stateRaw) as PersistedThreadState);
|
|
@@ -99,43 +99,65 @@ export async function applySnapshot(
|
|
|
99
99
|
// CROSSSLOT, …) short-circuits before any writes hit the wire —
|
|
100
100
|
// pipelines are non-atomic, so a queued DEL wouldn't stop later
|
|
101
101
|
// commands from accumulating data behind a missing meta marker.
|
|
102
|
-
await redis.del(listKey, stateKey);
|
|
102
|
+
await redis.del([listKey, stateKey]);
|
|
103
103
|
|
|
104
|
-
// Pipeline the data writes (list/state/dedup) in one round-trip
|
|
105
|
-
// Meta is written
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// retries cleanly.
|
|
109
|
-
const pipeline = redis.
|
|
104
|
+
// Pipeline the data writes (list/state/dedup) in one round-trip via a
|
|
105
|
+
// non-transactional `MULTI` (`execAsPipeline`). Meta is written
|
|
106
|
+
// separately, only after every queued command succeeded, preserving
|
|
107
|
+
// the "meta-last" crash-safety invariant — a partial restore must
|
|
108
|
+
// leave meta absent so the next hydrate retries cleanly.
|
|
109
|
+
const pipeline = redis.multi();
|
|
110
110
|
if (snapshot.messages.length > 0) {
|
|
111
|
-
pipeline.
|
|
111
|
+
pipeline.rPush(listKey, snapshot.messages);
|
|
112
112
|
pipeline.expire(listKey, ttlSeconds);
|
|
113
113
|
}
|
|
114
114
|
if (snapshot.state != null) {
|
|
115
|
-
pipeline.set(stateKey, JSON.stringify(snapshot.state),
|
|
115
|
+
pipeline.set(stateKey, JSON.stringify(snapshot.state), { EX: ttlSeconds });
|
|
116
116
|
}
|
|
117
117
|
for (const id of snapshot.dedupIds) {
|
|
118
|
-
pipeline.set(getThreadDedupKey(threadId, id), "1",
|
|
118
|
+
pipeline.set(getThreadDedupKey(threadId, id), "1", { EX: ttlSeconds });
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
120
|
+
try {
|
|
121
|
+
await pipeline.execAsPipeline();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Compensate: pipelines are non-atomic, so writes queued after a
|
|
124
|
+
// failing command (notably dedup SETs) may have landed. Best-effort
|
|
125
|
+
// clear every key we touched so a leftover dedup marker can't
|
|
126
|
+
// silently skip a future append with the same id. node-redis
|
|
127
|
+
// surfaces per-command failures by rejecting `execAsPipeline` with a
|
|
128
|
+
// `MultiErrorReply`; we unwrap it to rethrow the first real error.
|
|
129
|
+
await redis
|
|
130
|
+
.del([
|
|
131
|
+
listKey,
|
|
132
|
+
stateKey,
|
|
133
|
+
...snapshot.dedupIds.map((id) => getThreadDedupKey(threadId, id)),
|
|
134
|
+
])
|
|
135
|
+
.catch(() => undefined);
|
|
136
|
+
throw firstPipelineError(err);
|
|
137
137
|
}
|
|
138
|
-
await redis.set(metaKey, "1",
|
|
138
|
+
await redis.set(metaKey, "1", { EX: ttlSeconds });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Unwrap node-redis's `MultiErrorReply` (thrown by `execAsPipeline` when
|
|
143
|
+
* one or more queued commands fail) to the first underlying error so
|
|
144
|
+
* callers see the actual Redis error (OOM, WRONGTYPE, …) rather than the
|
|
145
|
+
* generic aggregate wrapper. The structural check avoids a hard runtime
|
|
146
|
+
* dependency on the `redis` error class.
|
|
147
|
+
*/
|
|
148
|
+
function firstPipelineError(err: unknown): unknown {
|
|
149
|
+
if (
|
|
150
|
+
err != null &&
|
|
151
|
+
typeof err === "object" &&
|
|
152
|
+
"replies" in err &&
|
|
153
|
+
Array.isArray((err as { replies: unknown }).replies)
|
|
154
|
+
) {
|
|
155
|
+
const firstErr = (err as { replies: unknown[] }).replies.find(
|
|
156
|
+
(r): r is Error => r instanceof Error
|
|
157
|
+
);
|
|
158
|
+
if (firstErr) return firstErr;
|
|
159
|
+
}
|
|
160
|
+
return err;
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
/** Configuration for {@link clearHotTier}. */
|
|
@@ -159,5 +181,5 @@ export async function clearHotTier(
|
|
|
159
181
|
getThreadStateKey(threadKey, threadId),
|
|
160
182
|
...dedupIds.map((id) => getThreadDedupKey(threadId, id)),
|
|
161
183
|
];
|
|
162
|
-
await redis.del(
|
|
184
|
+
await redis.del(keys);
|
|
163
185
|
}
|