@axiom-lattice/pg-stores 3.2.1 → 3.3.0
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 +87 -0
- package/dist/index.d.mts +109 -2
- package/dist/index.d.ts +109 -2
- package/dist/index.js +395 -23
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +395 -23
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/PostgreSQLLeaseStore.test.ts +43 -0
- package/src/__tests__/PostgreSQLOutboxStore.test.ts +77 -0
- package/src/__tests__/PostgreSQLScheduleStorage.test.ts +162 -0
- package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +82 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +71 -12
- package/src/__tests__/queue_migrations.test.ts +18 -0
- package/src/createPgStoreConfig.ts +10 -12
- package/src/migrations/add_queue_claim_lease.ts +35 -0
- package/src/migrations/distributed_lease_migration.ts +32 -0
- package/src/migrations/outbox_migration.ts +30 -0
- package/src/migrations/queue_migrations.ts +28 -0
- package/src/stores/PostgreSQLLeaseStore.ts +126 -0
- package/src/stores/PostgreSQLOutboxStore.ts +136 -0
- package/src/stores/PostgreSQLScheduleStorage.ts +54 -0
- package/src/stores/ThreadMessageQueueStore.ts +49 -16
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { PoolClient } from "pg";
|
|
2
|
+
import { Migration } from "./migration";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Migration: add atomic-claim lease columns to the thread message queue.
|
|
6
|
+
*
|
|
7
|
+
* @remarks Version 186 follows the current ceiling (185, distributed leases).
|
|
8
|
+
* `locked_by` identifies the claiming replica; `lease_expires_at` lets another
|
|
9
|
+
* replica reclaim an abandoned claim.
|
|
10
|
+
*/
|
|
11
|
+
export const addQueueClaimLease: Migration = {
|
|
12
|
+
version: 186,
|
|
13
|
+
name: "add_queue_claim_lease_columns",
|
|
14
|
+
up: async (client: PoolClient) => {
|
|
15
|
+
await client.query(`
|
|
16
|
+
ALTER TABLE lattice_thread_message_queue
|
|
17
|
+
ADD COLUMN IF NOT EXISTS locked_by VARCHAR(255),
|
|
18
|
+
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ,
|
|
19
|
+
ADD COLUMN IF NOT EXISTS lease_expires_at TIMESTAMPTZ
|
|
20
|
+
`);
|
|
21
|
+
await client.query(`
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_lattice_thread_message_queue_lease
|
|
23
|
+
ON lattice_thread_message_queue(status, lease_expires_at)
|
|
24
|
+
`);
|
|
25
|
+
},
|
|
26
|
+
down: async (client: PoolClient) => {
|
|
27
|
+
await client.query("DROP INDEX IF EXISTS idx_lattice_thread_message_queue_lease");
|
|
28
|
+
await client.query(`
|
|
29
|
+
ALTER TABLE lattice_thread_message_queue
|
|
30
|
+
DROP COLUMN IF EXISTS lease_expires_at,
|
|
31
|
+
DROP COLUMN IF EXISTS locked_at,
|
|
32
|
+
DROP COLUMN IF EXISTS locked_by
|
|
33
|
+
`);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { PoolClient } from "pg";
|
|
2
|
+
import { Migration } from "./migration";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Migration: create the cross-replica distributed lease table.
|
|
6
|
+
*
|
|
7
|
+
* @remarks Version 185 follows the current ceiling (184). The table is generic:
|
|
8
|
+
* later plans key task-run ownership, project mutation locks, project-task
|
|
9
|
+
* machine locks, and leader election on it.
|
|
10
|
+
*/
|
|
11
|
+
export const createDistributedLeases: Migration = {
|
|
12
|
+
version: 185,
|
|
13
|
+
name: "create_lattice_distributed_leases",
|
|
14
|
+
up: async (client: PoolClient) => {
|
|
15
|
+
await client.query(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS lattice_leases (
|
|
17
|
+
key TEXT PRIMARY KEY,
|
|
18
|
+
holder TEXT NOT NULL,
|
|
19
|
+
acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
20
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
await client.query(`
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_lattice_leases_expires
|
|
25
|
+
ON lattice_leases(expires_at)
|
|
26
|
+
`);
|
|
27
|
+
},
|
|
28
|
+
down: async (client: PoolClient) => {
|
|
29
|
+
await client.query("DROP INDEX IF EXISTS idx_lattice_leases_expires");
|
|
30
|
+
await client.query("DROP TABLE IF EXISTS lattice_leases");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PoolClient } from "pg";
|
|
2
|
+
import { Migration } from "./migration";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Migration: create the durable outbox fan-out log.
|
|
6
|
+
*
|
|
7
|
+
* @remarks Version 187 follows the current ceiling (186). The table is an
|
|
8
|
+
* append-only, bare-serial-ordered event log; replicas poll it with a cursor to
|
|
9
|
+
* deliver notifications to their process-local listeners.
|
|
10
|
+
*/
|
|
11
|
+
export const createOutboxTable: Migration = {
|
|
12
|
+
version: 187,
|
|
13
|
+
name: "create_lattice_outbox",
|
|
14
|
+
up: async (client: PoolClient) => {
|
|
15
|
+
await client.query(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS lattice_outbox (
|
|
17
|
+
id BIGSERIAL PRIMARY KEY,
|
|
18
|
+
topic TEXT NOT NULL,
|
|
19
|
+
payload JSONB NOT NULL,
|
|
20
|
+
origin_replica_id TEXT NOT NULL,
|
|
21
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
22
|
+
)
|
|
23
|
+
`);
|
|
24
|
+
await client.query(`CREATE INDEX IF NOT EXISTS idx_lattice_outbox_id ON lattice_outbox(id)`);
|
|
25
|
+
},
|
|
26
|
+
down: async (client: PoolClient) => {
|
|
27
|
+
await client.query("DROP INDEX IF EXISTS idx_lattice_outbox_id");
|
|
28
|
+
await client.query("DROP TABLE IF EXISTS lattice_outbox");
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MigrationManager } from "./migration";
|
|
2
|
+
import { createThreadMessageQueueTable } from "./thread_message_queue_migrations";
|
|
3
|
+
import { addPriorityAndCommandColumns } from "./add_priority_command_columns";
|
|
4
|
+
import { addCustomRunConfigColumn } from "./add_custom_run_config_column";
|
|
5
|
+
import { alterMessageQueueIdColumn } from "./alter_message_queue_id_column";
|
|
6
|
+
import { addWorkspaceProjectToQueue } from "./add_workspace_project_to_queue";
|
|
7
|
+
import { addTrustedRunContextColumn } from "./add_trusted_run_context_column";
|
|
8
|
+
import { addQueueClaimLease } from "./add_queue_claim_lease";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Single source of truth for the thread-message-queue schema migrations.
|
|
12
|
+
*
|
|
13
|
+
* @param manager Migration manager to register the queue migrations on.
|
|
14
|
+
* @remarks
|
|
15
|
+
* Both the own-pool `ThreadMessageQueueStore` constructor and the shared-pool
|
|
16
|
+
* `createPgStoreConfig` path must register the SAME set. Keeping the list here
|
|
17
|
+
* prevents the two sites from drifting (a missing column on one path once
|
|
18
|
+
* slipped through and only surfaced in a real two-process run).
|
|
19
|
+
*/
|
|
20
|
+
export function registerThreadMessageQueueMigrations(manager: MigrationManager): void {
|
|
21
|
+
manager.register(createThreadMessageQueueTable); // v100
|
|
22
|
+
manager.register(addPriorityAndCommandColumns); // v101
|
|
23
|
+
manager.register(alterMessageQueueIdColumn); // v102
|
|
24
|
+
manager.register(addCustomRunConfigColumn); // v114
|
|
25
|
+
manager.register(addWorkspaceProjectToQueue); // v131
|
|
26
|
+
manager.register(addTrustedRunContextColumn); // v173
|
|
27
|
+
manager.register(addQueueClaimLease); // v186
|
|
28
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Pool, type PoolConfig } from "pg";
|
|
2
|
+
import type { LeaseRecord, LeaseStore } from "@axiom-lattice/protocols";
|
|
3
|
+
import { MigrationManager } from "../migrations/migration";
|
|
4
|
+
import { createDistributedLeases } from "../migrations/distributed_lease_migration";
|
|
5
|
+
|
|
6
|
+
export interface PostgreSQLLeaseStoreOptions {
|
|
7
|
+
pool?: Pool;
|
|
8
|
+
poolConfig?: string | PoolConfig;
|
|
9
|
+
autoMigrate?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PostgreSQL {@link LeaseStore}: cross-replica exclusion with self-expiring rows.
|
|
14
|
+
*
|
|
15
|
+
* @remarks `tryAcquire` is a single atomic upsert guarded by `expires_at`; no
|
|
16
|
+
* external lock manager is required. Uses the shared `lattice_leases` table.
|
|
17
|
+
*/
|
|
18
|
+
export class PostgreSQLLeaseStore implements LeaseStore {
|
|
19
|
+
private pool: Pool;
|
|
20
|
+
private ownsPool = true;
|
|
21
|
+
private initialized = false;
|
|
22
|
+
private initPromise: Promise<void> | null = null;
|
|
23
|
+
private migrationManager!: MigrationManager;
|
|
24
|
+
|
|
25
|
+
/** @param options An externally managed pool, or a pool config/URL. */
|
|
26
|
+
constructor(options: PostgreSQLLeaseStoreOptions) {
|
|
27
|
+
if (options.pool) {
|
|
28
|
+
this.pool = options.pool;
|
|
29
|
+
this.ownsPool = false;
|
|
30
|
+
this.initialized = true;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (typeof options.poolConfig === "string") {
|
|
34
|
+
this.pool = new Pool({ connectionString: options.poolConfig });
|
|
35
|
+
} else if (options.poolConfig) {
|
|
36
|
+
this.pool = new Pool(options.poolConfig as PoolConfig);
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error("Either pool or poolConfig must be provided");
|
|
39
|
+
}
|
|
40
|
+
this.migrationManager = new MigrationManager(this.pool);
|
|
41
|
+
this.migrationManager.register(createDistributedLeases);
|
|
42
|
+
if (options.autoMigrate !== false) {
|
|
43
|
+
this.initialize().catch((error) => {
|
|
44
|
+
console.error("Failed to initialize PostgreSQLLeaseStore:", error);
|
|
45
|
+
throw error;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Runs pending migrations once. */
|
|
51
|
+
async initialize(): Promise<void> {
|
|
52
|
+
if (this.initialized) return;
|
|
53
|
+
if (this.initPromise) return this.initPromise;
|
|
54
|
+
this.initPromise = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
await this.migrationManager.migrate();
|
|
57
|
+
this.initialized = true;
|
|
58
|
+
} finally {
|
|
59
|
+
this.initPromise = null;
|
|
60
|
+
}
|
|
61
|
+
})();
|
|
62
|
+
return this.initPromise;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Ends the pool when this store owns it. */
|
|
66
|
+
async dispose(): Promise<void> {
|
|
67
|
+
if (this.ownsPool) await this.pool.end();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Atomically acquires a free/expired key (or re-acquires the caller's own). */
|
|
71
|
+
async tryAcquire(key: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
72
|
+
const result = await this.pool.query(
|
|
73
|
+
`INSERT INTO lattice_leases (key, holder, acquired_at, expires_at)
|
|
74
|
+
VALUES ($1, $2, now(), now() + ($3::bigint || ' milliseconds')::interval)
|
|
75
|
+
ON CONFLICT (key) DO UPDATE
|
|
76
|
+
SET holder = EXCLUDED.holder,
|
|
77
|
+
acquired_at = CASE WHEN lattice_leases.holder = EXCLUDED.holder
|
|
78
|
+
THEN lattice_leases.acquired_at ELSE now() END,
|
|
79
|
+
expires_at = EXCLUDED.expires_at
|
|
80
|
+
WHERE lattice_leases.expires_at <= now()
|
|
81
|
+
OR lattice_leases.holder = EXCLUDED.holder
|
|
82
|
+
RETURNING holder`,
|
|
83
|
+
[key, holder, ttlMs],
|
|
84
|
+
);
|
|
85
|
+
return result.rowCount === 1 && result.rows[0].holder === holder;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Extends a lease only while `holder` still owns it. */
|
|
89
|
+
async renew(key: string, holder: string, ttlMs: number): Promise<boolean> {
|
|
90
|
+
const result = await this.pool.query(
|
|
91
|
+
`UPDATE lattice_leases
|
|
92
|
+
SET expires_at = now() + ($3::bigint || ' milliseconds')::interval
|
|
93
|
+
WHERE key = $1 AND holder = $2
|
|
94
|
+
RETURNING key`,
|
|
95
|
+
[key, holder, ttlMs],
|
|
96
|
+
);
|
|
97
|
+
return (result.rowCount ?? 0) > 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Deletes a lease only when `holder` owns it. */
|
|
101
|
+
async release(key: string, holder: string): Promise<boolean> {
|
|
102
|
+
const result = await this.pool.query(
|
|
103
|
+
`DELETE FROM lattice_leases WHERE key = $1 AND holder = $2 RETURNING key`,
|
|
104
|
+
[key, holder],
|
|
105
|
+
);
|
|
106
|
+
return (result.rowCount ?? 0) > 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Returns the live lease, or `null` when free/expired/absent. */
|
|
110
|
+
async get(key: string): Promise<LeaseRecord | null> {
|
|
111
|
+
const result = await this.pool.query(
|
|
112
|
+
`SELECT key, holder, acquired_at, expires_at
|
|
113
|
+
FROM lattice_leases
|
|
114
|
+
WHERE key = $1 AND expires_at > now()`,
|
|
115
|
+
[key],
|
|
116
|
+
);
|
|
117
|
+
if (result.rowCount === 0) return null;
|
|
118
|
+
const row = result.rows[0];
|
|
119
|
+
return {
|
|
120
|
+
key: row.key,
|
|
121
|
+
holder: row.holder,
|
|
122
|
+
acquiredAt: new Date(row.acquired_at).toISOString(),
|
|
123
|
+
expiresAt: new Date(row.expires_at).toISOString(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { Pool, type PoolConfig } from "pg";
|
|
2
|
+
import type { OutboxEvent, OutboxStore } from "@axiom-lattice/protocols";
|
|
3
|
+
import { MigrationManager } from "../migrations/migration";
|
|
4
|
+
import { createOutboxTable } from "../migrations/outbox_migration";
|
|
5
|
+
|
|
6
|
+
export interface PostgreSQLOutboxStoreOptions {
|
|
7
|
+
pool?: Pool;
|
|
8
|
+
poolConfig?: string | PoolConfig;
|
|
9
|
+
autoMigrate?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PostgreSQL {@link OutboxStore}: an append-only, cross-replica notification log.
|
|
14
|
+
*
|
|
15
|
+
* @remarks Ids come from a `BIGSERIAL`, giving a total order across replicas.
|
|
16
|
+
* Callers poll with a cursor (`listAfter`) and skip their own `originReplicaId`;
|
|
17
|
+
* delivery is therefore at-least-once. Uses the shared `lattice_outbox` table.
|
|
18
|
+
*/
|
|
19
|
+
export class PostgreSQLOutboxStore implements OutboxStore {
|
|
20
|
+
private pool: Pool;
|
|
21
|
+
private ownsPool = true;
|
|
22
|
+
private initialized = false;
|
|
23
|
+
private initPromise: Promise<void> | null = null;
|
|
24
|
+
private migrationManager!: MigrationManager;
|
|
25
|
+
|
|
26
|
+
/** @param options An externally managed pool, or a pool config/URL. */
|
|
27
|
+
constructor(options: PostgreSQLOutboxStoreOptions) {
|
|
28
|
+
if (options.pool) {
|
|
29
|
+
this.pool = options.pool;
|
|
30
|
+
this.ownsPool = false;
|
|
31
|
+
this.initialized = true;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (typeof options.poolConfig === "string") {
|
|
35
|
+
this.pool = new Pool({ connectionString: options.poolConfig });
|
|
36
|
+
} else if (options.poolConfig) {
|
|
37
|
+
this.pool = new Pool(options.poolConfig as PoolConfig);
|
|
38
|
+
} else {
|
|
39
|
+
throw new Error("Either pool or poolConfig must be provided");
|
|
40
|
+
}
|
|
41
|
+
this.migrationManager = new MigrationManager(this.pool);
|
|
42
|
+
this.migrationManager.register(createOutboxTable);
|
|
43
|
+
if (options.autoMigrate !== false) {
|
|
44
|
+
this.initialize().catch((error) => {
|
|
45
|
+
console.error("Failed to initialize PostgreSQLOutboxStore:", error);
|
|
46
|
+
throw error;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Runs pending migrations once. */
|
|
52
|
+
async initialize(): Promise<void> {
|
|
53
|
+
if (this.initialized) return;
|
|
54
|
+
if (this.initPromise) return this.initPromise;
|
|
55
|
+
this.initPromise = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
await this.migrationManager.migrate();
|
|
58
|
+
this.initialized = true;
|
|
59
|
+
} finally {
|
|
60
|
+
this.initPromise = null;
|
|
61
|
+
}
|
|
62
|
+
})();
|
|
63
|
+
return this.initPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Ends the pool when this store owns it. */
|
|
67
|
+
async dispose(): Promise<void> {
|
|
68
|
+
if (this.ownsPool) await this.pool.end();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Appends one event and returns it with its assigned id. */
|
|
72
|
+
async append(input: { topic: string; payload: unknown; originReplicaId: string }): Promise<OutboxEvent> {
|
|
73
|
+
const result = await this.pool.query(
|
|
74
|
+
`INSERT INTO lattice_outbox (topic, payload, origin_replica_id)
|
|
75
|
+
VALUES ($1, $2::jsonb, $3)
|
|
76
|
+
RETURNING id, topic, payload, origin_replica_id, created_at`,
|
|
77
|
+
[input.topic, JSON.stringify(input.payload), input.originReplicaId],
|
|
78
|
+
);
|
|
79
|
+
return this.rowToEvent(result.rows[0]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Lists up to `limit` events with `id > cursor`, ascending. */
|
|
83
|
+
async listAfter(cursor: string | null, limit: number): Promise<OutboxEvent[]> {
|
|
84
|
+
const result = await this.pool.query(
|
|
85
|
+
`SELECT id, topic, payload, origin_replica_id, created_at
|
|
86
|
+
FROM lattice_outbox
|
|
87
|
+
WHERE id > $1
|
|
88
|
+
ORDER BY id ASC
|
|
89
|
+
LIMIT $2`,
|
|
90
|
+
[cursor === null ? 0 : Number(cursor), limit],
|
|
91
|
+
);
|
|
92
|
+
return result.rows.map((row) => this.rowToEvent(row));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Returns the newest id, or `null` when the log is empty. */
|
|
96
|
+
async latestId(): Promise<string | null> {
|
|
97
|
+
const result = await this.pool.query(
|
|
98
|
+
`SELECT id FROM lattice_outbox ORDER BY id DESC LIMIT 1`,
|
|
99
|
+
);
|
|
100
|
+
if (result.rowCount === 0) return null;
|
|
101
|
+
return String(result.rows[0].id);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Deletes events older than `olderThanMs` (evaluated against the database clock).
|
|
106
|
+
*
|
|
107
|
+
* @param olderThanMs Age threshold in milliseconds.
|
|
108
|
+
* @returns The number of rows deleted.
|
|
109
|
+
*/
|
|
110
|
+
async pruneOlderThan(olderThanMs: number): Promise<number> {
|
|
111
|
+
const result = await this.pool.query(
|
|
112
|
+
`DELETE FROM lattice_outbox
|
|
113
|
+
WHERE created_at < now() - ($1::bigint || ' milliseconds')::interval
|
|
114
|
+
RETURNING id`,
|
|
115
|
+
[olderThanMs],
|
|
116
|
+
);
|
|
117
|
+
return result.rowCount ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Maps a `lattice_outbox` row to an {@link OutboxEvent}. */
|
|
121
|
+
private rowToEvent(row: {
|
|
122
|
+
id: string | number;
|
|
123
|
+
topic: string;
|
|
124
|
+
payload: unknown;
|
|
125
|
+
origin_replica_id: string;
|
|
126
|
+
created_at: Date | string;
|
|
127
|
+
}): OutboxEvent {
|
|
128
|
+
return {
|
|
129
|
+
id: String(row.id),
|
|
130
|
+
topic: row.topic,
|
|
131
|
+
payload: row.payload,
|
|
132
|
+
originReplicaId: row.origin_replica_id,
|
|
133
|
+
createdAt: new Date(row.created_at).toISOString(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -339,6 +339,60 @@ export class PostgreSQLScheduleStorage implements ScheduleStorage {
|
|
|
339
339
|
);
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Atomically claims one due fire by transitioning the task `pending -> running`.
|
|
344
|
+
*
|
|
345
|
+
* Only one caller wins the `UPDATE ... WHERE status = 'pending'`; all others
|
|
346
|
+
* observe zero affected rows and must skip execution.
|
|
347
|
+
*
|
|
348
|
+
* @param taskId Task to claim.
|
|
349
|
+
* @returns True when this caller won the claim (task was `pending`); false if
|
|
350
|
+
* another replica already claimed it or it is not pending.
|
|
351
|
+
*/
|
|
352
|
+
async claimScheduledRun(taskId: string): Promise<boolean> {
|
|
353
|
+
await this.ensureInitialized();
|
|
354
|
+
|
|
355
|
+
const result = await this.pool.query(
|
|
356
|
+
`UPDATE lattice_scheduled_tasks
|
|
357
|
+
SET status = $1, updated_at = $2
|
|
358
|
+
WHERE task_id = $3 AND status = $4
|
|
359
|
+
RETURNING task_id`,
|
|
360
|
+
[
|
|
361
|
+
ScheduledTaskStatus.RUNNING,
|
|
362
|
+
new Date(),
|
|
363
|
+
taskId,
|
|
364
|
+
ScheduledTaskStatus.PENDING,
|
|
365
|
+
]
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return (result.rowCount ?? 0) > 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Requeues `running` tasks whose `updated_at` is older than `olderThanMs` back
|
|
373
|
+
* to `pending` (recovery after a crash that left a claim unsettled).
|
|
374
|
+
*
|
|
375
|
+
* @param olderThanMs Age threshold in milliseconds.
|
|
376
|
+
* @returns The number of tasks requeued.
|
|
377
|
+
*/
|
|
378
|
+
async requeueStaleRunning(olderThanMs: number): Promise<number> {
|
|
379
|
+
await this.ensureInitialized();
|
|
380
|
+
|
|
381
|
+
const result = await this.pool.query(
|
|
382
|
+
`UPDATE lattice_scheduled_tasks
|
|
383
|
+
SET status = $1, updated_at = now()
|
|
384
|
+
WHERE status = $2 AND updated_at < now() - ($3::bigint || ' milliseconds')::interval
|
|
385
|
+
RETURNING task_id`,
|
|
386
|
+
[
|
|
387
|
+
ScheduledTaskStatus.PENDING,
|
|
388
|
+
ScheduledTaskStatus.RUNNING,
|
|
389
|
+
olderThanMs,
|
|
390
|
+
]
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
return result.rowCount ?? 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
342
396
|
/**
|
|
343
397
|
* Delete task
|
|
344
398
|
*/
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
AddMessageParams,
|
|
13
13
|
ThreadInfo,
|
|
14
14
|
QueueScope,
|
|
15
|
+
ClaimMessagesOptions,
|
|
15
16
|
} from "@axiom-lattice/core";
|
|
16
17
|
import {
|
|
17
18
|
parseQueuedExecutionMode,
|
|
@@ -20,12 +21,7 @@ import {
|
|
|
20
21
|
type TrustedRunContext,
|
|
21
22
|
} from "@axiom-lattice/protocols";
|
|
22
23
|
import { MigrationManager } from "../migrations/migration";
|
|
23
|
-
import {
|
|
24
|
-
import { addPriorityAndCommandColumns } from "../migrations/add_priority_command_columns";
|
|
25
|
-
import { addCustomRunConfigColumn } from "../migrations/add_custom_run_config_column";
|
|
26
|
-
import { alterMessageQueueIdColumn } from "../migrations/alter_message_queue_id_column";
|
|
27
|
-
import { addWorkspaceProjectToQueue } from "../migrations/add_workspace_project_to_queue";
|
|
28
|
-
import { addTrustedRunContextColumn } from "../migrations/add_trusted_run_context_column";
|
|
24
|
+
import { registerThreadMessageQueueMigrations } from "../migrations/queue_migrations";
|
|
29
25
|
|
|
30
26
|
export type { PendingMessage, AddMessageParams, ThreadInfo };
|
|
31
27
|
|
|
@@ -61,12 +57,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
61
57
|
}
|
|
62
58
|
|
|
63
59
|
this.migrationManager = new MigrationManager(this.pool);
|
|
64
|
-
this.migrationManager
|
|
65
|
-
this.migrationManager.register(addPriorityAndCommandColumns);
|
|
66
|
-
this.migrationManager.register(addCustomRunConfigColumn);
|
|
67
|
-
this.migrationManager.register(alterMessageQueueIdColumn);
|
|
68
|
-
this.migrationManager.register(addWorkspaceProjectToQueue);
|
|
69
|
-
this.migrationManager.register(addTrustedRunContextColumn);
|
|
60
|
+
registerThreadMessageQueueMigrations(this.migrationManager);
|
|
70
61
|
|
|
71
62
|
if (options.autoMigrate !== false) {
|
|
72
63
|
this.initialize().catch((error) => {
|
|
@@ -211,6 +202,50 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
211
202
|
return result.rows.map(row => this.rowToMessage(row));
|
|
212
203
|
}
|
|
213
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Atomically claims pending messages for a replica, stamping a lease.
|
|
207
|
+
*/
|
|
208
|
+
async claimPendingMessages(
|
|
209
|
+
threadId: string,
|
|
210
|
+
scope: QueueScope | undefined,
|
|
211
|
+
options: ClaimMessagesOptions,
|
|
212
|
+
): Promise<PendingMessage[]> {
|
|
213
|
+
const filter = scopeClause(scope, 4);
|
|
214
|
+
const limitClause = options.limit === undefined ? "" : ` LIMIT ${Math.max(0, Math.floor(options.limit))}`;
|
|
215
|
+
const result = await this.pool.query(
|
|
216
|
+
`UPDATE lattice_thread_message_queue q
|
|
217
|
+
SET status = 'processing',
|
|
218
|
+
locked_by = $2,
|
|
219
|
+
locked_at = now(),
|
|
220
|
+
lease_expires_at = now() + ($3::bigint || ' milliseconds')::interval
|
|
221
|
+
WHERE q.id IN (
|
|
222
|
+
SELECT id FROM lattice_thread_message_queue
|
|
223
|
+
WHERE thread_id = $1 AND status = 'pending'${filter.sql}
|
|
224
|
+
ORDER BY priority DESC, sequence_order ASC${limitClause}
|
|
225
|
+
FOR UPDATE SKIP LOCKED
|
|
226
|
+
)
|
|
227
|
+
RETURNING q.*`,
|
|
228
|
+
[threadId, options.owner, options.ttlMs, ...filter.params],
|
|
229
|
+
);
|
|
230
|
+
return result.rows.map((row) => this.rowToMessage(row));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns expired processing rows to pending and clears their lease.
|
|
235
|
+
*/
|
|
236
|
+
async reclaimExpiredProcessing(threadId: string, scope?: QueueScope): Promise<number> {
|
|
237
|
+
const filter = scopeClause(scope, 2);
|
|
238
|
+
const result = await this.pool.query(
|
|
239
|
+
`UPDATE lattice_thread_message_queue
|
|
240
|
+
SET status = 'pending', locked_by = NULL, locked_at = NULL, lease_expires_at = NULL
|
|
241
|
+
WHERE thread_id = $1 AND status = 'processing'
|
|
242
|
+
AND lease_expires_at IS NOT NULL AND lease_expires_at <= now()${filter.sql}
|
|
243
|
+
RETURNING id`,
|
|
244
|
+
[threadId, ...filter.params],
|
|
245
|
+
);
|
|
246
|
+
return result.rowCount ?? 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
214
249
|
/**
|
|
215
250
|
* Get processing messages for a thread
|
|
216
251
|
*/
|
|
@@ -311,7 +346,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
311
346
|
const filter = scopeClause(scope, 2);
|
|
312
347
|
const result = await this.pool.query(
|
|
313
348
|
`UPDATE lattice_thread_message_queue
|
|
314
|
-
SET status = 'pending'
|
|
349
|
+
SET status = 'pending', locked_by = NULL, locked_at = NULL, lease_expires_at = NULL
|
|
315
350
|
WHERE thread_id = $1 AND status = 'processing'${filter.sql}
|
|
316
351
|
RETURNING id`,
|
|
317
352
|
[threadId, ...filter.params]
|
|
@@ -322,9 +357,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
|
|
|
322
357
|
private rowToMessage(row: any): PendingMessage {
|
|
323
358
|
return {
|
|
324
359
|
id: row.id,
|
|
325
|
-
content:
|
|
326
|
-
? JSON.parse(row.message_content)
|
|
327
|
-
: row.message_content,
|
|
360
|
+
content: row.message_content,
|
|
328
361
|
type: row.message_type,
|
|
329
362
|
sequence: row.sequence_order,
|
|
330
363
|
createdAt: new Date(row.created_at),
|