feat(fleet): native Mosaic backlog on @mosaicstack/db (atomic claim + TTL) (#657)
This commit was merged in pull request #657.
This commit is contained in:
22
packages/db/drizzle/0011_bitter_gateway.sql
Normal file
22
packages/db/drizzle/0011_bitter_gateway.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
CREATE TYPE "public"."backlog_status" AS ENUM('ready', 'claimed', 'blocked', 'done');--> statement-breakpoint
|
||||
CREATE TABLE "backlog" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"body" text,
|
||||
"phase" text,
|
||||
"priority" integer DEFAULT 0 NOT NULL,
|
||||
"status" "backlog_status" DEFAULT 'ready' NOT NULL,
|
||||
"depends_on" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"claim_owner" text,
|
||||
"claim_ttl_seconds" integer,
|
||||
"claimed_at" timestamp with time zone,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"idempotency_key" text,
|
||||
"acceptance" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "backlog_status_priority_idx" ON "backlog" USING btree ("status","priority");--> statement-breakpoint
|
||||
CREATE INDEX "backlog_status_claimed_at_idx" ON "backlog" USING btree ("status","claimed_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "backlog_idempotency_key_idx" ON "backlog" USING btree ("idempotency_key");
|
||||
3631
packages/db/drizzle/meta/0011_snapshot.json
Normal file
3631
packages/db/drizzle/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1745366400000,
|
||||
"tag": "0010_federation_enrollment_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1782310438919,
|
||||
"tag": "0011_bitter_gateway",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
263
packages/db/src/backlog.spec.ts
Normal file
263
packages/db/src/backlog.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { createPgliteDb } from './client-pglite.js';
|
||||
import { runPgliteMigrations } from './migrate.js';
|
||||
import type { DbHandle } from './client.js';
|
||||
import { BacklogService } from './backlog.js';
|
||||
import { backlog } from './schema.js';
|
||||
|
||||
// Helper: backdate a claim's claimed_at by 1 hour so it is past any short TTL.
|
||||
function sqlBackdate(id: string) {
|
||||
return sql`UPDATE ${backlog} SET claimed_at = now() - interval '1 hour' WHERE ${backlog.id} = ${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real Postgres semantics, no external server: embedded in-memory PGlite.
|
||||
* The migration path creates the `backlog` table (and every other table) so the
|
||||
* service runs against the actual generated schema, including the row locks the
|
||||
* atomic-claim path depends on.
|
||||
*/
|
||||
async function freshService(): Promise<{ handle: DbHandle; svc: BacklogService }> {
|
||||
const handle = createPgliteDb('memory://');
|
||||
await runPgliteMigrations(handle);
|
||||
return { handle, svc: new BacklogService(handle.db) };
|
||||
}
|
||||
|
||||
describe('BacklogService', () => {
|
||||
let handle: DbHandle;
|
||||
let svc: BacklogService;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ handle, svc } = await freshService());
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle.close();
|
||||
});
|
||||
|
||||
it('create then list returns the card', async () => {
|
||||
await svc.create({ id: 'c1', title: 'First card', phase: 'M1', priority: 5 });
|
||||
const all = await svc.list();
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]).toMatchObject({ id: 'c1', title: 'First card', phase: 'M1', status: 'ready' });
|
||||
});
|
||||
|
||||
it('idempotency_key dedups create', async () => {
|
||||
const a = await svc.create({ id: 'c1', title: 'one', idempotencyKey: 'k-1' });
|
||||
const b = await svc.create({ id: 'c2', title: 'two', idempotencyKey: 'k-1' });
|
||||
expect(b.id).toBe(a.id);
|
||||
const all = await svc.list();
|
||||
expect(all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('list filters by status and phase', async () => {
|
||||
await svc.create({ id: 'c1', title: 'a', phase: 'M1' });
|
||||
await svc.create({ id: 'c2', title: 'b', phase: 'M2' });
|
||||
await svc.block('c2');
|
||||
expect(await svc.list({ phase: 'M1' })).toHaveLength(1);
|
||||
expect(await svc.list({ status: 'blocked' })).toHaveLength(1);
|
||||
expect((await svc.list({ status: 'blocked' }))[0]!.id).toBe('c2');
|
||||
});
|
||||
|
||||
describe('atomic claim', () => {
|
||||
it('two concurrent claimers on one card => exactly one wins', async () => {
|
||||
await svc.create({ id: 'only', title: 'the one', priority: 10 });
|
||||
|
||||
// Two independent claimers race for the single ready card on the same db.
|
||||
// The atomic claim path (`FOR UPDATE SKIP LOCKED` inside a transaction)
|
||||
// guarantees the loser's locked row is skipped, so it can never also flip
|
||||
// the card to claimed — it gets the next candidate (none) and returns null.
|
||||
const svcA = new BacklogService(handle.db);
|
||||
const svcB = new BacklogService(handle.db);
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
svcA.claim({ owner: 'worker-A' }),
|
||||
svcB.claim({ owner: 'worker-B' }),
|
||||
]);
|
||||
|
||||
const winners = [a, b].filter((c) => c !== null);
|
||||
expect(winners).toHaveLength(1);
|
||||
expect(winners[0]!.id).toBe('only');
|
||||
expect(winners[0]!.status).toBe('claimed');
|
||||
expect(['worker-A', 'worker-B']).toContain(winners[0]!.claimOwner);
|
||||
|
||||
const card = await svc.get('only');
|
||||
expect(card!.status).toBe('claimed');
|
||||
expect(card!.attempts).toBe(1);
|
||||
});
|
||||
|
||||
it('many concurrent claimers on N cards => no card is double-claimed', async () => {
|
||||
// 5 ready cards, 8 concurrent claimers. Exactly 5 win, all distinct.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await svc.create({ id: `card-${i}`, title: `card ${i}`, priority: i });
|
||||
}
|
||||
const claimers = Array.from({ length: 8 }, (_, i) =>
|
||||
new BacklogService(handle.db).claim({ owner: `w-${i}` }),
|
||||
);
|
||||
const results = await Promise.all(claimers);
|
||||
const won = results.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
const wonIds = won.map((c) => c.id);
|
||||
expect(won).toHaveLength(5);
|
||||
expect(new Set(wonIds).size).toBe(5); // all distinct — no double-claim
|
||||
});
|
||||
|
||||
it('N concurrent claimers on N ready cards => every claimer wins a distinct card (no starvation)', async () => {
|
||||
// This is the direct benefit of locking exactly ONE ready row per claim
|
||||
// (`FOR UPDATE SKIP LOCKED LIMIT 1`): with as many ready cards as
|
||||
// claimers, NONE should starve. The old "lock the whole ready set"
|
||||
// behaviour let one claimer lock every row, forcing the rest to null even
|
||||
// though cards were free.
|
||||
const N = 6;
|
||||
for (let i = 0; i < N; i++) {
|
||||
await svc.create({ id: `n-${i}`, title: `card ${i}`, priority: i });
|
||||
}
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: N }, (_, i) =>
|
||||
new BacklogService(handle.db).claim({ owner: `w-${i}` }),
|
||||
),
|
||||
);
|
||||
const won = results.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
// No claimer starved: all N won.
|
||||
expect(won).toHaveLength(N);
|
||||
// Each won a distinct card.
|
||||
expect(new Set(won.map((c) => c.id)).size).toBe(N);
|
||||
// Every ready card was consumed.
|
||||
expect(await svc.list({ status: 'ready' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sequential claims drain ready cards in priority order and never null while ready remain', async () => {
|
||||
// PGlite-stable fallback assertion of the same property without relying on
|
||||
// true parallelism or wall-clock timing: each claim returns the next
|
||||
// highest-priority distinct card and never spuriously returns null while
|
||||
// ready cards remain.
|
||||
const N = 4;
|
||||
for (let i = 0; i < N; i++) {
|
||||
await svc.create({ id: `s-${i}`, title: `card ${i}`, priority: i });
|
||||
}
|
||||
const order: string[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const claimed = await svc.claim({ owner: `w-${i}` });
|
||||
expect(claimed).not.toBeNull();
|
||||
order.push(claimed!.id);
|
||||
}
|
||||
// Highest priority first, all distinct.
|
||||
expect(order).toEqual(['s-3', 's-2', 's-1', 's-0']);
|
||||
expect(new Set(order).size).toBe(N);
|
||||
// Now nothing ready remains => null.
|
||||
expect(await svc.claim({ owner: 'late' })).toBeNull();
|
||||
});
|
||||
|
||||
it('claim picks the highest-priority ready card', async () => {
|
||||
await svc.create({ id: 'low', title: 'low', priority: 1 });
|
||||
await svc.create({ id: 'high', title: 'high', priority: 9 });
|
||||
const claimed = await svc.claim({ owner: 'w' });
|
||||
expect(claimed!.id).toBe('high');
|
||||
});
|
||||
|
||||
it('claim of a specific --id', async () => {
|
||||
await svc.create({ id: 'a', title: 'a', priority: 9 });
|
||||
await svc.create({ id: 'b', title: 'b', priority: 1 });
|
||||
const claimed = await svc.claim({ owner: 'w', id: 'b' });
|
||||
expect(claimed!.id).toBe('b');
|
||||
});
|
||||
|
||||
it('claim returns null when nothing is ready', async () => {
|
||||
const claimed = await svc.claim({ owner: 'w' });
|
||||
expect(claimed).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deps DAG gate', () => {
|
||||
it('card with an unfinished dep is not claimable and not ready', async () => {
|
||||
await svc.create({ id: 'dep', title: 'dependency' });
|
||||
await svc.create({ id: 'main', title: 'depends on dep', dependsOn: ['dep'] });
|
||||
|
||||
// `main` should NOT be claimable while `dep` is not done — `dep` wins.
|
||||
const first = await svc.claim({ owner: 'w' });
|
||||
expect(first!.id).toBe('dep');
|
||||
|
||||
// With dep claimed (not done), main still cannot be claimed.
|
||||
const second = await svc.claim({ owner: 'w' });
|
||||
expect(second).toBeNull();
|
||||
|
||||
// ready-only list excludes main while its dep is unfinished.
|
||||
const ready = await svc.list({ readyOnly: true });
|
||||
expect(ready.map((c) => c.id)).not.toContain('main');
|
||||
|
||||
// Once dep is done, main becomes ready and claimable.
|
||||
await svc.complete('dep');
|
||||
const readyAfter = await svc.list({ readyOnly: true });
|
||||
expect(readyAfter.map((c) => c.id)).toContain('main');
|
||||
const third = await svc.claim({ owner: 'w' });
|
||||
expect(third!.id).toBe('main');
|
||||
});
|
||||
|
||||
it('link adds a depends_on edge', async () => {
|
||||
await svc.create({ id: 'a', title: 'a' });
|
||||
await svc.create({ id: 'b', title: 'b' });
|
||||
const linked = await svc.link('a', 'b');
|
||||
expect(linked.dependsOn).toEqual(['b']);
|
||||
// a is now gated on b
|
||||
const claimed = await svc.claim({ owner: 'w' });
|
||||
expect(claimed!.id).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reclaim TTL', () => {
|
||||
it('reclaim returns expired claims to ready', async () => {
|
||||
await svc.create({ id: 'c1', title: 'c1' });
|
||||
const claimed = await svc.claim({ owner: 'w', ttlSeconds: 60 });
|
||||
expect(claimed!.status).toBe('claimed');
|
||||
|
||||
// Backdate the claim so it is well past its TTL.
|
||||
await handle.db.execute(sqlBackdate('c1'));
|
||||
|
||||
const result = await svc.reclaim();
|
||||
expect(result.reclaimed).toEqual(['c1']);
|
||||
const card = await svc.get('c1');
|
||||
expect(card!.status).toBe('ready');
|
||||
expect(card!.claimOwner).toBeNull();
|
||||
expect(card!.claimedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('reclaim does not touch a fresh (unexpired) claim', async () => {
|
||||
await svc.create({ id: 'c1', title: 'c1' });
|
||||
await svc.claim({ owner: 'w', ttlSeconds: 3600 });
|
||||
const result = await svc.reclaim();
|
||||
expect(result.reclaimed).toEqual([]);
|
||||
expect((await svc.get('c1'))!.status).toBe('claimed');
|
||||
});
|
||||
|
||||
it('reclaim --id releases a specific claim regardless of expiry', async () => {
|
||||
await svc.create({ id: 'c1', title: 'c1' });
|
||||
await svc.claim({ owner: 'w', ttlSeconds: 3600 });
|
||||
const result = await svc.reclaim({ id: 'c1' });
|
||||
expect(result.reclaimed).toEqual(['c1']);
|
||||
expect((await svc.get('c1'))!.status).toBe('ready');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stats', () => {
|
||||
it('computes counts, oldest-ready age, and expired-claim count', async () => {
|
||||
await svc.create({ id: 'r1', title: 'r1' });
|
||||
await svc.create({ id: 'r2', title: 'r2' });
|
||||
await svc.create({ id: 'b1', title: 'b1' });
|
||||
await svc.block('b1');
|
||||
await svc.create({ id: 'd1', title: 'd1' });
|
||||
await svc.complete('d1');
|
||||
await svc.create({ id: 'cl1', title: 'cl1' });
|
||||
await svc.claim({ owner: 'w', id: 'cl1', ttlSeconds: 60 });
|
||||
await handle.db.execute(sqlBackdate('cl1'));
|
||||
|
||||
const stats = await svc.stats();
|
||||
expect(stats.counts.ready).toBe(2);
|
||||
expect(stats.counts.blocked).toBe(1);
|
||||
expect(stats.counts.done).toBe(1);
|
||||
expect(stats.counts.claimed).toBe(1);
|
||||
expect(stats.total).toBe(5);
|
||||
expect(stats.expiredClaimCount).toBe(1);
|
||||
expect(stats.oldestReadyAgeSeconds).not.toBeNull();
|
||||
expect(stats.oldestReadyAgeSeconds!).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
457
packages/db/src/backlog.ts
Normal file
457
packages/db/src/backlog.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Mosaic-native backlog-of-record service (card A4).
|
||||
*
|
||||
* This is the backlog Mosaic owns end-to-end on its OWN Postgres storage layer.
|
||||
* It REPLACES the former Hermes adapter — there is NO runtime dependency on
|
||||
* Hermes here or anywhere downstream.
|
||||
*
|
||||
* The service takes a `Db` handle, so it works identically against:
|
||||
* - `createDb()` — server Postgres (DATABASE_URL / config), and
|
||||
* - `createPgliteDb()` — embedded Postgres (file or in-memory).
|
||||
* Same code, same semantics — PGlite gives real Postgres behaviour (including
|
||||
* row locks), so the atomic-claim path is exercised by the in-memory tests.
|
||||
*
|
||||
* Atomic claim: `claim()` selects the highest-priority, deps-satisfied, ready
|
||||
* card with `SELECT ... FOR UPDATE SKIP LOCKED` and flips it to `claimed` inside
|
||||
* one transaction. Two concurrent claimers can therefore NEVER both win the same
|
||||
* card — the loser's locked row is skipped and it picks the next candidate (or
|
||||
* gets null).
|
||||
*/
|
||||
|
||||
import { and, asc, desc, eq, sql } from 'drizzle-orm';
|
||||
import type { Db } from './client.js';
|
||||
import { backlog } from './schema.js';
|
||||
|
||||
export type BacklogStatus = 'ready' | 'claimed' | 'blocked' | 'done';
|
||||
|
||||
export interface BacklogCard {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string | null;
|
||||
phase: string | null;
|
||||
priority: number;
|
||||
status: BacklogStatus;
|
||||
dependsOn: string[];
|
||||
claimOwner: string | null;
|
||||
claimTtlSeconds: number | null;
|
||||
claimedAt: Date | null;
|
||||
attempts: number;
|
||||
idempotencyKey: string | null;
|
||||
acceptance: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateCardInput {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
phase?: string | null;
|
||||
priority?: number;
|
||||
dependsOn?: string[];
|
||||
acceptance?: unknown;
|
||||
idempotencyKey?: string | null;
|
||||
status?: BacklogStatus;
|
||||
}
|
||||
|
||||
export interface ListFilter {
|
||||
status?: BacklogStatus;
|
||||
phase?: string;
|
||||
/** When true, return only cards that are `ready` AND have all deps `done`. */
|
||||
readyOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ClaimOptions {
|
||||
owner: string;
|
||||
/** Claim time-to-live in seconds (default 900). */
|
||||
ttlSeconds?: number;
|
||||
/** Claim a specific card by id instead of the highest-priority ready one. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface ReclaimResult {
|
||||
reclaimed: string[];
|
||||
}
|
||||
|
||||
export interface BacklogStats {
|
||||
counts: Record<BacklogStatus, number>;
|
||||
total: number;
|
||||
oldestReadyAgeSeconds: number | null;
|
||||
expiredClaimCount: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLAIM_TTL_SECONDS = 900;
|
||||
|
||||
type Row = typeof backlog.$inferSelect;
|
||||
|
||||
/**
|
||||
* Row shape as returned by the raw `SELECT * ... FOR UPDATE SKIP LOCKED` path.
|
||||
* That path bypasses drizzle's column-name mapping, so JSON columns arrive as
|
||||
* the snake_case `depends_on` (and may be a JSON string under some drivers).
|
||||
*/
|
||||
interface RawRow extends Row {
|
||||
depends_on?: unknown;
|
||||
}
|
||||
|
||||
function toCard(row: Row): BacklogCard {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
phase: row.phase,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
dependsOn: row.dependsOn ?? [],
|
||||
claimOwner: row.claimOwner,
|
||||
claimTtlSeconds: row.claimTtlSeconds,
|
||||
claimedAt: row.claimedAt,
|
||||
attempts: row.attempts,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
acceptance: row.acceptance,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The backlog repository/service. Construct with any `Db` handle.
|
||||
*/
|
||||
export class BacklogService {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
/**
|
||||
* Create a card. If `idempotencyKey` is provided and a card already exists
|
||||
* with that key, the existing card is returned unchanged (no duplicate).
|
||||
*/
|
||||
async create(input: CreateCardInput): Promise<BacklogCard> {
|
||||
if (input.idempotencyKey) {
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(backlog)
|
||||
.where(eq(backlog.idempotencyKey, input.idempotencyKey))
|
||||
.limit(1);
|
||||
if (existing[0]) return toCard(existing[0]);
|
||||
}
|
||||
|
||||
const inserted = await this.db
|
||||
.insert(backlog)
|
||||
.values({
|
||||
id: input.id,
|
||||
title: input.title,
|
||||
body: input.body ?? null,
|
||||
phase: input.phase ?? null,
|
||||
priority: input.priority ?? 0,
|
||||
status: input.status ?? 'ready',
|
||||
dependsOn: input.dependsOn ?? [],
|
||||
acceptance: input.acceptance ?? null,
|
||||
idempotencyKey: input.idempotencyKey ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toCard(inserted[0]!);
|
||||
}
|
||||
|
||||
/** Fetch a single card by id, or null. */
|
||||
async get(id: string): Promise<BacklogCard | null> {
|
||||
const rows = await this.db.select().from(backlog).where(eq(backlog.id, id)).limit(1);
|
||||
return rows[0] ? toCard(rows[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List cards with optional filters. `readyOnly` enforces the DAG gate:
|
||||
* a card is "ready" only when its own status is `ready` AND every card in
|
||||
* `depends_on` exists and is `done`.
|
||||
*/
|
||||
async list(filter: ListFilter = {}): Promise<BacklogCard[]> {
|
||||
const conditions = [];
|
||||
if (filter.status) conditions.push(eq(backlog.status, filter.status));
|
||||
if (filter.phase) conditions.push(eq(backlog.phase, filter.phase));
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(backlog)
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(backlog.priority), asc(backlog.createdAt));
|
||||
|
||||
const cards = rows.map(toCard);
|
||||
if (!filter.readyOnly) return cards;
|
||||
|
||||
const doneIds = await this.doneIdSet();
|
||||
return cards.filter(
|
||||
(c) => c.status === 'ready' && c.dependsOn.every((dep) => doneIds.has(dep)),
|
||||
);
|
||||
}
|
||||
|
||||
private async doneIdSet(): Promise<Set<string>> {
|
||||
const done = await this.db
|
||||
.select({ id: backlog.id })
|
||||
.from(backlog)
|
||||
.where(eq(backlog.status, 'done'));
|
||||
return new Set(done.map((d) => d.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim a card.
|
||||
*
|
||||
* Strategy: inside ONE transaction we lock the candidate row with
|
||||
* `FOR UPDATE SKIP LOCKED LIMIT 1`. A concurrent claimer that already holds
|
||||
* the lock on a row has that row skipped for us, so two claimers can never
|
||||
* both win the same card — and, crucially, each claimer locks exactly ONE
|
||||
* row, so concurrent claimers fan out across distinct ready cards instead of
|
||||
* one claimer locking the whole ready set and starving the rest.
|
||||
*
|
||||
* Candidate selection (when no explicit `id`):
|
||||
* - status = 'ready'
|
||||
* - all deps satisfied (every id in depends_on is currently 'done')
|
||||
* - ordered by priority DESC, created_at ASC
|
||||
*
|
||||
* Returns the claimed card, or null if nothing is claimable.
|
||||
*/
|
||||
async claim(opts: ClaimOptions): Promise<BacklogCard | null> {
|
||||
const ttl = opts.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
|
||||
|
||||
return this.db.transaction(async (tx) => {
|
||||
// Specific-id path: lock that one ready row (if free) and apply the
|
||||
// deps-satisfied gate in JS, exactly as before.
|
||||
if (opts.id) {
|
||||
const doneRows = await tx
|
||||
.select({ id: backlog.id })
|
||||
.from(backlog)
|
||||
.where(eq(backlog.status, 'done'));
|
||||
const doneIds = new Set(doneRows.map((r) => r.id));
|
||||
|
||||
const result = await tx.execute(
|
||||
sql`SELECT * FROM ${backlog}
|
||||
WHERE ${backlog.id} = ${opts.id} AND ${backlog.status} = 'ready'
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
);
|
||||
const candidate = rowsOf(result).find((row) =>
|
||||
normalizeDeps(row.depends_on).every((dep) => doneIds.has(dep)),
|
||||
);
|
||||
if (!candidate) return null;
|
||||
|
||||
const updated = await tx
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'claimed',
|
||||
claimOwner: opts.owner,
|
||||
claimTtlSeconds: ttl,
|
||||
claimedAt: new Date(),
|
||||
attempts: sql`${backlog.attempts} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backlog.id, candidate.id))
|
||||
.returning();
|
||||
|
||||
return toCard(updated[0]!);
|
||||
}
|
||||
|
||||
// No-id path: claim the single highest-priority, deps-satisfied ready
|
||||
// card. We lock exactly ONE row in the inner SELECT (`FOR UPDATE SKIP
|
||||
// LOCKED LIMIT 1`) so concurrent claimers grab distinct cards rather than
|
||||
// one claimer locking every ready row and forcing the others to null.
|
||||
//
|
||||
// The deps-satisfied gate is pushed into SQL so `LIMIT 1` lands on the
|
||||
// next genuinely-eligible card: a card is eligible iff none of its
|
||||
// depends_on ids is absent from the set of 'done' card ids.
|
||||
const updated = await tx.execute(
|
||||
sql`UPDATE ${backlog}
|
||||
SET status = 'claimed',
|
||||
claim_owner = ${opts.owner},
|
||||
claim_ttl_seconds = ${ttl},
|
||||
claimed_at = now(),
|
||||
attempts = ${backlog.attempts} + 1,
|
||||
updated_at = now()
|
||||
WHERE ${backlog.id} = (
|
||||
SELECT b.id FROM ${backlog} AS b
|
||||
WHERE b.status = 'ready'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements_text(b.depends_on) AS dep
|
||||
WHERE dep NOT IN (
|
||||
SELECT d.id FROM ${backlog} AS d WHERE d.status = 'done'
|
||||
)
|
||||
)
|
||||
ORDER BY b.priority DESC, b.created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *`,
|
||||
);
|
||||
|
||||
const row = rowsOf(updated)[0];
|
||||
return row ? toCard(rawToRow(row)) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Release expired claims (claimed_at + ttl < now) back to `ready`, OR release
|
||||
* a specific card by id regardless of expiry. Cleared claim fields.
|
||||
* Returns the ids that were released.
|
||||
*/
|
||||
async reclaim(opts: { id?: string } = {}): Promise<ReclaimResult> {
|
||||
if (opts.id) {
|
||||
const released = await this.db
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'ready',
|
||||
claimOwner: null,
|
||||
claimTtlSeconds: null,
|
||||
claimedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(backlog.id, opts.id), eq(backlog.status, 'claimed')))
|
||||
.returning({ id: backlog.id });
|
||||
return { reclaimed: released.map((r) => r.id) };
|
||||
}
|
||||
|
||||
// Expired = status claimed AND claimed_at + (ttl seconds) < now().
|
||||
const released = await this.db
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'ready',
|
||||
claimOwner: null,
|
||||
claimTtlSeconds: null,
|
||||
claimedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(backlog.status, 'claimed'),
|
||||
sql`${backlog.claimedAt} + make_interval(secs => ${backlog.claimTtlSeconds}) < now()`,
|
||||
),
|
||||
)
|
||||
.returning({ id: backlog.id });
|
||||
return { reclaimed: released.map((r) => r.id) };
|
||||
}
|
||||
|
||||
/** Add a `depends_on` edge (from → depends on → to). Idempotent. */
|
||||
async link(from: string, to: string): Promise<BacklogCard> {
|
||||
const card = await this.get(from);
|
||||
if (!card) throw new Error(`backlog card not found: ${from}`);
|
||||
const target = await this.get(to);
|
||||
if (!target) throw new Error(`backlog dependency not found: ${to}`);
|
||||
if (from === to) throw new Error('a card cannot depend on itself');
|
||||
|
||||
if (card.dependsOn.includes(to)) return card;
|
||||
const nextDeps = [...card.dependsOn, to];
|
||||
const updated = await this.db
|
||||
.update(backlog)
|
||||
.set({ dependsOn: nextDeps, updatedAt: new Date() })
|
||||
.where(eq(backlog.id, from))
|
||||
.returning();
|
||||
return toCard(updated[0]!);
|
||||
}
|
||||
|
||||
/** Mark a card blocked. */
|
||||
async block(id: string): Promise<BacklogCard | null> {
|
||||
return this.setStatus(id, 'blocked');
|
||||
}
|
||||
|
||||
/** Mark a card done (releasing any claim). */
|
||||
async complete(id: string): Promise<BacklogCard | null> {
|
||||
const updated = await this.db
|
||||
.update(backlog)
|
||||
.set({
|
||||
status: 'done',
|
||||
claimOwner: null,
|
||||
claimTtlSeconds: null,
|
||||
claimedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backlog.id, id))
|
||||
.returning();
|
||||
return updated[0] ? toCard(updated[0]) : null;
|
||||
}
|
||||
|
||||
private async setStatus(id: string, status: BacklogStatus): Promise<BacklogCard | null> {
|
||||
const updated = await this.db
|
||||
.update(backlog)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(backlog.id, id))
|
||||
.returning();
|
||||
return updated[0] ? toCard(updated[0]) : null;
|
||||
}
|
||||
|
||||
/** Counts by status, oldest-ready age (seconds), and expired-claim count. */
|
||||
async stats(): Promise<BacklogStats> {
|
||||
const all = await this.db.select().from(backlog);
|
||||
const counts: Record<BacklogStatus, number> = {
|
||||
ready: 0,
|
||||
claimed: 0,
|
||||
blocked: 0,
|
||||
done: 0,
|
||||
};
|
||||
let oldestReady: Date | null = null;
|
||||
let expiredClaimCount = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for (const row of all) {
|
||||
counts[row.status] += 1;
|
||||
if (row.status === 'ready') {
|
||||
if (oldestReady === null || row.createdAt < oldestReady) oldestReady = row.createdAt;
|
||||
}
|
||||
if (row.status === 'claimed' && row.claimedAt && row.claimTtlSeconds != null) {
|
||||
const expiry = row.claimedAt.getTime() + row.claimTtlSeconds * 1000;
|
||||
if (expiry < now) expiredClaimCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
counts,
|
||||
total: all.length,
|
||||
oldestReadyAgeSeconds:
|
||||
oldestReady === null ? null : Math.max(0, Math.floor((now - oldestReady.getTime()) / 1000)),
|
||||
expiredClaimCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract rows from a drizzle `.execute()` result across drivers (pg / pglite). */
|
||||
function rowsOf(result: unknown): RawRow[] {
|
||||
if (Array.isArray(result)) return result as RawRow[];
|
||||
const maybe = result as { rows?: unknown };
|
||||
if (maybe && Array.isArray(maybe.rows)) return maybe.rows as RawRow[];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw `RETURNING *` row (snake_case columns, possibly string-encoded
|
||||
* timestamps/JSON depending on the driver) onto the drizzle `Row` shape that
|
||||
* `toCard` consumes. Mirrors the column ↔ property mapping in `schema.ts`.
|
||||
*/
|
||||
function rawToRow(raw: RawRow): Row {
|
||||
const r = raw as unknown as Record<string, unknown>;
|
||||
const toDate = (v: unknown): Date => (v instanceof Date ? v : new Date(v as string));
|
||||
return {
|
||||
id: r.id as string,
|
||||
title: r.title as string,
|
||||
body: (r.body ?? null) as string | null,
|
||||
phase: (r.phase ?? null) as string | null,
|
||||
priority: Number(r.priority),
|
||||
status: r.status as BacklogStatus,
|
||||
dependsOn: normalizeDeps(r.depends_on),
|
||||
claimOwner: (r.claim_owner ?? null) as string | null,
|
||||
claimTtlSeconds: r.claim_ttl_seconds == null ? null : Number(r.claim_ttl_seconds),
|
||||
claimedAt: r.claimed_at == null ? null : toDate(r.claimed_at),
|
||||
attempts: Number(r.attempts),
|
||||
idempotencyKey: (r.idempotency_key ?? null) as string | null,
|
||||
acceptance: r.acceptance ?? null,
|
||||
createdAt: toDate(r.created_at),
|
||||
updatedAt: toDate(r.updated_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** A raw SQL row returns snake_case `depends_on`; normalize to string[]. */
|
||||
function normalizeDeps(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -3,6 +3,17 @@ export { createPgliteDb } from './client-pglite.js';
|
||||
export { runMigrations, runPgliteMigrations } from './migrate.js';
|
||||
export * from './schema.js';
|
||||
export * from './federation.js';
|
||||
export {
|
||||
BacklogService,
|
||||
DEFAULT_CLAIM_TTL_SECONDS,
|
||||
type BacklogCard,
|
||||
type BacklogStatus,
|
||||
type BacklogStats,
|
||||
type ClaimOptions,
|
||||
type CreateCardInput,
|
||||
type ListFilter,
|
||||
type ReclaimResult,
|
||||
} from './backlog.js';
|
||||
export {
|
||||
eq,
|
||||
and,
|
||||
|
||||
@@ -587,6 +587,62 @@ export const summarizationJobs = pgTable(
|
||||
(t) => [index('summarization_jobs_status_idx').on(t.status)],
|
||||
);
|
||||
|
||||
// ─── Fleet Backlog ────────────────────────────────────────────────────────────
|
||||
// Mosaic-native backlog-of-record (card A4). This REPLACES the former Hermes
|
||||
// adapter — there is NO runtime dependency on Hermes. Cards form a dependency
|
||||
// DAG (`depends_on`), are claimed atomically by fleet workers via
|
||||
// `SELECT ... FOR UPDATE SKIP LOCKED`, and auto-expire via a TTL so a crashed
|
||||
// claimer's card returns to the pool.
|
||||
|
||||
/**
|
||||
* Lifecycle status of a backlog card.
|
||||
* - ready: eligible to be claimed (once its deps are all `done`).
|
||||
* - claimed: a worker holds it (claim_owner + claimed_at set); may expire via TTL.
|
||||
* - blocked: explicitly parked; never auto-claimed.
|
||||
* - done: completed; satisfies dependents.
|
||||
*/
|
||||
export const backlogStatusEnum = pgEnum('backlog_status', ['ready', 'claimed', 'blocked', 'done']);
|
||||
|
||||
export const backlog = pgTable(
|
||||
'backlog',
|
||||
{
|
||||
/** Stable, caller-supplied card id (e.g. "A4", "fleet-001"). PK. */
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').notNull(),
|
||||
body: text('body'),
|
||||
/** Board/phase grouping (e.g. "M1", "fleet"). Free-form. */
|
||||
phase: text('phase'),
|
||||
/** Higher number = higher priority; claim picks the max-priority ready card. */
|
||||
priority: integer('priority').notNull().default(0),
|
||||
status: backlogStatusEnum('status').notNull().default('ready'),
|
||||
/** DAG edges: ids of cards this one depends on. "ready" requires all done. */
|
||||
dependsOn: jsonb('depends_on').notNull().$type<string[]>().default([]),
|
||||
/** Owner token of the current claim (worker/agent id). NULL when unclaimed. */
|
||||
claimOwner: text('claim_owner'),
|
||||
/** TTL of the active claim in seconds. NULL when unclaimed. */
|
||||
claimTtlSeconds: integer('claim_ttl_seconds'),
|
||||
/** When the active claim was taken. NULL when unclaimed. claimed_at + ttl = expiry. */
|
||||
claimedAt: timestamp('claimed_at', { withTimezone: true }),
|
||||
/** Count of times this card has been claimed (incremented on each claim). */
|
||||
attempts: integer('attempts').notNull().default(0),
|
||||
/** Optional dedup key for `create`; a repeat key returns the existing card. */
|
||||
idempotencyKey: text('idempotency_key'),
|
||||
/** Acceptance criteria — free-form JSON (array of strings or object). */
|
||||
acceptance: jsonb('acceptance'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// Hot path: claim scans ready cards ordered by priority then age.
|
||||
index('backlog_status_priority_idx').on(t.status, t.priority),
|
||||
// reclaim sweeps claimed cards by claimed_at to find expired ones.
|
||||
index('backlog_status_claimed_at_idx').on(t.status, t.claimedAt),
|
||||
// Idempotent create dedups on this key (NULLs are distinct in Postgres, so
|
||||
// many unkeyed cards coexist; a repeated non-null key collides).
|
||||
uniqueIndex('backlog_idempotency_key_idx').on(t.idempotencyKey),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Federation ──────────────────────────────────────────────────────────────
|
||||
// Enums declared before tables that reference them.
|
||||
// All federation definitions live in this file (avoids CJS/ESM cross-import
|
||||
|
||||
Reference in New Issue
Block a user