feat(platform-service): fleet repositories with rev compare-and-swap (P2 foundation)
One repository per fleet_* container on the @bytelyst/datastore abstraction (memory + cosmos): create/getById/list (by productId, stage, idempotencyKey), partition-aware single-partition queries, ordered append-only appendEvent, and runs/leases/factories/profiles/artifacts CRUD. Adds revUpdateJob/revUpdateLease — a `rev`-token compare-and-swap that writes only when the stored rev still matches (the optimistic-concurrency primitive for atomic claim + fenced transitions; maps to Cosmos _etag/If-Match in production).
This commit is contained in:
parent
721d3fcb48
commit
fada354df8
187
services/platform-service/src/modules/fleet/repository.test.ts
Normal file
187
services/platform-service/src/modules/fleet/repository.test.ts
Normal file
@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Fleet repository — CRUD round-trips, list filters, appendEvent ordering, and
|
||||
* the rev compare-and-swap. Runs on the in-memory datastore provider.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
|
||||
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
|
||||
import * as repo from './repository.js';
|
||||
import type { FleetJobDoc } from './types.js';
|
||||
|
||||
const PID = 'lysnrai';
|
||||
const now = '2026-05-30T00:00:00.000Z';
|
||||
|
||||
function jobDoc(over: Partial<FleetJobDoc> = {}): FleetJobDoc {
|
||||
return {
|
||||
id: 'fjob_1',
|
||||
productId: PID,
|
||||
stage: 'queued',
|
||||
idempotencyKey: 'task-1',
|
||||
contentHash: 'h1',
|
||||
bodyMd: '# task',
|
||||
manifestSnapshot: {
|
||||
priority: 'medium',
|
||||
capabilities: [],
|
||||
prefersEngine: [],
|
||||
allowedScope: [],
|
||||
deps: [],
|
||||
},
|
||||
priority: 'medium',
|
||||
priorityOrder: 2,
|
||||
capabilities: [],
|
||||
deps: [],
|
||||
kind: 'leaf',
|
||||
attempts: 0,
|
||||
leaseEpoch: 0,
|
||||
rev: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('fleet repository', () => {
|
||||
beforeEach(() => setProvider(new MemoryDatastoreProvider()));
|
||||
afterEach(() => _resetDatastoreProvider());
|
||||
|
||||
it('jobs: create / getById / list by stage + idempotencyKey', async () => {
|
||||
await repo.createJob(jobDoc({ id: 'fjob_1', idempotencyKey: 'a', stage: 'queued' }));
|
||||
await repo.createJob(jobDoc({ id: 'fjob_2', idempotencyKey: 'b', stage: 'blocked' }));
|
||||
|
||||
expect((await repo.getJob('fjob_1', PID))?.idempotencyKey).toBe('a');
|
||||
expect(await repo.getJob('missing', PID)).toBeNull();
|
||||
|
||||
const queued = await repo.listJobs({ productId: PID, stage: 'queued' });
|
||||
expect(queued.map(j => j.id)).toEqual(['fjob_1']);
|
||||
|
||||
const byKey = await repo.findJobsByIdempotencyKey(PID, 'b');
|
||||
expect(byKey).toHaveLength(1);
|
||||
expect(byKey[0].id).toBe('fjob_2');
|
||||
});
|
||||
|
||||
it('jobs: revUpdate is a compare-and-swap', async () => {
|
||||
await repo.createJob(jobDoc({ id: 'fjob_cas', rev: 0, stage: 'queued' }));
|
||||
|
||||
const first = await repo.revUpdateJob('fjob_cas', PID, 0, { stage: 'assigned' });
|
||||
expect(first.ok).toBe(true);
|
||||
if (first.ok) expect(first.doc.rev).toBe(1);
|
||||
|
||||
// a second writer using the stale rev (0) must conflict
|
||||
const stale = await repo.revUpdateJob('fjob_cas', PID, 0, { stage: 'building' });
|
||||
expect(stale.ok).toBe(false);
|
||||
if (!stale.ok) expect(stale.reason).toBe('conflict');
|
||||
|
||||
const missing = await repo.revUpdateJob('nope', PID, 0, { stage: 'failed' });
|
||||
expect(missing.ok).toBe(false);
|
||||
if (!missing.ok) expect(missing.reason).toBe('not_found');
|
||||
});
|
||||
|
||||
it('runs: create + list ordered by attempt', async () => {
|
||||
await repo.createRun({
|
||||
id: 'r2',
|
||||
productId: PID,
|
||||
jobId: 'j',
|
||||
attempt: 2,
|
||||
engine: 'devin',
|
||||
startedAt: now,
|
||||
insights: {},
|
||||
});
|
||||
await repo.createRun({
|
||||
id: 'r1',
|
||||
productId: PID,
|
||||
jobId: 'j',
|
||||
attempt: 1,
|
||||
engine: 'devin',
|
||||
startedAt: now,
|
||||
insights: {},
|
||||
});
|
||||
const runs = await repo.listRunsByJob('j');
|
||||
expect(runs.map(r => r.attempt)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('leases: create + revUpdate', async () => {
|
||||
await repo.createLease({
|
||||
id: 'j',
|
||||
productId: PID,
|
||||
jobId: 'j',
|
||||
leaseEpoch: 1,
|
||||
renewals: 0,
|
||||
status: 'held',
|
||||
rev: 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
const res = await repo.revUpdateLease('j', 0, { renewals: 1 });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) expect(res.doc.renewals).toBe(1);
|
||||
expect((await repo.getLease('j'))?.rev).toBe(1);
|
||||
});
|
||||
|
||||
it('factories: upsert + list by productId', async () => {
|
||||
await repo.upsertFactory({
|
||||
id: 'fac_1',
|
||||
productId: PID,
|
||||
factoryId: 'fac_1',
|
||||
descriptor: {},
|
||||
capabilities: ['os:mac'],
|
||||
health: 'ok',
|
||||
load: 0,
|
||||
seatLimit: 1,
|
||||
lastHeartbeatAt: now,
|
||||
});
|
||||
await repo.upsertFactory({
|
||||
id: 'fac_1',
|
||||
productId: PID,
|
||||
factoryId: 'fac_1',
|
||||
descriptor: {},
|
||||
capabilities: ['os:mac'],
|
||||
health: 'degraded',
|
||||
load: 2,
|
||||
seatLimit: 1,
|
||||
lastHeartbeatAt: now,
|
||||
});
|
||||
const list = await repo.listFactories(PID);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].health).toBe('degraded');
|
||||
});
|
||||
|
||||
it('profiles: create + get', async () => {
|
||||
await repo.createProfile({
|
||||
id: 'prof_1',
|
||||
productId: PID,
|
||||
name: 'backend',
|
||||
version: 1,
|
||||
snapshot: { persona: 'x' },
|
||||
createdAt: now,
|
||||
});
|
||||
expect((await repo.getProfile('prof_1', PID))?.name).toBe('backend');
|
||||
});
|
||||
|
||||
it('events: appendEvent yields an ordered, append-only stream', async () => {
|
||||
await repo.appendEvent({ jobId: 'j', productId: PID, type: 'submitted' });
|
||||
await repo.appendEvent({ jobId: 'j', productId: PID, type: 'assigned', actor: 'fac_1' });
|
||||
await repo.appendEvent({
|
||||
jobId: 'j',
|
||||
productId: PID,
|
||||
type: 'transition',
|
||||
data: { stage: 'building' },
|
||||
});
|
||||
const events = await repo.listEvents('j');
|
||||
expect(events.map(e => e.seq)).toEqual([0, 1, 2]);
|
||||
expect(events.map(e => e.type)).toEqual(['submitted', 'assigned', 'transition']);
|
||||
});
|
||||
|
||||
it('artifacts: create + list', async () => {
|
||||
await repo.createArtifact({
|
||||
id: 'art_1',
|
||||
productId: PID,
|
||||
jobId: 'j',
|
||||
kind: 'coverage',
|
||||
blobUrl: 'https://b/x',
|
||||
createdAt: now,
|
||||
});
|
||||
const arts = await repo.listArtifacts('j');
|
||||
expect(arts).toHaveLength(1);
|
||||
expect(arts[0].blobUrl).toBe('https://b/x');
|
||||
});
|
||||
});
|
||||
257
services/platform-service/src/modules/fleet/repository.ts
Normal file
257
services/platform-service/src/modules/fleet/repository.ts
Normal file
@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Fleet repositories — one per fleet_* container, cloud-agnostic via @bytelyst/datastore.
|
||||
*
|
||||
* Partition keys (see lib/cosmos-init.ts):
|
||||
* fleet_jobs /productId fleet_runs /jobId
|
||||
* fleet_leases /jobId fleet_factories /productId
|
||||
* fleet_profiles /productId fleet_events /jobId
|
||||
* fleet_artifacts /jobId
|
||||
*
|
||||
* Optimistic concurrency: jobs and leases carry a monotonic `rev` token.
|
||||
* `revUpdate*` is a compare-and-swap — it writes only when the stored `rev`
|
||||
* still equals the caller's expected `rev`, otherwise it reports a conflict
|
||||
* WITHOUT writing. In production (Cosmos) this maps to an `_etag` / If-Match
|
||||
* conditional replace; on the memory provider it is enforced by re-reading the
|
||||
* current `rev` immediately before the write, which is exact for the sequential
|
||||
* calls the coordinator + tests make (see coordinator.claimNextJob).
|
||||
*/
|
||||
|
||||
import type { DocumentCollection } from '@bytelyst/datastore';
|
||||
import { getCollection } from '../../lib/datastore.js';
|
||||
import type {
|
||||
FleetArtifactDoc,
|
||||
FleetEventDoc,
|
||||
FleetFactoryDoc,
|
||||
FleetJobDoc,
|
||||
FleetLeaseDoc,
|
||||
FleetProfileDoc,
|
||||
FleetRunDoc,
|
||||
FleetStage,
|
||||
} from './types.js';
|
||||
|
||||
// ── Collections ───────────────────────────────────────────────────────────────
|
||||
|
||||
function jobs(): DocumentCollection<FleetJobDoc> {
|
||||
return getCollection<FleetJobDoc>('fleet_jobs', '/productId');
|
||||
}
|
||||
function runs(): DocumentCollection<FleetRunDoc> {
|
||||
return getCollection<FleetRunDoc>('fleet_runs', '/jobId');
|
||||
}
|
||||
function leases(): DocumentCollection<FleetLeaseDoc> {
|
||||
return getCollection<FleetLeaseDoc>('fleet_leases', '/jobId');
|
||||
}
|
||||
function factories(): DocumentCollection<FleetFactoryDoc> {
|
||||
return getCollection<FleetFactoryDoc>('fleet_factories', '/productId');
|
||||
}
|
||||
function profiles(): DocumentCollection<FleetProfileDoc> {
|
||||
return getCollection<FleetProfileDoc>('fleet_profiles', '/productId');
|
||||
}
|
||||
function events(): DocumentCollection<FleetEventDoc> {
|
||||
return getCollection<FleetEventDoc>('fleet_events', '/jobId');
|
||||
}
|
||||
function artifacts(): DocumentCollection<FleetArtifactDoc> {
|
||||
return getCollection<FleetArtifactDoc>('fleet_artifacts', '/jobId');
|
||||
}
|
||||
|
||||
/** Result of a compare-and-swap update. */
|
||||
export type RevResult<T> = { ok: true; doc: T } | { ok: false; reason: 'not_found' | 'conflict' };
|
||||
|
||||
// ── Jobs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createJob(doc: FleetJobDoc): Promise<FleetJobDoc> {
|
||||
return jobs().create(doc);
|
||||
}
|
||||
|
||||
export async function getJob(id: string, productId: string): Promise<FleetJobDoc | null> {
|
||||
return jobs().findById(id, productId);
|
||||
}
|
||||
|
||||
export interface ListJobsFilter {
|
||||
productId: string;
|
||||
stage?: FleetStage;
|
||||
idempotencyKey?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function listJobs(f: ListJobsFilter): Promise<FleetJobDoc[]> {
|
||||
const filter: Record<string, string> = { productId: f.productId };
|
||||
if (f.stage) filter.stage = f.stage;
|
||||
if (f.idempotencyKey) filter.idempotencyKey = f.idempotencyKey;
|
||||
return jobs().findMany({
|
||||
filter,
|
||||
sort: { priorityOrder: 1, createdAt: 1 },
|
||||
offset: f.offset,
|
||||
limit: f.limit,
|
||||
});
|
||||
}
|
||||
|
||||
/** All jobs for a product sharing an idempotency-key (dedupe lookups). */
|
||||
export async function findJobsByIdempotencyKey(
|
||||
productId: string,
|
||||
idempotencyKey: string
|
||||
): Promise<FleetJobDoc[]> {
|
||||
return jobs().findMany({ filter: { productId, idempotencyKey } });
|
||||
}
|
||||
|
||||
/** Unconditional merge update (use only when concurrency is not contended). */
|
||||
export async function updateJob(
|
||||
id: string,
|
||||
productId: string,
|
||||
updates: Partial<FleetJobDoc>
|
||||
): Promise<FleetJobDoc | null> {
|
||||
const cur = await jobs().findById(id, productId);
|
||||
if (!cur) return null;
|
||||
return jobs().update(id, productId, {
|
||||
...updates,
|
||||
rev: (cur.rev ?? 0) + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Compare-and-swap on `rev` — the atomic-claim / fenced-transition primitive. */
|
||||
export async function revUpdateJob(
|
||||
id: string,
|
||||
productId: string,
|
||||
expectedRev: number,
|
||||
updates: Partial<FleetJobDoc>
|
||||
): Promise<RevResult<FleetJobDoc>> {
|
||||
const cur = await jobs().findById(id, productId);
|
||||
if (!cur) return { ok: false, reason: 'not_found' };
|
||||
if ((cur.rev ?? 0) !== expectedRev) return { ok: false, reason: 'conflict' };
|
||||
const doc = await jobs().update(id, productId, {
|
||||
...updates,
|
||||
rev: expectedRev + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { ok: true, doc };
|
||||
}
|
||||
|
||||
export async function deleteJob(id: string, productId: string): Promise<void> {
|
||||
await jobs().delete(id, productId);
|
||||
}
|
||||
|
||||
// ── Runs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createRun(doc: FleetRunDoc): Promise<FleetRunDoc> {
|
||||
return runs().create(doc);
|
||||
}
|
||||
|
||||
export async function updateRun(
|
||||
id: string,
|
||||
jobId: string,
|
||||
updates: Partial<FleetRunDoc>
|
||||
): Promise<FleetRunDoc | null> {
|
||||
const cur = await runs().findById(id, jobId);
|
||||
if (!cur) return null;
|
||||
return runs().update(id, jobId, updates);
|
||||
}
|
||||
|
||||
export async function listRunsByJob(jobId: string): Promise<FleetRunDoc[]> {
|
||||
return runs().findMany({ filter: { jobId }, sort: { attempt: 1 } });
|
||||
}
|
||||
|
||||
// ── Leases ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getLease(jobId: string): Promise<FleetLeaseDoc | null> {
|
||||
return leases().findById(jobId, jobId);
|
||||
}
|
||||
|
||||
export async function createLease(doc: FleetLeaseDoc): Promise<FleetLeaseDoc> {
|
||||
return leases().create(doc);
|
||||
}
|
||||
|
||||
export async function revUpdateLease(
|
||||
jobId: string,
|
||||
expectedRev: number,
|
||||
updates: Partial<FleetLeaseDoc>
|
||||
): Promise<RevResult<FleetLeaseDoc>> {
|
||||
const cur = await leases().findById(jobId, jobId);
|
||||
if (!cur) return { ok: false, reason: 'not_found' };
|
||||
if ((cur.rev ?? 0) !== expectedRev) return { ok: false, reason: 'conflict' };
|
||||
const doc = await leases().update(jobId, jobId, {
|
||||
...updates,
|
||||
rev: expectedRev + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { ok: true, doc };
|
||||
}
|
||||
|
||||
export async function listExpiredLeases(nowIso: string): Promise<FleetLeaseDoc[]> {
|
||||
return leases().findMany({
|
||||
filter: { status: 'held', expiresAt: { $lt: nowIso } },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Factories ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getFactory(
|
||||
factoryId: string,
|
||||
productId: string
|
||||
): Promise<FleetFactoryDoc | null> {
|
||||
return factories().findById(factoryId, productId);
|
||||
}
|
||||
|
||||
export async function upsertFactory(doc: FleetFactoryDoc): Promise<FleetFactoryDoc> {
|
||||
return factories().upsert(doc);
|
||||
}
|
||||
|
||||
export async function listFactories(productId: string): Promise<FleetFactoryDoc[]> {
|
||||
return factories().findMany({ filter: { productId } });
|
||||
}
|
||||
|
||||
// ── Profiles ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProfile(doc: FleetProfileDoc): Promise<FleetProfileDoc> {
|
||||
return profiles().create(doc);
|
||||
}
|
||||
|
||||
export async function getProfile(id: string, productId: string): Promise<FleetProfileDoc | null> {
|
||||
return profiles().findById(id, productId);
|
||||
}
|
||||
|
||||
export async function listProfiles(productId: string): Promise<FleetProfileDoc[]> {
|
||||
return profiles().findMany({ filter: { productId } });
|
||||
}
|
||||
|
||||
// ── Events (append-only) ────────────────────────────────────────────────────
|
||||
|
||||
export interface AppendEventInput {
|
||||
jobId: string;
|
||||
productId: string;
|
||||
type: string;
|
||||
actor?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Append an ordered event to a job's stream; `seq` is monotonic per job. */
|
||||
export async function appendEvent(input: AppendEventInput): Promise<FleetEventDoc> {
|
||||
const existing = await events().findMany({ filter: { jobId: input.jobId } });
|
||||
const seq = existing.length;
|
||||
const doc: FleetEventDoc = {
|
||||
id: `${input.jobId}:evt:${seq}`,
|
||||
productId: input.productId,
|
||||
jobId: input.jobId,
|
||||
seq,
|
||||
type: input.type,
|
||||
at: new Date().toISOString(),
|
||||
actor: input.actor,
|
||||
data: input.data ?? {},
|
||||
};
|
||||
return events().create(doc);
|
||||
}
|
||||
|
||||
export async function listEvents(jobId: string): Promise<FleetEventDoc[]> {
|
||||
const docs = await events().findMany({ filter: { jobId }, sort: { seq: 1 } });
|
||||
return docs;
|
||||
}
|
||||
|
||||
// ── Artifacts ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createArtifact(doc: FleetArtifactDoc): Promise<FleetArtifactDoc> {
|
||||
return artifacts().create(doc);
|
||||
}
|
||||
|
||||
export async function listArtifacts(jobId: string): Promise<FleetArtifactDoc[]> {
|
||||
return artifacts().findMany({ filter: { jobId }, sort: { createdAt: 1 } });
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user