import { randomUUID } from "node:crypto"; import type { BuildEvent, BuildRequest, BuildStatus, Sha256Digest, } from "../shared/build-protocol"; export const BUILD_RECORD_API_VERSION = "kuber.astrxl.dev/v1" as const; export interface BuildRecord { apiVersion: typeof BUILD_RECORD_API_VERSION; kind: "BuildRecord"; metadata: { name: string; resourceVersion: string; creationTimestamp: string; labels: Record; }; spec: { request: BuildRequest; imageKey: string; jobName: string; workspaceSubPath: string; }; status: BuildStatus & { cancelled?: boolean; jobCreated?: boolean; logBytes: number; logOffset: number; nextSequence: number; events: BuildEvent[]; reconcileLease?: BuildReconciliationLease; }; } /** A record-local fencing token for distributed build reconciliation. */ export interface BuildReconciliationLease { holder: string; token: string; expiresAt: string; } export interface UploadRecord { apiVersion: typeof BUILD_RECORD_API_VERSION; kind: "BuildUpload"; metadata: { name: string; resourceVersion: string; creationTimestamp: string; }; spec: { digest: Sha256Digest; size: number }; status: { offset: number; data: Uint8Array }; } export type CreateBuildResult = { record: BuildRecord; created: boolean; superseded?: BuildRecord[]; }; /** Implementations must make createBuild and replace operations atomic. */ export interface BuildStore { createBuild(record: BuildRecord): Promise; getBuild(id: string): Promise; listBuilds(): Promise; replaceBuild( record: BuildRecord, expectedResourceVersion: string, reconcileLeaseToken?: string, ): Promise; acquireBuildReconciliationLease( id: string, holder: string, ttlMs: number, ): Promise; renewBuildReconciliationLease( id: string, token: string, ttlMs: number, ): Promise; releaseBuildReconciliationLease(id: string, token: string): Promise; ownsBuild?(imageKey: string, buildId: string): Promise; getUpload(digest: Sha256Digest): Promise; createUpload(record: UploadRecord): Promise; replaceUpload( record: UploadRecord, expectedResourceVersion: string, ): Promise; deleteUpload(digest: Sha256Digest): Promise; } export class BuildStoreConflictError extends Error { readonly code = "BUILD_STORE_CONFLICT"; } function clone(value: T): T { return structuredClone(value); } function terminal(record: BuildRecord): boolean { return ( record.status.state === "succeeded" || record.status.state === "failed" ); } function sameSpec(left: BuildRecord, right: BuildRecord): boolean { return JSON.stringify(left.spec) === JSON.stringify(right.spec); } export function buildTimestamp(record: BuildRecord): string { const creationTimestamp = record.metadata?.creationTimestamp; if (typeof creationTimestamp === "string") return creationTimestamp; const createdAt = record.status?.createdAt; return typeof createdAt === "string" ? createdAt : ""; } export function compareBuildRecords( left: BuildRecord, right: BuildRecord, ): number { const timestamp = buildTimestamp(left).localeCompare(buildTimestamp(right)); if (timestamp !== 0) return timestamp; return left.metadata.name.localeCompare(right.metadata.name); } function newer(left: BuildRecord, right: BuildRecord): boolean { return compareBuildRecords(left, right) > 0; } function supersede(record: BuildRecord, finishedAt: string): BuildRecord { const reason = "Superseded by newer build"; record.status = { ...record.status, state: "failed", finishedAt, error: reason, cancelled: true, events: [ ...record.status.events, { type: "status", status: { version: record.status.version, id: record.status.id, state: "failed", createdAt: record.status.createdAt, ...(record.status.startedAt && { startedAt: record.status.startedAt, }), finishedAt, error: reason, }, }, ], }; record.metadata.resourceVersion = String( Number(record.metadata.resourceVersion) + 1, ); return record; } export class MemoryBuildStore implements BuildStore { private readonly builds = new Map(); private readonly uploads = new Map(); private readonly active = new Map(); private readonly byImage = new Map>(); async createBuild(record: BuildRecord): Promise { const existing = this.builds.get(record.metadata.name); if (existing) { if (!sameSpec(existing, record)) { throw new BuildStoreConflictError( "Build ID was already used for a different request", ); } if (terminal(existing)) return { record: clone(existing), created: false }; const candidates = [...(this.byImage.get(existing.spec.imageKey) ?? [])] .map((id) => this.builds.get(id)) .filter( (value): value is BuildRecord => !!value && !terminal(value) && value.metadata.name !== existing.metadata.name, ); const newerCandidate = candidates.find((candidate) => newer(candidate, existing), ); if (newerCandidate) { const superseded = supersede(existing, buildTimestamp(existing)); this.builds.set(existing.metadata.name, clone(superseded)); if (this.active.get(existing.spec.imageKey) === existing.metadata.name) this.active.delete(existing.spec.imageKey); return { record: clone(superseded), created: false, superseded: [clone(superseded)], }; } const superseded: BuildRecord[] = []; for (const old of candidates) { const next = supersede(old, buildTimestamp(existing)); this.builds.set(old.metadata.name, clone(next)); superseded.push(clone(next)); } this.active.set(existing.spec.imageKey, existing.metadata.name); return { record: clone(existing), created: false, ...(superseded.length && { superseded }), }; } const candidates = [...(this.byImage.get(record.spec.imageKey) ?? [])] .map((id) => this.builds.get(id)) .filter((value): value is BuildRecord => !!value && !terminal(value)); const active = candidates.sort(compareBuildRecords).at(-1); if (active && newer(active, record)) { const superseded = supersede(record, record.status.createdAt); this.builds.set(record.metadata.name, clone(superseded)); this.byImage.get(record.spec.imageKey)?.add(record.metadata.name); return { record: clone(superseded), created: false, superseded: [clone(superseded)], }; } if (active) { const old = supersede(active, record.status.createdAt); this.builds.set(active.metadata.name, clone(old)); if (this.active.get(record.spec.imageKey) === active.metadata.name) this.active.delete(record.spec.imageKey); } this.builds.set(record.metadata.name, clone(record)); this.byImage.set( record.spec.imageKey, new Set([ ...(this.byImage.get(record.spec.imageKey) ?? []), record.metadata.name, ]), ); this.active.set(record.spec.imageKey, record.metadata.name); return { record: clone(record), created: true, superseded: active ? [clone(this.builds.get(active.metadata.name)!)] : undefined, }; } async getBuild(id: string) { const value = this.builds.get(id); return value && clone(value); } async listBuilds() { return [...this.builds.values()].sort(compareBuildRecords).map(clone); } async replaceBuild( record: BuildRecord, expectedResourceVersion: string, reconcileLeaseToken?: string, ) { const current = this.builds.get(record.metadata.name); if ( !current || current.metadata.resourceVersion !== expectedResourceVersion ) { throw new BuildStoreConflictError( "Build record was concurrently modified", ); } if (!sameSpec(current, record)) { throw new BuildStoreConflictError("Build specification is immutable"); } if ( reconcileLeaseToken && current.status.reconcileLease?.token !== reconcileLeaseToken ) { throw new BuildStoreConflictError( "Build reconciliation lease ownership was lost", ); } if ( current.status.state === "succeeded" && (record.status.state !== "succeeded" || record.status.digest !== current.status.digest) ) { throw new BuildStoreConflictError( "A successful image digest is immutable", ); } this.builds.set(record.metadata.name, clone(record)); if ( terminal(record) && this.active.get(record.spec.imageKey) === record.metadata.name ) { this.active.delete(record.spec.imageKey); } } async acquireBuildReconciliationLease( id: string, holder: string, ttlMs: number, ): Promise { if (!holder || !Number.isFinite(ttlMs) || ttlMs <= 0) throw new BuildStoreConflictError("Invalid build reconciliation lease"); const current = this.builds.get(id); if (!current) return; if ( current.status.reconcileLease && Date.parse(current.status.reconcileLease.expiresAt) > Date.now() ) { return; } const lease: BuildReconciliationLease = { holder, token: randomUUID(), expiresAt: new Date(Date.now() + ttlMs).toISOString(), }; const next = clone(current); next.metadata.resourceVersion = String( Number(current.metadata.resourceVersion) + 1, ); next.status.reconcileLease = lease; await this.replaceBuild(next, current.metadata.resourceVersion); return clone(lease); } async renewBuildReconciliationLease( id: string, token: string, ttlMs: number, ): Promise { if (!token || !Number.isFinite(ttlMs) || ttlMs <= 0) return false; const current = this.builds.get(id); if ( !current || current.status.reconcileLease?.token !== token || Date.parse(current.status.reconcileLease.expiresAt) <= Date.now() ) { return false; } const next = clone(current); next.metadata.resourceVersion = String( Number(current.metadata.resourceVersion) + 1, ); next.status.reconcileLease!.expiresAt = new Date( Date.now() + ttlMs, ).toISOString(); try { await this.replaceBuild(next, current.metadata.resourceVersion, token); return true; } catch (error) { if (error instanceof BuildStoreConflictError) return false; throw error; } } async releaseBuildReconciliationLease( id: string, token: string, ): Promise { const current = this.builds.get(id); if (current?.status.reconcileLease?.token !== token) return; const next = clone(current); next.metadata.resourceVersion = String( Number(current.metadata.resourceVersion) + 1, ); delete next.status.reconcileLease; await this.replaceBuild(next, current.metadata.resourceVersion, token); } async ownsBuild(imageKey: string, buildId: string): Promise { return this.active.get(imageKey) === buildId; } async getUpload(digest: Sha256Digest) { const value = this.uploads.get(digest); return value && clone(value); } async createUpload(record: UploadRecord) { const current = this.uploads.get(record.spec.digest); if (current) return clone(current); this.uploads.set(record.spec.digest, clone(record)); return clone(record); } async replaceUpload(record: UploadRecord, expectedResourceVersion: string) { const current = this.uploads.get(record.spec.digest); if ( !current || current.metadata.resourceVersion !== expectedResourceVersion ) { throw new BuildStoreConflictError( "Upload record was concurrently modified", ); } this.uploads.set(record.spec.digest, clone(record)); } async deleteUpload(digest: Sha256Digest) { this.uploads.delete(digest); } }