@fullstackhouse/open-mercato-durable-work 0.1.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/README.md +136 -0
- package/dist/core/errors.js +81 -0
- package/dist/core/errors.js.map +7 -0
- package/dist/core/ids.js +30 -0
- package/dist/core/ids.js.map +7 -0
- package/dist/core/reconciler.js +149 -0
- package/dist/core/reconciler.js.map +7 -0
- package/dist/core/registry.js +72 -0
- package/dist/core/registry.js.map +7 -0
- package/dist/core/run-slice.js +210 -0
- package/dist/core/run-slice.js.map +7 -0
- package/dist/core/schema.js +100 -0
- package/dist/core/schema.js.map +7 -0
- package/dist/core/service.js +161 -0
- package/dist/core/service.js.map +7 -0
- package/dist/core/store.js +516 -0
- package/dist/core/store.js.map +7 -0
- package/dist/core/terminal.js +53 -0
- package/dist/core/terminal.js.map +7 -0
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +7 -0
- package/dist/core/worker.js +111 -0
- package/dist/core/worker.js.map +7 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/durable_work/acl.js +10 -0
- package/dist/modules/durable_work/acl.js.map +7 -0
- package/dist/modules/durable_work/api/jobs/[id]/redrive.js +20 -0
- package/dist/modules/durable_work/api/jobs/[id]/redrive.js.map +7 -0
- package/dist/modules/durable_work/api/jobs/[id]/route.js +26 -0
- package/dist/modules/durable_work/api/jobs/[id]/route.js.map +7 -0
- package/dist/modules/durable_work/api/jobs/route.js +24 -0
- package/dist/modules/durable_work/api/jobs/route.js.map +7 -0
- package/dist/modules/durable_work/cli.js +72 -0
- package/dist/modules/durable_work/cli.js.map +7 -0
- package/dist/modules/durable_work/data/entities.js +163 -0
- package/dist/modules/durable_work/data/entities.js.map +7 -0
- package/dist/modules/durable_work/di.js +34 -0
- package/dist/modules/durable_work/di.js.map +7 -0
- package/dist/modules/durable_work/events.js +26 -0
- package/dist/modules/durable_work/events.js.map +7 -0
- package/dist/modules/durable_work/index.js +17 -0
- package/dist/modules/durable_work/index.js.map +7 -0
- package/dist/modules/durable_work/lib/route-helpers.js +66 -0
- package/dist/modules/durable_work/lib/route-helpers.js.map +7 -0
- package/dist/modules/durable_work/migrations/Migration20260908120000.js +17 -0
- package/dist/modules/durable_work/migrations/Migration20260908120000.js.map +7 -0
- package/dist/modules/durable_work/setup.js +12 -0
- package/dist/modules/durable_work/setup.js.map +7 -0
- package/dist/om/config.js +49 -0
- package/dist/om/config.js.map +7 -0
- package/dist/om/progress-mirror.js +49 -0
- package/dist/om/progress-mirror.js.map +7 -0
- package/dist/om/sql-executor-mikro.js +48 -0
- package/dist/om/sql-executor-mikro.js.map +7 -0
- package/dist/transport/bullmq.js +144 -0
- package/dist/transport/bullmq.js.map +7 -0
- package/dist/transport/conformance.js +177 -0
- package/dist/transport/conformance.js.map +7 -0
- package/dist/transport/memory.js +139 -0
- package/dist/transport/memory.js.map +7 -0
- package/dist/transport/pgboss.js +176 -0
- package/dist/transport/pgboss.js.map +7 -0
- package/dist/transport/types.js +1 -0
- package/dist/transport/types.js.map +7 -0
- package/generated/entities/durable_work_job/index.ts +42 -0
- package/generated/entities.ids.generated.ts +9 -0
- package/package.json +145 -0
- package/src/core/__tests__/registry.test.ts +43 -0
- package/src/core/errors.ts +104 -0
- package/src/core/ids.ts +58 -0
- package/src/core/reconciler.ts +242 -0
- package/src/core/registry.ts +199 -0
- package/src/core/run-slice.ts +343 -0
- package/src/core/schema.ts +114 -0
- package/src/core/service.ts +222 -0
- package/src/core/store.ts +786 -0
- package/src/core/terminal.ts +107 -0
- package/src/core/types.ts +120 -0
- package/src/core/worker.ts +169 -0
- package/src/index.ts +100 -0
- package/src/modules/durable_work/__integration__/TC-DW-001.spec.ts +51 -0
- package/src/modules/durable_work/__tests__/metadata.test.ts +13 -0
- package/src/modules/durable_work/__tests__/schema-agreement.test.ts +52 -0
- package/src/modules/durable_work/acl.ts +6 -0
- package/src/modules/durable_work/api/jobs/[id]/redrive.ts +27 -0
- package/src/modules/durable_work/api/jobs/[id]/route.ts +27 -0
- package/src/modules/durable_work/api/jobs/route.ts +26 -0
- package/src/modules/durable_work/cli.ts +91 -0
- package/src/modules/durable_work/data/entities.ts +158 -0
- package/src/modules/durable_work/di.ts +41 -0
- package/src/modules/durable_work/events.ts +30 -0
- package/src/modules/durable_work/index.ts +16 -0
- package/src/modules/durable_work/lib/route-helpers.ts +83 -0
- package/src/modules/durable_work/migrations/Migration20260908120000.ts +24 -0
- package/src/modules/durable_work/setup.ts +10 -0
- package/src/om/__tests__/sql-executor-mikro.test.ts +83 -0
- package/src/om/config.ts +65 -0
- package/src/om/progress-mirror.ts +80 -0
- package/src/om/sql-executor-mikro.ts +104 -0
- package/src/transport/bullmq.ts +213 -0
- package/src/transport/conformance.ts +218 -0
- package/src/transport/memory.ts +191 -0
- package/src/transport/pgboss.ts +250 -0
- package/src/transport/types.ts +81 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The one code path by which a job reaches `completed`, `failed` or `cancelled`.
|
|
2
|
+
//
|
|
3
|
+
// Everything terminal goes through here so there is a single place where "the job row and the
|
|
4
|
+
// domain row move together, or neither moves" is true. The job row is what every surface reads
|
|
5
|
+
// — the UI, the cancel route, the reconciler — so a domain row that went terminal while the
|
|
6
|
+
// job row still said `running` would be repaired by the reconciler as an orphan and re-driven,
|
|
7
|
+
// which is worse than the inconsistency it came from.
|
|
8
|
+
|
|
9
|
+
import { bumpMirrorAttempts, cancelCas, completeCas, failTerminalCas, markMirrored } from './store'
|
|
10
|
+
import type { ResolvedKind } from './registry'
|
|
11
|
+
import type { DurableJob, ErrorClass, Lease, Scope, SqlExecutor, SqlTransactor } from './types'
|
|
12
|
+
|
|
13
|
+
/** The domain mirror ran but matched no row: the domain record is gone, or another path
|
|
14
|
+
* already moved it. Treated exactly like a throw — "mirrored" means the domain row agrees,
|
|
15
|
+
* not that the callback was called. */
|
|
16
|
+
export class DomainMirrorMismatchError extends Error {
|
|
17
|
+
constructor(readonly jobId: string) {
|
|
18
|
+
super(`Domain mirror matched no rows for job ${jobId}`)
|
|
19
|
+
this.name = 'DomainMirrorMismatchError'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Transition =
|
|
24
|
+
| { type: 'complete'; patch?: { processedCount?: number; totalCount?: number | null } }
|
|
25
|
+
| { type: 'fail'; code: string; class: ErrorClass; message: string | null }
|
|
26
|
+
| { type: 'cancel' }
|
|
27
|
+
|
|
28
|
+
export type TerminalResult = { job: DurableJob; mirrored: boolean }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs the terminal CAS and the kind's domain mirror in one transaction.
|
|
32
|
+
*
|
|
33
|
+
* Three distinguishable outcomes, and keeping them distinguishable is the point:
|
|
34
|
+
* - `null` the CAS matched no rows. The lease was lost or taken; this delivery has no
|
|
35
|
+
* say any more and should end quietly.
|
|
36
|
+
* - a result committed.
|
|
37
|
+
* - a throw the CAS matched but the mirror failed, so everything rolled back. The caller
|
|
38
|
+
* decides whether that costs a retry (it does on the ordinary completion path,
|
|
39
|
+
* and deliberately does not on the cancellation path).
|
|
40
|
+
*
|
|
41
|
+
* An earlier draft returned `null` for both the refused fence and the rolled-back mirror. The
|
|
42
|
+
* caller then could not tell "someone else owns this" from "try again", which are opposite
|
|
43
|
+
* instructions.
|
|
44
|
+
*/
|
|
45
|
+
export async function runTerminalTransition(
|
|
46
|
+
sql: SqlTransactor,
|
|
47
|
+
kind: ResolvedKind,
|
|
48
|
+
lease: Lease,
|
|
49
|
+
scope: Scope,
|
|
50
|
+
transition: Transition,
|
|
51
|
+
): Promise<TerminalResult | null> {
|
|
52
|
+
let casMatched = false
|
|
53
|
+
try {
|
|
54
|
+
const result = await sql.transaction(async (tx) => {
|
|
55
|
+
const job = await applyCas(tx, lease, transition)
|
|
56
|
+
if (!job) return null
|
|
57
|
+
casMatched = true
|
|
58
|
+
|
|
59
|
+
if (transition.type === 'cancel' && kind.onCancel) await kind.onCancel(job, scope, tx)
|
|
60
|
+
|
|
61
|
+
if (kind.onTransition) {
|
|
62
|
+
const { matched } = await kind.onTransition(job, scope, tx)
|
|
63
|
+
if (matched < 1) throw new DomainMirrorMismatchError(job.id)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Recorded inside the same transaction as the mirror it describes: a job that says its
|
|
67
|
+
// domain row agrees, when the write that made it agree rolled back, is the exact lie
|
|
68
|
+
// this protocol exists to prevent.
|
|
69
|
+
await markMirrored(tx, job.id)
|
|
70
|
+
return { job, mirrored: true }
|
|
71
|
+
})
|
|
72
|
+
return result
|
|
73
|
+
} catch (error) {
|
|
74
|
+
// Outside the rolled-back transaction on purpose — a counter written inside it would roll
|
|
75
|
+
// back with it, and the job would retry its mirror forever with nothing to show for it.
|
|
76
|
+
if (casMatched) await bumpMirrorAttempts(sql, lease.jobId).catch(() => undefined)
|
|
77
|
+
throw error
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function applyCas(tx: SqlExecutor, lease: Lease, transition: Transition): Promise<DurableJob | null> {
|
|
82
|
+
switch (transition.type) {
|
|
83
|
+
case 'complete':
|
|
84
|
+
return completeCas(tx, lease, transition.patch)
|
|
85
|
+
case 'fail':
|
|
86
|
+
return failTerminalCas(tx, lease, { code: transition.code, class: transition.class, message: transition.message })
|
|
87
|
+
case 'cancel':
|
|
88
|
+
return cancelCas(tx, lease)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Runs the kind's after-commit hook. Best-effort and at-most-once by design: it has already
|
|
93
|
+
* been decided that the job is terminal, and a hook that throws must not undo that or be
|
|
94
|
+
* retried into a duplicate side effect. */
|
|
95
|
+
export async function runAfterTransition(
|
|
96
|
+
kind: ResolvedKind,
|
|
97
|
+
job: DurableJob,
|
|
98
|
+
scope: Scope,
|
|
99
|
+
onError?: (error: unknown) => void,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
if (!kind.onAfterTransition) return
|
|
102
|
+
try {
|
|
103
|
+
await kind.onAfterTransition(job, scope)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
onError?.(error)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The public vocabulary of the mechanism. Kept free of Open Mercato and of any transport:
|
|
2
|
+
// `core/` talks to Postgres through `SqlExecutor` and to a broker through `TransportAdapter`,
|
|
3
|
+
// so the same code runs in the OM module, in the CLI worker and in the failure harness.
|
|
4
|
+
|
|
5
|
+
export type DurableJobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
6
|
+
|
|
7
|
+
/** Why a slice returned. `budget` hands the rest of the work back without spending a retry. */
|
|
8
|
+
export type SliceOutcome = 'drained' | 'budget' | 'cancelled'
|
|
9
|
+
|
|
10
|
+
/** What the reconciler does with a job whose driver died. Default `park`: a job that nobody
|
|
11
|
+
* declared idempotent is not re-run automatically. */
|
|
12
|
+
export type OrphanPolicy = 'redrive' | 'park'
|
|
13
|
+
|
|
14
|
+
/** How an error was classified. `transient` is the default for anything unrecognised — the
|
|
15
|
+
* transport retries it. `terminal` fails the job now but leaves it re-drivable. `unrecoverable`
|
|
16
|
+
* fails it now and requires `{ force: true }` to re-drive. */
|
|
17
|
+
export type ErrorClass = 'transient' | 'terminal' | 'unrecoverable'
|
|
18
|
+
|
|
19
|
+
/** Written by the mechanism into `error_code`. Park reasons come from the reconciler, verdicts
|
|
20
|
+
* from a slice; an operator re-drive clears either. */
|
|
21
|
+
export type ParkReason = 'orphaned' | 'poison' | 'never_started' | 'no_handler'
|
|
22
|
+
export type SliceVerdict = 'unrecoverable' | 'retry_exhausted'
|
|
23
|
+
|
|
24
|
+
/** Every statement is tenant- and organization-scoped. A null organization is a real value
|
|
25
|
+
* (it means tenant-wide), which is why the lock-key index coalesces it to the zero uuid
|
|
26
|
+
* rather than relying on NULL-distinctness. */
|
|
27
|
+
export type Scope = { tenantId: string; organizationId: string | null }
|
|
28
|
+
|
|
29
|
+
/** What a worker holds while it runs a slice. There is deliberately no worker-clock expiry
|
|
30
|
+
* here: expiry is a fact about the database's clock, and only the database may judge it. */
|
|
31
|
+
export type Lease = { jobId: string; owner: string; epoch: number; ttlMs: number }
|
|
32
|
+
|
|
33
|
+
/** The identity of one delivery. A job may be delivered many times; only the delivery whose
|
|
34
|
+
* (seq, redrives) still matches the row may claim it. */
|
|
35
|
+
export type Delivery = { jobId: string; seq: number; redrives: number }
|
|
36
|
+
|
|
37
|
+
/** One row of `durable_work_jobs`, in camelCase. The row IS the authority for liveness:
|
|
38
|
+
* status, lease, budgets and cancellation intent are read from nowhere else. */
|
|
39
|
+
export type DurableJob = {
|
|
40
|
+
id: string
|
|
41
|
+
tenantId: string
|
|
42
|
+
organizationId: string | null
|
|
43
|
+
kind: string
|
|
44
|
+
status: DurableJobStatus
|
|
45
|
+
createdBy: string | null
|
|
46
|
+
createdAt: Date
|
|
47
|
+
updatedAt: Date
|
|
48
|
+
|
|
49
|
+
input: unknown
|
|
50
|
+
checkpoint: unknown
|
|
51
|
+
meta: Record<string, unknown> | null
|
|
52
|
+
|
|
53
|
+
idempotencyKey: string | null
|
|
54
|
+
lockKey: string | null
|
|
55
|
+
subjectType: string | null
|
|
56
|
+
subjectId: string | null
|
|
57
|
+
progressJobId: string | null
|
|
58
|
+
|
|
59
|
+
leaseOwner: string | null
|
|
60
|
+
leaseEpoch: number
|
|
61
|
+
leaseExpiresAt: Date | null
|
|
62
|
+
heartbeatAt: Date | null
|
|
63
|
+
|
|
64
|
+
queueName: string | null
|
|
65
|
+
queueJobId: string | null
|
|
66
|
+
continuationSeq: number
|
|
67
|
+
redrives: number
|
|
68
|
+
nextRunAt: Date | null
|
|
69
|
+
pendingSince: Date | null
|
|
70
|
+
|
|
71
|
+
redrivesSinceCommit: number
|
|
72
|
+
consecutiveFailures: number
|
|
73
|
+
interruptions: number
|
|
74
|
+
mirrorAttempts: number
|
|
75
|
+
lastCommittedAt: Date | null
|
|
76
|
+
|
|
77
|
+
startedAt: Date | null
|
|
78
|
+
finishedAt: Date | null
|
|
79
|
+
parkedAt: Date | null
|
|
80
|
+
cancelRequestedAt: Date | null
|
|
81
|
+
cancelledBy: string | null
|
|
82
|
+
errorClass: ErrorClass | null
|
|
83
|
+
errorCode: string | null
|
|
84
|
+
errorMessage: string | null
|
|
85
|
+
domainMirroredAt: Date | null
|
|
86
|
+
|
|
87
|
+
processedCount: number
|
|
88
|
+
totalCount: number | null
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The minimum a caller must supply to create a job. `kind` selects the handler; `lockKey`
|
|
92
|
+
* is the single-runner key; `idempotencyKey` makes `start` safe to call twice. */
|
|
93
|
+
export type StartJobInput = {
|
|
94
|
+
kind: string
|
|
95
|
+
input?: unknown
|
|
96
|
+
idempotencyKey?: string | null
|
|
97
|
+
lockKey?: string | null
|
|
98
|
+
subject?: { type: string; id: string } | null
|
|
99
|
+
progressJobId?: string | null
|
|
100
|
+
createdBy?: string | null
|
|
101
|
+
meta?: Record<string, unknown> | null
|
|
102
|
+
totalCount?: number | null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A minimal SQL surface. Deliberately not MikroORM: the statements are hand-written because
|
|
106
|
+
* every one of them is a compare-and-set whose predicate is the actual specification, and an
|
|
107
|
+
* ORM would put a layer between that predicate and review. */
|
|
108
|
+
export interface SqlExecutor {
|
|
109
|
+
query<R = Record<string, unknown>>(text: string, params?: readonly unknown[]): Promise<{ rows: R[]; rowCount: number }>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface SqlTransactor extends SqlExecutor {
|
|
113
|
+
/** Runs `fn` inside one transaction. A throw rolls back and the throw propagates. */
|
|
114
|
+
transaction<T>(fn: (tx: SqlExecutor) => Promise<T>): Promise<T>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Effective per-kind settings after defaults are applied. */
|
|
118
|
+
export type LeaseSettings = { ttlMs: number; sliceBudgetMs: number; pendingTtlMs: number }
|
|
119
|
+
export type BudgetSettings = { maxRedrives: number; maxConsecutiveFailures: number; poisonRedrivesWithoutCommit: number }
|
|
120
|
+
export type RetrySettings = { attempts: number; backoff: { type: 'exponential' | 'fixed'; delayMs: number; maxDelayMs: number } }
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The worker process: binds each kind's queue, runs the reconciler tick, and drains on
|
|
2
|
+
// shutdown instead of being killed mid-batch.
|
|
3
|
+
|
|
4
|
+
import { deliveryId, makeOwnerId, parseDeliveryId, queueNameFor } from './ids'
|
|
5
|
+
import { errorMessageOf, NoFurtherAttempts } from './errors'
|
|
6
|
+
import { reconcileOnce, type ReconcileReport } from './reconciler'
|
|
7
|
+
import { registry as globalRegistry, type KindRegistry, type ResolvedKind } from './registry'
|
|
8
|
+
import { runSlice } from './run-slice'
|
|
9
|
+
import { getJob, recordEnqueue } from './store'
|
|
10
|
+
import type { DurableJob, Delivery, Scope, SqlTransactor } from './types'
|
|
11
|
+
import type { BoundWorker, TransportAdapter } from '../transport/types'
|
|
12
|
+
|
|
13
|
+
export const RECONCILE_TICK_ID = 'durable-work-reconcile'
|
|
14
|
+
export const RECONCILE_QUEUE = queueNameFor('reconcile')
|
|
15
|
+
|
|
16
|
+
export type WorkerOptions = {
|
|
17
|
+
sql: SqlTransactor
|
|
18
|
+
transport: TransportAdapter
|
|
19
|
+
registry?: KindRegistry
|
|
20
|
+
/** Restrict this process to a subset of kinds. Everything registered runs by default. */
|
|
21
|
+
kinds?: string[]
|
|
22
|
+
concurrency?: number
|
|
23
|
+
/** How often the reconciler runs. */
|
|
24
|
+
tickMs?: number
|
|
25
|
+
reconcilerGraceMs?: number
|
|
26
|
+
drainTimeoutMs?: number
|
|
27
|
+
owner?: string
|
|
28
|
+
log?: (event: string, fields: Record<string, unknown>) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type DurableWorker = {
|
|
32
|
+
owner: string
|
|
33
|
+
/** Runs one reconciler pass immediately. Exposed for the CLI and for tests that would
|
|
34
|
+
* otherwise have to wait out a tick. */
|
|
35
|
+
reconcile(): Promise<ReconcileReport>
|
|
36
|
+
stop(): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Enqueues a delivery for a job, and records the id so a cancellation can remove it. */
|
|
40
|
+
export async function enqueueJob(
|
|
41
|
+
sql: SqlTransactor,
|
|
42
|
+
transport: TransportAdapter,
|
|
43
|
+
kind: ResolvedKind,
|
|
44
|
+
job: DurableJob,
|
|
45
|
+
opts: { tx?: Parameters<typeof recordEnqueue>[0] } = {},
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const delivery: Delivery = { jobId: job.id, seq: job.continuationSeq, redrives: job.redrives }
|
|
48
|
+
// The delay comes from the row's own `next_run_at`, computed on the database clock. Reading
|
|
49
|
+
// it back as a duration here — rather than passing a timestamp to the broker — keeps the
|
|
50
|
+
// two clocks from having to agree.
|
|
51
|
+
const delayMs = job.nextRunAt ? Math.max(0, job.nextRunAt.getTime() - Date.now()) : 0
|
|
52
|
+
const { transportJobId } = await transport.enqueue(job.queueName ?? kind.queue, delivery, {
|
|
53
|
+
delayMs,
|
|
54
|
+
retry: kind.retry,
|
|
55
|
+
tx: opts.tx,
|
|
56
|
+
})
|
|
57
|
+
await recordEnqueue(opts.tx ?? sql, job.id, transportJobId, job.queueName ?? kind.queue)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function startWorker(options: WorkerOptions): Promise<DurableWorker> {
|
|
61
|
+
const registry = options.registry ?? globalRegistry
|
|
62
|
+
const owner = options.owner ?? makeOwnerId()
|
|
63
|
+
const log = options.log ?? (() => undefined)
|
|
64
|
+
const { sql, transport } = options
|
|
65
|
+
|
|
66
|
+
const kinds = registry.list().filter((k) => !options.kinds || options.kinds.includes(k.kind))
|
|
67
|
+
const byQueue = new Map<string, ResolvedKind[]>()
|
|
68
|
+
for (const kind of kinds) byQueue.set(kind.queue, [...(byQueue.get(kind.queue) ?? []), kind])
|
|
69
|
+
|
|
70
|
+
const bound: BoundWorker[] = []
|
|
71
|
+
|
|
72
|
+
for (const [queue, queueKinds] of byQueue) {
|
|
73
|
+
const concurrency = options.concurrency ?? Math.max(...queueKinds.map((k) => k.concurrency))
|
|
74
|
+
// The broker must tolerate a delivery being in flight for longer than a whole slice, or it
|
|
75
|
+
// redelivers work that is still running — which the lease then refuses, wasting the slice.
|
|
76
|
+
const activeTimeoutMs = Math.max(...queueKinds.map((k) => k.lease.sliceBudgetMs)) * 2
|
|
77
|
+
|
|
78
|
+
bound.push(
|
|
79
|
+
await transport.bind(
|
|
80
|
+
queue,
|
|
81
|
+
async (delivery, ctx) => {
|
|
82
|
+
const job = await loadJob(sql, delivery)
|
|
83
|
+
if (!job) {
|
|
84
|
+
log('durable_work.delivery_orphaned', { jobId: delivery.jobId, queue })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
const kind = registry.get(job.kind)
|
|
88
|
+
if (!kind) {
|
|
89
|
+
// Nothing in this process can run it. Leave it alone rather than failing it: a
|
|
90
|
+
// rolling deploy legitimately has processes that do not yet know a new kind, and
|
|
91
|
+
// the reconciler parks it if nobody ever claims it.
|
|
92
|
+
log('durable_work.no_handler', { jobId: job.id, kind: job.kind })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const scope: Scope = { tenantId: job.tenantId, organizationId: job.organizationId }
|
|
96
|
+
const result = await runSlice({ sql, kind, owner, log }, delivery, scope, ctx)
|
|
97
|
+
if (result.outcome === 'yielded' && !ctx.signal.aborted) {
|
|
98
|
+
// The transport's hand-back may have been refused (a lock lost at exactly the
|
|
99
|
+
// wrong moment). The row is already `pending` at the next seq, so re-enqueuing
|
|
100
|
+
// under the new identity cannot collide with anything the broker still holds.
|
|
101
|
+
const current = await getJob(sql, job.id, scope)
|
|
102
|
+
if (current && current.status === 'pending') await enqueueJob(sql, transport, kind, current).catch((error) => {
|
|
103
|
+
log('durable_work.reenqueue_failed', { jobId: job.id, error: errorMessageOf(error) })
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{ concurrency, activeTimeoutMs },
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const reconcile = () =>
|
|
113
|
+
reconcileOnce({
|
|
114
|
+
sql,
|
|
115
|
+
registry,
|
|
116
|
+
graceMs: options.reconcilerGraceMs,
|
|
117
|
+
log,
|
|
118
|
+
enqueue: async (job) => {
|
|
119
|
+
const kind = registry.get(job.kind)
|
|
120
|
+
if (kind) await enqueueJob(sql, transport, kind, job)
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// The tick is a repeating delivery owned by the broker rather than a job that re-enqueues
|
|
125
|
+
// itself: a self-re-enqueue is lost the moment one tick fails, and nothing would notice.
|
|
126
|
+
let tickWorker: BoundWorker | null = null
|
|
127
|
+
if (options.tickMs !== 0) {
|
|
128
|
+
tickWorker = await transport.bind(
|
|
129
|
+
RECONCILE_QUEUE,
|
|
130
|
+
async () => {
|
|
131
|
+
const report = await reconcile()
|
|
132
|
+
if (report.scanned) log('durable_work.reconciled', report as unknown as Record<string, unknown>)
|
|
133
|
+
},
|
|
134
|
+
{ concurrency: 1, activeTimeoutMs: 120_000 },
|
|
135
|
+
)
|
|
136
|
+
await transport.upsertTick({ id: RECONCILE_TICK_ID, queue: RECONCILE_QUEUE, everyMs: options.tickMs ?? 15_000 })
|
|
137
|
+
bound.push(tickWorker)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
owner,
|
|
142
|
+
reconcile,
|
|
143
|
+
async stop() {
|
|
144
|
+
// Close the transport first: it stops accepting new deliveries and aborts the signal
|
|
145
|
+
// every in-flight slice is watching, so they hand back at their next boundary rather
|
|
146
|
+
// than being cut off between two writes.
|
|
147
|
+
await transport.close({ timeoutMs: options.drainTimeoutMs ?? 30_000 })
|
|
148
|
+
await Promise.allSettled(bound.map((worker) => worker.close({ timeoutMs: options.drainTimeoutMs ?? 30_000 })))
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function loadJob(sql: SqlTransactor, delivery: Delivery): Promise<DurableJob | null> {
|
|
154
|
+
// The delivery carries only ids, so the row is read unscoped here and every statement after
|
|
155
|
+
// it re-scopes from the row's own tenant. A delivery cannot name a scope it should not see:
|
|
156
|
+
// it can only name a job id that already exists.
|
|
157
|
+
const result = await sql.query<Record<string, unknown>>(
|
|
158
|
+
`select tenant_id, organization_id from durable_work_jobs where id = $1`,
|
|
159
|
+
[delivery.jobId],
|
|
160
|
+
)
|
|
161
|
+
if (!result.rows.length) return null
|
|
162
|
+
const row = result.rows[0]!
|
|
163
|
+
return getJob(sql, delivery.jobId, {
|
|
164
|
+
tenantId: String(row.tenant_id),
|
|
165
|
+
organizationId: row.organization_id == null ? null : String(row.organization_id),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export { deliveryId, parseDeliveryId, NoFurtherAttempts }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Public API of @fullstackhouse/open-mercato-durable-work.
|
|
2
|
+
//
|
|
3
|
+
// The OM module itself lives at ./modules/durable_work and is loaded by the host through
|
|
4
|
+
// `{ id: 'durable_work', from: '@fullstackhouse/open-mercato-durable-work' }`. Everything
|
|
5
|
+
// exported here is usable without Open Mercato: `core/` speaks to Postgres through
|
|
6
|
+
// `SqlExecutor` and to a broker through `TransportAdapter`, which is what lets the failure
|
|
7
|
+
// harness run the real mechanism with no app around it.
|
|
8
|
+
|
|
9
|
+
export { metadata } from './modules/durable_work/index'
|
|
10
|
+
export { features } from './modules/durable_work/acl'
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
Delivery,
|
|
14
|
+
DurableJob,
|
|
15
|
+
DurableJobStatus,
|
|
16
|
+
ErrorClass,
|
|
17
|
+
Lease,
|
|
18
|
+
LeaseSettings,
|
|
19
|
+
BudgetSettings,
|
|
20
|
+
RetrySettings,
|
|
21
|
+
OrphanPolicy,
|
|
22
|
+
ParkReason,
|
|
23
|
+
Scope,
|
|
24
|
+
SliceOutcome,
|
|
25
|
+
SliceVerdict,
|
|
26
|
+
SqlExecutor,
|
|
27
|
+
SqlTransactor,
|
|
28
|
+
StartJobInput,
|
|
29
|
+
} from './core/types'
|
|
30
|
+
|
|
31
|
+
export {
|
|
32
|
+
TransientError,
|
|
33
|
+
TerminalError,
|
|
34
|
+
UnrecoverableError,
|
|
35
|
+
LeaseLostError,
|
|
36
|
+
LockKeyHeldError,
|
|
37
|
+
NoFurtherAttempts,
|
|
38
|
+
UnknownKindError,
|
|
39
|
+
classifyError,
|
|
40
|
+
} from './core/errors'
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
DEFAULT_BUDGET,
|
|
44
|
+
DEFAULT_LEASE,
|
|
45
|
+
DEFAULT_RETRY,
|
|
46
|
+
KindRegistry,
|
|
47
|
+
nextAttemptDelayMs,
|
|
48
|
+
registry,
|
|
49
|
+
resolveKind,
|
|
50
|
+
} from './core/registry'
|
|
51
|
+
export type { KindDefinition, ResolvedKind, SliceContext } from './core/registry'
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
CREATE_INDEXES,
|
|
55
|
+
CREATE_TABLE,
|
|
56
|
+
DROP_INDEXES,
|
|
57
|
+
DROP_TABLE,
|
|
58
|
+
NO_ORG,
|
|
59
|
+
SCHEMA_STATEMENTS,
|
|
60
|
+
TABLE,
|
|
61
|
+
} from './core/schema'
|
|
62
|
+
|
|
63
|
+
export * as store from './core/store'
|
|
64
|
+
export { runSlice } from './core/run-slice'
|
|
65
|
+
export type { RunSliceDeps, RunSliceResult } from './core/run-slice'
|
|
66
|
+
export { DomainMirrorMismatchError, runAfterTransition, runTerminalTransition } from './core/terminal'
|
|
67
|
+
export type { TerminalResult, Transition } from './core/terminal'
|
|
68
|
+
export { DurableWorkService } from './core/service'
|
|
69
|
+
export type { DurableWorkServiceDeps, RedriveRefusal, StartResult } from './core/service'
|
|
70
|
+
export { reconcileOnce } from './core/reconciler'
|
|
71
|
+
export type { ReconcileReport, ReconcilerDeps } from './core/reconciler'
|
|
72
|
+
export { RECONCILE_QUEUE, RECONCILE_TICK_ID, enqueueJob, startWorker } from './core/worker'
|
|
73
|
+
export type { DurableWorker, WorkerOptions } from './core/worker'
|
|
74
|
+
export { PORTABLE_QUEUE_NAME, deliveryId, makeOwnerId, parseDeliveryId, queueNameFor, sliceIdempotencyKey } from './core/ids'
|
|
75
|
+
|
|
76
|
+
export type {
|
|
77
|
+
BindOptions,
|
|
78
|
+
BoundWorker,
|
|
79
|
+
DeliveryHandler,
|
|
80
|
+
DeliveryState,
|
|
81
|
+
EnqueueOptions,
|
|
82
|
+
HandlerContext,
|
|
83
|
+
TransportAdapter,
|
|
84
|
+
TransportName,
|
|
85
|
+
} from './transport/types'
|
|
86
|
+
export { createTransport, readConfig } from './om/config'
|
|
87
|
+
export type { DurableWorkConfig } from './om/config'
|
|
88
|
+
export { mikroExecutor, mikroTx } from './om/sql-executor-mikro'
|
|
89
|
+
export { createProgressMirror } from './om/progress-mirror'
|
|
90
|
+
export type { ProgressMirror, ProgressServiceLike } from './om/progress-mirror'
|
|
91
|
+
export { DurableWorkJob } from './modules/durable_work/data/entities'
|
|
92
|
+
|
|
93
|
+
export { MemoryTransport } from './transport/memory'
|
|
94
|
+
export type { MemoryFaults } from './transport/memory'
|
|
95
|
+
export { BullMQTransport } from './transport/bullmq'
|
|
96
|
+
export type { BullMQTransportOptions } from './transport/bullmq'
|
|
97
|
+
export { PgBossTransport } from './transport/pgboss'
|
|
98
|
+
export type { PgBossTransportOptions } from './transport/pgboss'
|
|
99
|
+
export { transportConformance } from './transport/conformance'
|
|
100
|
+
export type { ConformanceHooks } from './transport/conformance'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The operator surface, against a running app.
|
|
2
|
+
//
|
|
3
|
+
// What these check is the contract an operator actually relies on: that the list is scoped and
|
|
4
|
+
// gated, that a job they cannot re-drive is not offered as re-drivable, and that a refusal says
|
|
5
|
+
// which refusal it is — because "wait for the other run" and "this is already done" call for
|
|
6
|
+
// opposite actions.
|
|
7
|
+
|
|
8
|
+
import { expect, test } from '@playwright/test'
|
|
9
|
+
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
10
|
+
|
|
11
|
+
test.describe('durable_work operator API', () => {
|
|
12
|
+
test('requires authentication', async ({ request }) => {
|
|
13
|
+
const response = await request.get('/api/durable_work/jobs')
|
|
14
|
+
expect(response.status()).toBe(401)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('lists jobs for an authorised operator', async ({ request }) => {
|
|
18
|
+
const token = await getAuthToken(request, 'admin')
|
|
19
|
+
const response = await apiRequest(request, 'GET', '/api/durable_work/jobs', { token })
|
|
20
|
+
|
|
21
|
+
expect(response.status()).toBe(200)
|
|
22
|
+
const body = (await response.json()) as { items: unknown[]; total: number }
|
|
23
|
+
expect(Array.isArray(body.items)).toBe(true)
|
|
24
|
+
expect(typeof body.total).toBe('number')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('answers 404 for a job that does not exist, rather than leaking that it might', async ({ request }) => {
|
|
28
|
+
const token = await getAuthToken(request, 'admin')
|
|
29
|
+
const response = await apiRequest(request, 'GET', '/api/durable_work/jobs/00000000-0000-0000-0000-000000000000', { token })
|
|
30
|
+
expect(response.status()).toBe(404)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('refuses to re-drive a job that does not exist', async ({ request }) => {
|
|
34
|
+
const token = await getAuthToken(request, 'admin')
|
|
35
|
+
const response = await apiRequest(request, 'POST', '/api/durable_work/jobs/00000000-0000-0000-0000-000000000000/redrive', {
|
|
36
|
+
token,
|
|
37
|
+
data: {},
|
|
38
|
+
})
|
|
39
|
+
// 409 with a code, not a bare failure: the caller needs to know *which* refusal it is.
|
|
40
|
+
expect(response.status()).toBe(409)
|
|
41
|
+
expect((await response.json()) as { error: string }).toMatchObject({ error: 'not_redrivable' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('rejects a page size a caller could use to pull the whole table', async ({ request }) => {
|
|
45
|
+
const token = await getAuthToken(request, 'admin')
|
|
46
|
+
const response = await apiRequest(request, 'GET', '/api/durable_work/jobs?pageSize=100000', { token })
|
|
47
|
+
expect(response.status()).toBe(200)
|
|
48
|
+
const body = (await response.json()) as { items: unknown[] }
|
|
49
|
+
expect(body.items.length).toBeLessThanOrEqual(200)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { metadata } from '../index'
|
|
2
|
+
import { features } from '../acl'
|
|
3
|
+
|
|
4
|
+
describe('durable_work module metadata', () => {
|
|
5
|
+
it('declares the module id the host registers', () => {
|
|
6
|
+
expect(metadata.name).toBe('durable_work')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('declares view and operate features scoped to the module', () => {
|
|
10
|
+
expect(features.map((f) => f.id)).toEqual(['durable_work.view', 'durable_work.operate'])
|
|
11
|
+
expect(features.every((f) => f.module === 'durable_work')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `core/schema.ts` is the definition of the table; the MikroORM entity is a second description
|
|
2
|
+
// of the same thing, for the host's benefit. Two descriptions drift, and the way this
|
|
3
|
+
// particular drift would surface is ugly: the host's migration creates one shape while the
|
|
4
|
+
// statements — every one of them a compare-and-set naming columns explicitly — expect another,
|
|
5
|
+
// so the failure lands at runtime on a predicate rather than at migration time on a schema.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
import { CREATE_TABLE } from '../../../core/schema'
|
|
12
|
+
|
|
13
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
|
|
15
|
+
/** Column names as they appear in the DDL. */
|
|
16
|
+
function ddlColumns(): string[] {
|
|
17
|
+
const body = CREATE_TABLE.slice(CREATE_TABLE.indexOf('(') + 1, CREATE_TABLE.lastIndexOf(')'))
|
|
18
|
+
return body
|
|
19
|
+
.split('\n')
|
|
20
|
+
.map((line) => line.trim())
|
|
21
|
+
.filter((line) => line.length > 0)
|
|
22
|
+
.map((line) => line.split(/\s+/)[0]!)
|
|
23
|
+
.filter((name) => /^[a-z_]+$/.test(name))
|
|
24
|
+
.sort()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Column names the entity declares, read from the source rather than from decorator metadata:
|
|
28
|
+
* loading metadata would need a configured ORM, and this only has to compare two lists. */
|
|
29
|
+
function entityColumns(): string[] {
|
|
30
|
+
const source = fs.readFileSync(path.join(HERE, '..', 'data', 'entities.ts'), 'utf8')
|
|
31
|
+
const names = [...source.matchAll(/@(?:Property|PrimaryKey)\(\{[^}]*name:\s*'([a-z_]+)'/g)].map((m) => m[1]!)
|
|
32
|
+
// The primary key declares no `name`, so it is added explicitly.
|
|
33
|
+
return [...new Set([...names, 'id'])].sort()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('durable_work_jobs schema', () => {
|
|
37
|
+
it('declares the same columns in the DDL and in the entity', () => {
|
|
38
|
+
const ddl = ddlColumns()
|
|
39
|
+
const entity = entityColumns()
|
|
40
|
+
expect(ddl.length).toBeGreaterThan(30)
|
|
41
|
+
expect(entity).toEqual(ddl)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps the columns the fence depends on', () => {
|
|
45
|
+
// Named individually because losing any one of them silently removes a guarantee rather
|
|
46
|
+
// than breaking a build: no epoch is no fence, no lock key is no single-runner, no
|
|
47
|
+
// idempotency key turns a retried start into a second job.
|
|
48
|
+
for (const column of ['lease_epoch', 'lease_owner', 'lease_expires_at', 'lock_key', 'idempotency_key', 'continuation_seq', 'redrives']) {
|
|
49
|
+
expect(ddlColumns()).toContain(column)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
|
|
3
|
+
import { routeContext, toDto } from '../../../lib/route-helpers'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
POST: { requireAuth: true, requireFeatures: ['durable_work.operate'] },
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Runs a stopped job again.
|
|
11
|
+
*
|
|
12
|
+
* Each refusal is a 409 with a code rather than a generic failure, because the three have
|
|
13
|
+
* different answers: wait for or cancel the job holding the lock key; nothing to re-drive;
|
|
14
|
+
* or say explicitly that re-running an unrecoverable failure is right.
|
|
15
|
+
*/
|
|
16
|
+
export async function POST(req: Request, { params }: { params: { id: string } }) {
|
|
17
|
+
const ctx = await routeContext(req)
|
|
18
|
+
if (ctx instanceof NextResponse) return ctx
|
|
19
|
+
|
|
20
|
+
const body = (await req.json().catch(() => ({}))) as { force?: boolean }
|
|
21
|
+
const result = await ctx.service.redrive(params.id, ctx.scope, { force: body.force === true })
|
|
22
|
+
|
|
23
|
+
if ('refused' in result) {
|
|
24
|
+
return NextResponse.json({ error: result.refused, heldBy: result.heldBy }, { status: 409 })
|
|
25
|
+
}
|
|
26
|
+
return NextResponse.json(toDto(result))
|
|
27
|
+
}
|