feat: platform backbone and field capture, first part (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks: - run roles api/worker/job/all with shared startup checks (Task 1) - schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2) - audit, notifications and domain events written in the same transaction (Task 3) - dispatcher with SKIP LOCKED claims, retries and stale release (Task 4) - worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5) - worker process and entry points (Task 6) - throttler counters in Postgres (Task 19) - separate keys per purpose with key ring (Task 20) - public origin page without exact location (Task 22) - shop keys split into read and write (Task 23) - Kustomize base, components, overlay encry, CI validation (Task 26) Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks: - brood frames, read endpoints, apiary overview and offline pack - network basics, offline queue v3, navigation sheets, form building blocks - new inspection with last-time hints and weather, varroa per day, treatment prefill, apiary page with round - inspection photo (Task 10) is implemented, review pending Not rolled out. Remaining tasks follow in later commits. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
10cbe9bf2d
commit
c01a38835a
340 files changed
+25695
-1808
No files matched your search
@@ -0,0 +1,204 @@
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { DbClient } from '../../prisma/db-client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
|
||||
|
||||
/**
|
||||
* Claim, settle and stale release for every outbox table. One worker owns
|
||||
* an entry from the claim (status PROCESSING, claimedAt = now()) until it
|
||||
* settles it; settling is a compare-and-set on id + claimedAt, so a worker
|
||||
* whose claim was released in the meantime changes nothing. Every time
|
||||
* comparison and calculation runs in SQL against now() - the database
|
||||
* clock - never against a JS Date.
|
||||
*/
|
||||
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
|
||||
|
||||
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
|
||||
|
||||
export interface ClaimOptions {
|
||||
limit: number;
|
||||
/** Extra condition, ANDed to the claim (e.g. a correlation id filter). */
|
||||
filter?: Prisma.Sql;
|
||||
orderBy: OutboxOrderColumn;
|
||||
}
|
||||
|
||||
interface OutboxTableSql {
|
||||
table: Prisma.Sql;
|
||||
statusType: Prisma.Sql;
|
||||
settledAtColumn: Prisma.Sql;
|
||||
successStatus: 'DONE' | 'DELIVERED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Table, enum and column names cannot be bound as parameters. They come
|
||||
* only from these closed maps - never from caller input - which is what
|
||||
* makes Prisma.raw safe here.
|
||||
*/
|
||||
const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
|
||||
DomainEvent: {
|
||||
table: Prisma.raw('"DomainEvent"'),
|
||||
statusType: Prisma.raw('"DomainEventStatus"'),
|
||||
settledAtColumn: Prisma.raw('"processedAt"'),
|
||||
successStatus: 'DONE',
|
||||
},
|
||||
WebhookDelivery: {
|
||||
table: Prisma.raw('"WebhookDelivery"'),
|
||||
statusType: Prisma.raw('"WebhookDeliveryStatus"'),
|
||||
settledAtColumn: Prisma.raw('"deliveredAt"'),
|
||||
successStatus: 'DELIVERED',
|
||||
},
|
||||
};
|
||||
|
||||
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
|
||||
occurredAt: Prisma.raw('"occurredAt"'),
|
||||
createdAt: Prisma.raw('"createdAt"'),
|
||||
};
|
||||
|
||||
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
|
||||
|
||||
function outboxTable(table: OutboxTable): OutboxTableSql {
|
||||
if (!Object.hasOwn(OUTBOX_TABLES, table)) {
|
||||
throw new Error(`Unknown outbox table "${String(table)}"`);
|
||||
}
|
||||
return OUTBOX_TABLES[table];
|
||||
}
|
||||
|
||||
function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
|
||||
if (!Object.hasOwn(ORDER_COLUMNS, column)) {
|
||||
throw new Error(`Unknown outbox order column "${String(column)}"`);
|
||||
}
|
||||
return ORDER_COLUMNS[column];
|
||||
}
|
||||
|
||||
/**
|
||||
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
|
||||
* several workers claim side by side without waiting for or overlapping
|
||||
* each other. Returns the claimed rows (all columns) in claim order.
|
||||
* Uses the root client: a claim must commit on its own.
|
||||
*/
|
||||
export async function claimBatch<Row>(
|
||||
db: PrismaService,
|
||||
table: OutboxTable,
|
||||
options: ClaimOptions,
|
||||
): Promise<Row[]> {
|
||||
if (!Number.isInteger(options.limit) || options.limit < 1) {
|
||||
throw new RangeError(
|
||||
`Claim limit must be a positive integer, got ${options.limit}`,
|
||||
);
|
||||
}
|
||||
const { table: name } = outboxTable(table);
|
||||
const orderBy = orderColumn(options.orderBy);
|
||||
const filter = options.filter
|
||||
? Prisma.sql`AND ${options.filter}`
|
||||
: Prisma.empty;
|
||||
return db.$queryRaw<Row[]>(Prisma.sql`
|
||||
WITH claimed AS (
|
||||
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
|
||||
WHERE "id" IN (
|
||||
SELECT "id" FROM ${name}
|
||||
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ${options.limit}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT * FROM claimed ORDER BY ${orderBy}`);
|
||||
}
|
||||
|
||||
/** An entry a stale release put back (PENDING) or gave up on (FAILED). */
|
||||
export interface ReleasedClaim {
|
||||
id: string;
|
||||
status: 'PENDING' | 'FAILED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts entries whose claim is older than STALE_CLAIM_MS (their worker died)
|
||||
* back to PENDING right away, with attempts + 1 - or to FAILED once that
|
||||
* reaches MAX_ATTEMPTS, so an entry that kills its worker cannot loop
|
||||
* forever. Returns the released entries, for follow-up work on FAILED ones.
|
||||
*/
|
||||
export async function releaseStaleClaimRows(
|
||||
db: PrismaService,
|
||||
table: OutboxTable,
|
||||
): Promise<ReleasedClaim[]> {
|
||||
const { table: name, statusType } = outboxTable(table);
|
||||
return db.$queryRaw<ReleasedClaim[]>(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = CAST(CASE WHEN "attempts" + 1 >= ${MAX_ATTEMPTS} THEN 'FAILED' ELSE 'PENDING' END AS ${statusType}),
|
||||
"attempts" = "attempts" + 1,
|
||||
"claimedAt" = NULL,
|
||||
"availableAt" = now(),
|
||||
"lastError" = ${STALE_CLAIM_ERROR}
|
||||
WHERE "status" = 'PROCESSING'
|
||||
AND "claimedAt" < now() - (${STALE_CLAIM_MS} * interval '1 millisecond')
|
||||
RETURNING "id", "status"`);
|
||||
}
|
||||
|
||||
/** releaseStaleClaimRows for callers that only need the count. */
|
||||
export async function releaseStaleClaims(
|
||||
db: PrismaService,
|
||||
table: OutboxTable,
|
||||
): Promise<number> {
|
||||
return (await releaseStaleClaimRows(db, table)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a claimed entry as done. Returns false if the claim was lost
|
||||
* (released as stale, maybe claimed again) - the current owner settles it.
|
||||
* lastError stays as history of earlier failures.
|
||||
*/
|
||||
export async function settleSuccess(
|
||||
db: DbClient,
|
||||
table: OutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
status: 'DONE' | 'DELIVERED',
|
||||
): Promise<boolean> {
|
||||
const sql = outboxTable(table);
|
||||
if (status !== sql.successStatus) {
|
||||
throw new Error(`${table} settles as ${sql.successStatus}, not ${status}`);
|
||||
}
|
||||
const count = await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${sql.table}
|
||||
SET "status" = CAST(${status} AS ${sql.statusType}),
|
||||
"claimedAt" = NULL,
|
||||
${sql.settledAtColumn} = now()
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return count === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a claimed entry after a failed attempt: back to PENDING after
|
||||
* retryDelayMs(attempts) by the database clock, or FAILED once attempts
|
||||
* reaches MAX_ATTEMPTS. `lastError` must come from loggableError().
|
||||
*
|
||||
* The outcome also holds if the claim was lost: only a stale release
|
||||
* changes a claimed row, and it applies the same attempts + 1 >=
|
||||
* MAX_ATTEMPTS rule to the same attempt count.
|
||||
*/
|
||||
export async function settleFailure(
|
||||
db: DbClient,
|
||||
table: OutboxTable,
|
||||
id: string,
|
||||
claimedAt: Date,
|
||||
attemptsBefore: number,
|
||||
lastError: string,
|
||||
): Promise<'retry' | 'failed'> {
|
||||
const { table: name, statusType } = outboxTable(table);
|
||||
const attempts = attemptsBefore + 1;
|
||||
const outcome = attempts >= MAX_ATTEMPTS ? 'failed' : 'retry';
|
||||
const nextAttempt =
|
||||
outcome === 'retry'
|
||||
? Prisma.sql`, "availableAt" = now() + (${retryDelayMs(attempts)} * interval '1 millisecond')`
|
||||
: Prisma.empty;
|
||||
await db.$executeRaw(Prisma.sql`
|
||||
UPDATE ${name}
|
||||
SET "status" = CAST(${outcome === 'retry' ? 'PENDING' : 'FAILED'} AS ${statusType}),
|
||||
"attempts" = "attempts" + 1,
|
||||
"claimedAt" = NULL,
|
||||
"lastError" = ${lastError}
|
||||
${nextAttempt}
|
||||
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
|
||||
return outcome;
|
||||
}
|
||||
Reference in new issue
Block a user