@axiom-lattice/local-stores 3.1.24 → 3.2.1
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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +58 -0
- package/dist/index.d.mts +12 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +66 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +66 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalScheduleStorage.test.ts +143 -0
- package/src/__tests__/LocalThreadMessageQueueStore.test.ts +40 -0
- package/src/stores/LocalScheduleStorage.ts +23 -0
- package/src/stores/LocalThreadMessageQueueStore.ts +56 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/local-stores",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "Local SQLite-based stores for Axiom Lattice framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@types/sql.js": "^1.4.11",
|
|
26
26
|
"sql.js": "^1.14.1",
|
|
27
27
|
"uuid": "^14.0.1",
|
|
28
|
-
"@axiom-lattice/core": "6.
|
|
29
|
-
"@axiom-lattice/protocols": "4.
|
|
28
|
+
"@axiom-lattice/core": "6.3.1",
|
|
29
|
+
"@axiom-lattice/protocols": "4.7.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { tmpdir } from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ScheduleExecutionType,
|
|
7
|
+
ScheduledTaskDefinition,
|
|
8
|
+
ScheduledTaskStatus,
|
|
9
|
+
} from "@axiom-lattice/protocols";
|
|
10
|
+
|
|
11
|
+
import { closeDatabase, initDatabase } from "../database";
|
|
12
|
+
import { LocalScheduleStorage } from "../stores/LocalScheduleStorage";
|
|
13
|
+
|
|
14
|
+
const nextDbPath = (): string =>
|
|
15
|
+
path.join(
|
|
16
|
+
tmpdir(),
|
|
17
|
+
`lt-schedule-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const buildTask = (
|
|
21
|
+
taskId: string,
|
|
22
|
+
overrides: Partial<ScheduledTaskDefinition> = {}
|
|
23
|
+
): ScheduledTaskDefinition => {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
return {
|
|
26
|
+
taskId,
|
|
27
|
+
taskType: "test",
|
|
28
|
+
payload: {},
|
|
29
|
+
tenantId: "tenant-1",
|
|
30
|
+
executionType: ScheduleExecutionType.ONCE,
|
|
31
|
+
executeAt: now - 1000,
|
|
32
|
+
status: ScheduledTaskStatus.PENDING,
|
|
33
|
+
runCount: 0,
|
|
34
|
+
retryCount: 0,
|
|
35
|
+
maxRetries: 0,
|
|
36
|
+
createdAt: now,
|
|
37
|
+
updatedAt: now,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const createStore = async (): Promise<{
|
|
43
|
+
db: Awaited<ReturnType<typeof initDatabase>>;
|
|
44
|
+
store: LocalScheduleStorage;
|
|
45
|
+
}> => {
|
|
46
|
+
const db = await initDatabase({ dbPath: nextDbPath() });
|
|
47
|
+
return { db, store: new LocalScheduleStorage(db) };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("LocalScheduleStorage.claimScheduledRun", () => {
|
|
51
|
+
afterEach(() => closeDatabase());
|
|
52
|
+
|
|
53
|
+
it("claims a pending task exactly once", async () => {
|
|
54
|
+
const { store } = await createStore();
|
|
55
|
+
await store.save(buildTask("task-1"));
|
|
56
|
+
|
|
57
|
+
expect(await store.claimScheduledRun("task-1")).toBe(true);
|
|
58
|
+
expect(await store.claimScheduledRun("task-1")).toBe(false);
|
|
59
|
+
|
|
60
|
+
const task = await store.get("task-1");
|
|
61
|
+
expect(task?.status).toBe(ScheduledTaskStatus.RUNNING);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns false for an unknown task", async () => {
|
|
65
|
+
const { store } = await createStore();
|
|
66
|
+
expect(await store.claimScheduledRun("missing")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns false when the task is not pending", async () => {
|
|
70
|
+
const { store } = await createStore();
|
|
71
|
+
await store.save(buildTask("task-1"));
|
|
72
|
+
await store.update("task-1", { status: ScheduledTaskStatus.COMPLETED });
|
|
73
|
+
|
|
74
|
+
expect(await store.claimScheduledRun("task-1")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("can claim again after a retry re-arms the task to pending", async () => {
|
|
78
|
+
const { store } = await createStore();
|
|
79
|
+
await store.save(buildTask("task-1"));
|
|
80
|
+
|
|
81
|
+
expect(await store.claimScheduledRun("task-1")).toBe(true);
|
|
82
|
+
await store.update("task-1", { status: ScheduledTaskStatus.PENDING });
|
|
83
|
+
expect(await store.claimScheduledRun("task-1")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("LocalScheduleStorage.requeueStaleRunning", () => {
|
|
88
|
+
afterEach(() => closeDatabase());
|
|
89
|
+
|
|
90
|
+
it("requeues a running task older than the threshold", async () => {
|
|
91
|
+
const { store } = await createStore();
|
|
92
|
+
await store.save(
|
|
93
|
+
buildTask("task-stale", {
|
|
94
|
+
status: ScheduledTaskStatus.RUNNING,
|
|
95
|
+
updatedAt: Date.now() - 60_000,
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(await store.requeueStaleRunning(30_000)).toBe(1);
|
|
100
|
+
|
|
101
|
+
const task = await store.get("task-stale");
|
|
102
|
+
expect(task?.status).toBe(ScheduledTaskStatus.PENDING);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("leaves a recently updated running task alone", async () => {
|
|
106
|
+
const { store } = await createStore();
|
|
107
|
+
await store.save(
|
|
108
|
+
buildTask("task-fresh", {
|
|
109
|
+
status: ScheduledTaskStatus.RUNNING,
|
|
110
|
+
updatedAt: Date.now() - 1_000,
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(await store.requeueStaleRunning(30_000)).toBe(0);
|
|
115
|
+
|
|
116
|
+
const task = await store.get("task-fresh");
|
|
117
|
+
expect(task?.status).toBe(ScheduledTaskStatus.RUNNING);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("never touches pending or terminal tasks", async () => {
|
|
121
|
+
const { store } = await createStore();
|
|
122
|
+
await store.save(
|
|
123
|
+
buildTask("pending", {
|
|
124
|
+
status: ScheduledTaskStatus.PENDING,
|
|
125
|
+
updatedAt: Date.now() - 60_000,
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
await store.save(
|
|
129
|
+
buildTask("completed", {
|
|
130
|
+
status: ScheduledTaskStatus.COMPLETED,
|
|
131
|
+
updatedAt: Date.now() - 60_000,
|
|
132
|
+
})
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(await store.requeueStaleRunning(30_000)).toBe(0);
|
|
136
|
+
expect((await store.get("pending"))?.status).toBe(
|
|
137
|
+
ScheduledTaskStatus.PENDING
|
|
138
|
+
);
|
|
139
|
+
expect((await store.get("completed"))?.status).toBe(
|
|
140
|
+
ScheduledTaskStatus.COMPLETED
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -198,4 +198,44 @@ describe("LocalThreadMessageQueueStore scope isolation", () => {
|
|
|
198
198
|
|
|
199
199
|
await expect(store.getPendingMessages("thread")).rejects.toThrow("Invalid trusted agent run context");
|
|
200
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("claimPendingMessages / reclaimExpiredProcessing", () => {
|
|
203
|
+
it("claims pending messages atomically and marks them processing", async () => {
|
|
204
|
+
const message = await store.addMessage({
|
|
205
|
+
threadId: "claim", tenantId: "tenant", assistantId: "assistant", content: "hi",
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const claimed = await store.claimPendingMessages("claim", undefined, { owner: "r1", ttlMs: 1000 });
|
|
209
|
+
|
|
210
|
+
expect(claimed.map((m) => m.id)).toEqual([message.id]);
|
|
211
|
+
expect(await store.getPendingMessages("claim")).toHaveLength(0);
|
|
212
|
+
expect(await store.getProcessingMessages("claim")).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("gives sequential claimers disjoint sets bounded by the limit", async () => {
|
|
216
|
+
for (let i = 0; i < 4; i += 1) {
|
|
217
|
+
await store.addMessage({ threadId: "claim-disjoint", tenantId: "tenant", assistantId: "assistant", content: `hi${i}`, id: `m${i}` });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const a = await store.claimPendingMessages("claim-disjoint", undefined, { owner: "r1", ttlMs: 1000, limit: 2 });
|
|
221
|
+
const b = await store.claimPendingMessages("claim-disjoint", undefined, { owner: "r2", ttlMs: 1000, limit: 2 });
|
|
222
|
+
|
|
223
|
+
expect(a).toHaveLength(2);
|
|
224
|
+
expect(b).toHaveLength(2);
|
|
225
|
+
expect(new Set([...a, ...b].map((m) => m.id)).size).toBe(4);
|
|
226
|
+
expect(await store.getPendingMessages("claim-disjoint")).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("reclaims only expired processing rows", async () => {
|
|
230
|
+
await store.addMessage({ threadId: "lease-live", tenantId: "tenant", assistantId: "assistant", content: "live", id: "live" });
|
|
231
|
+
await store.addMessage({ threadId: "lease-expired", tenantId: "tenant", assistantId: "assistant", content: "expired", id: "expired" });
|
|
232
|
+
await store.claimPendingMessages("lease-live", undefined, { owner: "r1", ttlMs: 60000 });
|
|
233
|
+
await store.claimPendingMessages("lease-expired", undefined, { owner: "r1", ttlMs: 1 });
|
|
234
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
235
|
+
|
|
236
|
+
expect(await store.reclaimExpiredProcessing("lease-live")).toBe(0);
|
|
237
|
+
expect(await store.reclaimExpiredProcessing("lease-expired")).toBe(1);
|
|
238
|
+
expect(await store.getPendingMessages("lease-expired")).toHaveLength(1);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
201
241
|
});
|
|
@@ -154,6 +154,29 @@ export class LocalScheduleStorage implements ScheduleStorage {
|
|
|
154
154
|
).run(...values);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
async claimScheduledRun(taskId: string): Promise<boolean> {
|
|
158
|
+
const result = this.db.prepare(
|
|
159
|
+
`UPDATE lt_scheduled_tasks SET status = 'running', updated_at = ? WHERE task_id = ? AND status = 'pending'`,
|
|
160
|
+
).run(new Date().toISOString(), taskId);
|
|
161
|
+
return result.changes > 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Requeues `running` tasks whose `updated_at` is older than `olderThanMs` back
|
|
166
|
+
* to `pending` (recovery after a crash that left a claim unsettled).
|
|
167
|
+
*
|
|
168
|
+
* @param olderThanMs Age threshold in milliseconds.
|
|
169
|
+
* @returns The number of tasks requeued.
|
|
170
|
+
*/
|
|
171
|
+
async requeueStaleRunning(olderThanMs: number): Promise<number> {
|
|
172
|
+
const now = new Date();
|
|
173
|
+
const cutoff = new Date(now.getTime() - olderThanMs).toISOString();
|
|
174
|
+
const result = this.db.prepare(
|
|
175
|
+
`UPDATE lt_scheduled_tasks SET status = 'pending', updated_at = ? WHERE status = 'running' AND updated_at < ?`,
|
|
176
|
+
).run(now.toISOString(), cutoff);
|
|
177
|
+
return result.changes;
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
async delete(taskId: string): Promise<void> {
|
|
158
181
|
this.db.prepare(`DELETE FROM lt_scheduled_tasks WHERE task_id = ?`).run(taskId);
|
|
159
182
|
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
AddMessageParams,
|
|
10
10
|
ThreadInfo,
|
|
11
11
|
QueueScope,
|
|
12
|
+
ClaimMessagesOptions,
|
|
12
13
|
} from "@axiom-lattice/core";
|
|
13
14
|
import {
|
|
14
15
|
parseQueuedExecutionMode,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
} from "@axiom-lattice/protocols";
|
|
19
20
|
import { ensureTable, nowISO, parseISO } from "../database";
|
|
20
21
|
import { randomUUID } from "crypto";
|
|
22
|
+
import { transaction, write } from "./project-room-schema";
|
|
21
23
|
|
|
22
24
|
const DDL = `
|
|
23
25
|
CREATE TABLE IF NOT EXISTS lt_thread_message_queue (
|
|
@@ -36,6 +38,9 @@ CREATE TABLE IF NOT EXISTS lt_thread_message_queue (
|
|
|
36
38
|
custom_run_config TEXT,
|
|
37
39
|
trusted_run_context TEXT,
|
|
38
40
|
execution_mode TEXT CHECK (execution_mode IS NULL OR execution_mode = 'followup'),
|
|
41
|
+
locked_by TEXT,
|
|
42
|
+
locked_at TEXT,
|
|
43
|
+
lease_expires_at TEXT,
|
|
39
44
|
created_at TEXT NOT NULL
|
|
40
45
|
);
|
|
41
46
|
CREATE INDEX IF NOT EXISTS idx_lt_tmq_thread ON lt_thread_message_queue(thread_id, status);
|
|
@@ -58,6 +63,9 @@ interface QueueRow {
|
|
|
58
63
|
custom_run_config: string | null;
|
|
59
64
|
trusted_run_context: string | null;
|
|
60
65
|
execution_mode: string | null;
|
|
66
|
+
locked_by: string | null;
|
|
67
|
+
locked_at: string | null;
|
|
68
|
+
lease_expires_at: string | null;
|
|
61
69
|
created_at: string;
|
|
62
70
|
}
|
|
63
71
|
|
|
@@ -76,6 +84,16 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
76
84
|
if (!names.has("execution_mode")) {
|
|
77
85
|
db.run("ALTER TABLE lt_thread_message_queue ADD COLUMN execution_mode TEXT CHECK (execution_mode IS NULL OR execution_mode = 'followup')");
|
|
78
86
|
}
|
|
87
|
+
if (!names.has("locked_by")) {
|
|
88
|
+
db.run("ALTER TABLE lt_thread_message_queue ADD COLUMN locked_by TEXT");
|
|
89
|
+
}
|
|
90
|
+
if (!names.has("locked_at")) {
|
|
91
|
+
db.run("ALTER TABLE lt_thread_message_queue ADD COLUMN locked_at TEXT");
|
|
92
|
+
}
|
|
93
|
+
if (!names.has("lease_expires_at")) {
|
|
94
|
+
db.run("ALTER TABLE lt_thread_message_queue ADD COLUMN lease_expires_at TEXT");
|
|
95
|
+
}
|
|
96
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_lt_tmq_lease ON lt_thread_message_queue(status, lease_expires_at)");
|
|
79
97
|
}
|
|
80
98
|
|
|
81
99
|
async addMessage(params: AddMessageParams): Promise<PendingMessage> {
|
|
@@ -189,6 +207,43 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
189
207
|
return rows.map(rowToMessage);
|
|
190
208
|
}
|
|
191
209
|
|
|
210
|
+
async claimPendingMessages(
|
|
211
|
+
threadId: string,
|
|
212
|
+
scope: QueueScope | undefined,
|
|
213
|
+
options: ClaimMessagesOptions,
|
|
214
|
+
): Promise<PendingMessage[]> {
|
|
215
|
+
const filter = scopeClause(scope);
|
|
216
|
+
const limitClause = options.limit === undefined ? "" : ` LIMIT ${Math.max(0, Math.floor(options.limit))}`;
|
|
217
|
+
const claimedRows = transaction(this.db, () => {
|
|
218
|
+
const candidates = this.db.prepare(
|
|
219
|
+
`SELECT * FROM lt_thread_message_queue WHERE thread_id = ? AND status = 'pending'${filter.sql} ORDER BY priority DESC, sequence_order ASC${limitClause}`,
|
|
220
|
+
).all(threadId, ...filter.params) as unknown as QueueRow[];
|
|
221
|
+
const startedAt = Date.now();
|
|
222
|
+
const lockedAt = new Date(startedAt).toISOString();
|
|
223
|
+
const leaseExpiresAt = new Date(startedAt + options.ttlMs).toISOString();
|
|
224
|
+
const claimed: QueueRow[] = [];
|
|
225
|
+
for (const row of candidates) {
|
|
226
|
+
const changes = write(
|
|
227
|
+
this.db,
|
|
228
|
+
`UPDATE lt_thread_message_queue SET status = 'processing', locked_by = ?, locked_at = ?, lease_expires_at = ? WHERE id = ? AND status = 'pending'`,
|
|
229
|
+
[options.owner, lockedAt, leaseExpiresAt, row.id],
|
|
230
|
+
);
|
|
231
|
+
if (changes > 0) claimed.push(row);
|
|
232
|
+
}
|
|
233
|
+
return claimed;
|
|
234
|
+
});
|
|
235
|
+
return claimedRows.map(rowToMessage);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async reclaimExpiredProcessing(threadId: string, scope?: QueueScope): Promise<number> {
|
|
239
|
+
const filter = scopeClause(scope);
|
|
240
|
+
return transaction(this.db, () => write(
|
|
241
|
+
this.db,
|
|
242
|
+
`UPDATE lt_thread_message_queue SET status = 'pending', locked_by = NULL, locked_at = NULL, lease_expires_at = NULL WHERE thread_id = ? AND status = 'processing' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?${filter.sql}`,
|
|
243
|
+
[threadId, nowISO(), ...(filter.params as Array<string | number | null>)],
|
|
244
|
+
));
|
|
245
|
+
}
|
|
246
|
+
|
|
192
247
|
async getQueueSize(threadId: string, scope?: QueueScope): Promise<number> {
|
|
193
248
|
const filter = scopeClause(scope);
|
|
194
249
|
const row = this.db.prepare(
|
|
@@ -235,7 +290,7 @@ export class LocalThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
235
290
|
async resetProcessingToPending(threadId: string, scope?: QueueScope): Promise<number> {
|
|
236
291
|
const filter = scopeClause(scope);
|
|
237
292
|
const result = this.db.prepare(
|
|
238
|
-
`UPDATE lt_thread_message_queue SET status = 'pending' WHERE thread_id = ? AND status = 'processing'${filter.sql}`,
|
|
293
|
+
`UPDATE lt_thread_message_queue SET status = 'pending', locked_by = NULL, locked_at = NULL, lease_expires_at = NULL WHERE thread_id = ? AND status = 'processing'${filter.sql}`,
|
|
239
294
|
).run(threadId, ...filter.params);
|
|
240
295
|
return result.changes;
|
|
241
296
|
}
|