@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axiom-lattice/local-stores",
3
- "version": "3.1.24",
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.2.0",
29
- "@axiom-lattice/protocols": "4.6.1"
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
  }