@fabrika/app 0.3.3 → 0.4.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 +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
// The job runner (`fabrika job run <name>`): one run of one `c.cron` job. A run takes the job's lease or does not
|
|
2
|
+
// start; each attempt holds one generation of it with a deadline the database enforces, so a write of an attempt
|
|
3
|
+
// that timed out, failed or lost the lease never commits (`@fabrika/db`'s fence). A form's `then` kicks a run in the
|
|
4
|
+
// background without waiting for any lock; a kick that finds the lease held leaves the shared pending flag, and the run
|
|
5
|
+
// holding it runs once more after it succeeded. Failures, crashed runs and a job that stopped succeeding are reported
|
|
6
|
+
// to Operations, each under a fixed failure code.
|
|
7
|
+
|
|
8
|
+
import type { IamMembership } from '@fabrika/auth'
|
|
9
|
+
import { type ContractArtifact, JOB_SITE_PREFIX, type JobArtifact, type PermissionEntry, permits } from '@fabrika/auth-core'
|
|
10
|
+
import type { CapabilityAuthority } from '@fabrika/db/capability'
|
|
11
|
+
import { type DataAccess, type FrameworkTables, type JobLeases, type LeaseFence, LeaseLostError } from '@fabrika/db/framework'
|
|
12
|
+
import { NotFoundError } from '../errors'
|
|
13
|
+
import type { FileCollections } from '../files/collections'
|
|
14
|
+
import { type CapabilityMint, CapabilityRefusedError, type Ref, RequestFrame, runInRequest, type Within } from '../implement/capability'
|
|
15
|
+
import { failureCode, type FailureReason, failureReason } from '../implement/failures'
|
|
16
|
+
import { ServerFault } from '../implement/faults'
|
|
17
|
+
import type { ContractDatabase } from '../implement/implement'
|
|
18
|
+
import {
|
|
19
|
+
approvedDeclaration,
|
|
20
|
+
approvedReach,
|
|
21
|
+
boundTo,
|
|
22
|
+
isSiteHandler,
|
|
23
|
+
jobHandles,
|
|
24
|
+
jobSql,
|
|
25
|
+
jobSqlOutsideTransaction,
|
|
26
|
+
noFileCollections,
|
|
27
|
+
siteData,
|
|
28
|
+
type SiteDeclaration,
|
|
29
|
+
undeclaredSql,
|
|
30
|
+
} from '../implement/invocation'
|
|
31
|
+
import { jobLinkIssuing, type JobLinkMount } from '../implement/membership'
|
|
32
|
+
import type { WorkloadCredential } from '../implement/workload'
|
|
33
|
+
import type { OperationsReporter } from './report'
|
|
34
|
+
import { type CronSchedule, parseCron, periodAt } from './schedule'
|
|
35
|
+
import type { JobContext, JobPrincipal } from './types'
|
|
36
|
+
|
|
37
|
+
/** An attempt of a job without a `timeout` is cancelled after this long. */
|
|
38
|
+
export const DEFAULT_JOB_TIMEOUT_MS = 15 * 60_000
|
|
39
|
+
/**
|
|
40
|
+
* The lease outlives an attempt's deadline by this much: the runner renews or finishes a timed-out attempt's lease,
|
|
41
|
+
* and no other run takes it over meanwhile. The attempt's writes stop at its deadline regardless.
|
|
42
|
+
*/
|
|
43
|
+
export const LEASE_GRACE_MS = 60_000
|
|
44
|
+
/** How often the serving process checks every job for a crashed run and for a schedule that stopped. */
|
|
45
|
+
export const JOB_CHECK_INTERVAL_MS = 60_000
|
|
46
|
+
const BACKOFF_BASE_MS = 1_000
|
|
47
|
+
const BACKOFF_MAX_MS = 5 * 60_000
|
|
48
|
+
const DURATION = /^([1-9][0-9]*)([smh])$/
|
|
49
|
+
const UNIT_MS: Readonly<Record<string, number>> = { s: 1_000, m: 60_000, h: 3_600_000 }
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Why a run failed. Fixed codes only: an application error's name and message may carry a secret, so neither is
|
|
53
|
+
* ever logged, returned or reported.
|
|
54
|
+
*/
|
|
55
|
+
export type JobFailureCode =
|
|
56
|
+
| 'timeout'
|
|
57
|
+
| 'lease_lost'
|
|
58
|
+
| 'handler_failed'
|
|
59
|
+
/** The data layer refused a statement or an operation tree (`QueryRefusedError`): its reason names why. */
|
|
60
|
+
| 'query_refused'
|
|
61
|
+
/** A capability or a handle was refused: forged, ended, of other work, or of another kind than the call takes. */
|
|
62
|
+
| 'capability_refused'
|
|
63
|
+
| 'driver_failed'
|
|
64
|
+
| 'crashed'
|
|
65
|
+
/** IAM refused the job's key: the attempt mints no capability. */
|
|
66
|
+
| 'unauthorized'
|
|
67
|
+
/** IAM could not be asked for the job's token. */
|
|
68
|
+
| 'iam_unavailable'
|
|
69
|
+
|
|
70
|
+
export type JobRunResult =
|
|
71
|
+
| { readonly status: 'succeeded'; readonly attempts: number }
|
|
72
|
+
/** `reason` is a framework refusal's catalog key (`query_refused`, `capability_refused`), present only for one. */
|
|
73
|
+
| { readonly status: 'failed'; readonly attempts: number; readonly code: JobFailureCode; readonly reason?: string }
|
|
74
|
+
/** Another run holds the lease: an overlapping run does not start. */
|
|
75
|
+
| { readonly status: 'skipped' }
|
|
76
|
+
|
|
77
|
+
/** The runner's waits; the lease's times are always the database's. */
|
|
78
|
+
export interface JobClock {
|
|
79
|
+
/** Resolves after `ms`, or as soon as `signal` aborts. */
|
|
80
|
+
wait(ms: number, signal: AbortSignal): Promise<void>
|
|
81
|
+
/** In [0, 1): the jitter of a retry's backoff. */
|
|
82
|
+
random(): number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface JobRunOptions {
|
|
86
|
+
readonly clock?: JobClock
|
|
87
|
+
/** Where failures and missed runs go; by default the one `boot` built from `FABRIKA_OPERATIONS_DSN`. */
|
|
88
|
+
readonly reporter?: OperationsReporter
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A job the framework runs itself under a reserved name (`FILE_SCAN_JOB`): no handler, no key, no capability, one
|
|
93
|
+
* attempt. It holds its lease like an application job, and every write of its run is fenced by it.
|
|
94
|
+
*/
|
|
95
|
+
export interface FrameworkJob {
|
|
96
|
+
readonly name: string
|
|
97
|
+
readonly schedule: string
|
|
98
|
+
readonly timeoutMs: number
|
|
99
|
+
/** One run; `signal` aborts when the attempt's deadline passes. A throw fails the run. */
|
|
100
|
+
run(reporter: OperationsReporter, signal: AbortSignal): Promise<void>
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The contract's jobs, bound to their handlers by `boot`, and the framework's own. */
|
|
104
|
+
export interface ContractJobs {
|
|
105
|
+
/** The contract's `c.cron` jobs; `run` also takes the name of a framework job the contract needs. */
|
|
106
|
+
readonly names: readonly string[]
|
|
107
|
+
run(name: string, options?: JobRunOptions): Promise<JobRunResult>
|
|
108
|
+
/**
|
|
109
|
+
* Registers every job (its first-seen time), reports a run that crashed and a job that has not succeeded within
|
|
110
|
+
* twice its period. The serving process calls it every `JOB_CHECK_INTERVAL_MS`; it never throws.
|
|
111
|
+
*/
|
|
112
|
+
check(options?: Pick<JobRunOptions, 'reporter'>): Promise<void>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A run that cannot start: an unknown job. Its message is framework-authored. */
|
|
116
|
+
export class JobError extends Error {
|
|
117
|
+
override readonly name = 'JobError'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const systemClock: JobClock = Object.freeze({
|
|
121
|
+
wait: (ms: number, signal: AbortSignal) =>
|
|
122
|
+
new Promise<void>((resolve) => {
|
|
123
|
+
if (signal.aborted) {
|
|
124
|
+
resolve()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const timer = setTimeout(resolve, ms)
|
|
128
|
+
signal.addEventListener('abort', () => {
|
|
129
|
+
clearTimeout(timer)
|
|
130
|
+
resolve()
|
|
131
|
+
}, { once: true })
|
|
132
|
+
}),
|
|
133
|
+
random: () => Math.random(),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
interface BoundJob {
|
|
137
|
+
readonly name: string
|
|
138
|
+
readonly artifact: JobArtifact
|
|
139
|
+
readonly schedule: CronSchedule
|
|
140
|
+
readonly timeoutMs: number
|
|
141
|
+
readonly handler: (args: { readonly ctx: object }) => unknown
|
|
142
|
+
readonly credential: WorkloadCredential
|
|
143
|
+
/** A staged-kit job's declaration: it reads through `ctx.db` and writes only in `ctx.transaction`. */
|
|
144
|
+
readonly site: SiteDeclaration | null
|
|
145
|
+
/** The link mount the approved artifact says the job `issues`: its `ctx.links`. */
|
|
146
|
+
readonly links: JobLinkMount | null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
interface RunnerContext {
|
|
150
|
+
readonly artifact: ContractArtifact
|
|
151
|
+
readonly database: ContractDatabase | null
|
|
152
|
+
readonly ownedDimensions: ReadonlySet<string>
|
|
153
|
+
readonly leases: JobLeases
|
|
154
|
+
readonly reporter: OperationsReporter
|
|
155
|
+
readonly mint: CapabilityMint
|
|
156
|
+
readonly data: DataAccess | null
|
|
157
|
+
readonly files: FileCollections | null
|
|
158
|
+
readonly membership: IamMembership | null
|
|
159
|
+
readonly capabilities: CapabilityAuthority
|
|
160
|
+
readonly containers: ReadonlyMap<string, readonly string[]>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface JobBindingInput {
|
|
164
|
+
readonly artifact: ContractArtifact
|
|
165
|
+
/** The handler tree `boot` binds; a job's handler is `handlers.jobs.<name>`. */
|
|
166
|
+
readonly handlers: unknown
|
|
167
|
+
readonly database: ContractDatabase | null
|
|
168
|
+
/** The framework side of `@fabrika/db`, bound by `boot`; `null` when the application has no such layer. */
|
|
169
|
+
readonly framework: FrameworkTables | null
|
|
170
|
+
readonly ownedDimensions: ReadonlySet<string>
|
|
171
|
+
/** Where failures and missed runs go: Operations, or the server log without a DSN. */
|
|
172
|
+
readonly reporter: OperationsReporter
|
|
173
|
+
/** `job:<name>` → the job's key; `boot` refuses a job without one. */
|
|
174
|
+
readonly credentials: ReadonlyMap<string, WorkloadCredential>
|
|
175
|
+
/** The runtime's capability mint, which `boot` alone holds. */
|
|
176
|
+
readonly mint: CapabilityMint
|
|
177
|
+
/** The mini-ORM of the bound `scoped(tables)` layer, behind a staged-kit job's `ctx.db`. */
|
|
178
|
+
readonly data: DataAccess | null
|
|
179
|
+
/** The contract's file collections, behind a staged-kit job's `ctx.files` and `tx.files`; `null` without any. */
|
|
180
|
+
readonly files?: FileCollections | null
|
|
181
|
+
/** The framework's own jobs this contract needs: the scan of its `scan: true` file collections. */
|
|
182
|
+
readonly frameworkJobs?: readonly FrameworkJob[]
|
|
183
|
+
/** IAM's membership operations, which a job that `issues` links asks; `null` when the adapter supplies none. */
|
|
184
|
+
readonly membership: IamMembership | null
|
|
185
|
+
/** The runtime's authority, which a `Ref` passed to IAM must resolve with. */
|
|
186
|
+
readonly capabilities: CapabilityAuthority
|
|
187
|
+
/** Each dimension's containers, nearest first: a link's chain. */
|
|
188
|
+
readonly containers: ReadonlyMap<string, readonly string[]>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface BoundFrameworkJob {
|
|
192
|
+
readonly job: FrameworkJob
|
|
193
|
+
readonly name: string
|
|
194
|
+
readonly schedule: CronSchedule
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface BoundJobs {
|
|
198
|
+
readonly jobs: ContractJobs
|
|
199
|
+
/** The handler ids the jobs took (`jobs.<name>`), so `boot` finds no undeclared handler among them. */
|
|
200
|
+
readonly declared: readonly string[]
|
|
201
|
+
/**
|
|
202
|
+
* Kicks the contract job `name`, as a form's `then` does after its row committed: a run under the job's own key
|
|
203
|
+
* now, or the shared pending flag for the run holding the lease, which then runs once more after it succeeded. It
|
|
204
|
+
* waits for no lock, settles when that work has ended and never rejects; the schedule stays the recovery path.
|
|
205
|
+
*/
|
|
206
|
+
readonly kick: (name: string) => Promise<void>
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function ownChild(node: unknown, key: string): unknown {
|
|
210
|
+
return typeof node === 'object' && node !== null && Object.hasOwn(node, key) ? Reflect.get(node, key) : undefined
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function durationMs(timeout: string | null): number | null {
|
|
214
|
+
if (timeout === null) {
|
|
215
|
+
return DEFAULT_JOB_TIMEOUT_MS
|
|
216
|
+
}
|
|
217
|
+
const match = DURATION.exec(timeout)
|
|
218
|
+
const unit = match?.[2] === undefined ? undefined : UNIT_MS[match[2]]
|
|
219
|
+
return match?.[1] === undefined || unit === undefined ? null : Number.parseInt(match[1], 10) * unit
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Binds every declared job to its handler, or adds why it cannot run to `problems`. */
|
|
223
|
+
export function bindJobs(input: JobBindingInput, problems: string[]): BoundJobs {
|
|
224
|
+
const entries = Object.entries(input.artifact.jobs)
|
|
225
|
+
const bound = new Map<string, BoundJob>()
|
|
226
|
+
const declared: string[] = []
|
|
227
|
+
const tree = ownChild(input.handlers, 'jobs')
|
|
228
|
+
for (const [name, artifact] of entries) {
|
|
229
|
+
declared.push(`jobs.${name}`)
|
|
230
|
+
const where = `job '${name}'`
|
|
231
|
+
if (artifact.scope.kind !== 'app') {
|
|
232
|
+
problems.push(`${where} is scoped to '${artifact.scope.dimension}'; this version runs jobs with scope: 'app' only`)
|
|
233
|
+
}
|
|
234
|
+
if (input.framework === null) {
|
|
235
|
+
problems.push(`${where} needs @fabrika/db on Postgres, which holds its lease and fences its writes: serve(contract, { database: scoped(tables) })`)
|
|
236
|
+
}
|
|
237
|
+
const schedule = parseCron(artifact.schedule)
|
|
238
|
+
if (schedule === null) {
|
|
239
|
+
problems.push(`${where} has the schedule '${artifact.schedule}', which is not a cron expression (allowed: '0 3 * * *', '*/15 * * * *')`)
|
|
240
|
+
}
|
|
241
|
+
const timeoutMs = durationMs(artifact.timeout)
|
|
242
|
+
if (timeoutMs === null) {
|
|
243
|
+
problems.push(`${where} has the timeout '${artifact.timeout}', which is not a duration (allowed: '30s', '10m', '1h')`)
|
|
244
|
+
}
|
|
245
|
+
const node = ownChild(tree, name)
|
|
246
|
+
const handler = isSiteHandler(node) ? node.handler : node
|
|
247
|
+
const site = isSiteHandler(node) ? approvedDeclaration(where, `${JOB_SITE_PREFIX}${name}`, node.declared, 'job', input.artifact, problems) : null
|
|
248
|
+
const credential = input.credentials.get(`job:${name}`)
|
|
249
|
+
const links = jobLinks(where, `${JOB_SITE_PREFIX}${name}`, site, input, problems)
|
|
250
|
+
if (typeof handler !== 'function') {
|
|
251
|
+
problems.push(`${where} has no handler; add it to handlers.jobs`)
|
|
252
|
+
} else if (site !== null && input.data === null) {
|
|
253
|
+
problems.push(`${where} reaches data through ctx.db, which runs on @fabrika/db: pass serve(contract, { database: scoped(tables) })`)
|
|
254
|
+
} else if (schedule !== null && timeoutMs !== null && credential !== undefined) {
|
|
255
|
+
bound.set(name, { name, artifact, schedule, timeoutMs, handler: (args) => Reflect.apply(handler, undefined, [args]), credential, site, links })
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const frameworkJobs = new Map<string, BoundFrameworkJob>()
|
|
259
|
+
for (const job of input.frameworkJobs ?? []) {
|
|
260
|
+
const schedule = parseCron(job.schedule)
|
|
261
|
+
if (input.framework === null) {
|
|
262
|
+
problems.push(`the framework job '${job.name}' needs @fabrika/db on Postgres, which holds its lease and fences its writes`)
|
|
263
|
+
} else if (schedule !== null) {
|
|
264
|
+
frameworkJobs.set(job.name, { job, name: job.name, schedule })
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const names = Object.freeze(entries.map(([name]) => name))
|
|
268
|
+
const framework = input.framework
|
|
269
|
+
const contextFor = (leases: JobLeases, chosen: OperationsReporter | undefined): RunnerContext => ({
|
|
270
|
+
artifact: input.artifact,
|
|
271
|
+
database: input.database,
|
|
272
|
+
ownedDimensions: input.ownedDimensions,
|
|
273
|
+
leases,
|
|
274
|
+
reporter: chosen ?? input.reporter,
|
|
275
|
+
mint: input.mint,
|
|
276
|
+
data: input.data,
|
|
277
|
+
files: input.files ?? null,
|
|
278
|
+
membership: input.membership,
|
|
279
|
+
capabilities: input.capabilities,
|
|
280
|
+
containers: input.containers,
|
|
281
|
+
})
|
|
282
|
+
const jobs: ContractJobs = Object.freeze({
|
|
283
|
+
names,
|
|
284
|
+
run(name: string, options: JobRunOptions = {}): Promise<JobRunResult> {
|
|
285
|
+
const own = frameworkJobs.get(name)
|
|
286
|
+
if (own !== undefined && framework !== null) {
|
|
287
|
+
return runFrameworkJob(own, contextFor(framework.jobs, options.reporter), options.clock ?? systemClock)
|
|
288
|
+
}
|
|
289
|
+
const job = bound.get(name)
|
|
290
|
+
if (job === undefined || framework === null) {
|
|
291
|
+
throw new JobError(`no job '${name}' in the contract (jobs: ${names.length === 0 ? 'none' : names.join(', ')})`)
|
|
292
|
+
}
|
|
293
|
+
return runJob(job, contextFor(framework.jobs, options.reporter), options.clock ?? systemClock, 'wait').then((result) => {
|
|
294
|
+
if (result.status === 'skipped') {
|
|
295
|
+
console.error(`fabrika: a previous run of job '${name}' still holds its lease; this run does not start`)
|
|
296
|
+
}
|
|
297
|
+
return result
|
|
298
|
+
})
|
|
299
|
+
},
|
|
300
|
+
async check(options: Pick<JobRunOptions, 'reporter'> = {}): Promise<void> {
|
|
301
|
+
if (framework === null) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
const context = contextFor(framework.jobs, options.reporter)
|
|
305
|
+
for (const job of [...bound.values(), ...frameworkJobs.values()]) {
|
|
306
|
+
try {
|
|
307
|
+
await context.leases.register(job.name)
|
|
308
|
+
await surveil(job, context)
|
|
309
|
+
} catch {
|
|
310
|
+
// A driver error may quote the database URL.
|
|
311
|
+
console.error(`fabrika: the check of job '${job.name}' failed: driver_failed`)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
const kick = async (name: string): Promise<void> => {
|
|
317
|
+
const job = bound.get(name)
|
|
318
|
+
if (job !== undefined && framework !== null) {
|
|
319
|
+
await kickOnce(job, contextFor(framework.jobs, undefined))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { jobs, declared, kick }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* One kick: a run while the lease is free, else the shared pending flag. Neither waits for a lock, so a kick never
|
|
327
|
+
* holds a connection requests share while a run writes. After a failed run the flag stays for the schedule.
|
|
328
|
+
*/
|
|
329
|
+
async function kickOnce(job: BoundJob, context: RunnerContext): Promise<void> {
|
|
330
|
+
if ((await runJob(job, context, systemClock, 'try')).status === 'skipped') {
|
|
331
|
+
await handOver(job, context, systemClock)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Leaves the flag to the run holding the lease, which takes it after it succeeded. That run may have finished before
|
|
337
|
+
* the flag was set, so the lease is tried once more; `null` when another run still holds it.
|
|
338
|
+
*/
|
|
339
|
+
async function handOver(job: BoundJob, context: RunnerContext, clock: JobClock): Promise<JobRunResult | null> {
|
|
340
|
+
if (!(await markPending(job, context))) {
|
|
341
|
+
return null
|
|
342
|
+
}
|
|
343
|
+
const result = await runJob(job, context, clock, 'try')
|
|
344
|
+
return result.status === 'skipped' ? null : result
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function markPending(job: BoundJob, context: RunnerContext): Promise<boolean> {
|
|
348
|
+
try {
|
|
349
|
+
await context.leases.markPending(job.name)
|
|
350
|
+
return true
|
|
351
|
+
} catch {
|
|
352
|
+
// A driver error may quote the database URL; the schedule picks the submission up.
|
|
353
|
+
console.error(`fabrika: the kick of job '${job.name}' failed: driver_failed`)
|
|
354
|
+
return false
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The link mount a job issues, by the approved artifact's declaration of its site whatever its leaf; a job that issues
|
|
360
|
+
* without an IAM membership transport does not boot.
|
|
361
|
+
*/
|
|
362
|
+
function jobLinks(where: string, site: string, declaration: SiteDeclaration | null, input: JobBindingInput, problems: string[]): JobLinkMount | null {
|
|
363
|
+
const reach = declaration ?? (input.artifact.declared === undefined ? null : approvedReach(site, input.artifact))
|
|
364
|
+
const name = reach?.issues ?? null
|
|
365
|
+
if (name === null) {
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
const mount = Object.hasOwn(input.artifact.mounts, name) ? input.artifact.mounts[name] : undefined
|
|
369
|
+
if (mount?.kind !== 'link') {
|
|
370
|
+
problems.push(`${where} issues '${name}', which is not a link mount of the contract`)
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
if (input.membership === null) {
|
|
374
|
+
problems.push(
|
|
375
|
+
`${where} issues links of '${name}', and the runtime adapter supplies no IAM membership transport (the IAM binding on Cloudflare; FABRIKA_IAM_RPC_URL and FABRIKA_IAM_RPC_KEY on Bun)`,
|
|
376
|
+
)
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
return { name, prefix: mount.prefix, dimension: mount.dimension, issuedBy: mount.issuedBy }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const attemptId = (): string => Bun.randomUUIDv7()
|
|
383
|
+
|
|
384
|
+
/** Exponential from one second, capped at five minutes, with half of it jittered. */
|
|
385
|
+
function backoffMs(attempt: number, random: number): number {
|
|
386
|
+
const ceiling = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (attempt - 1))
|
|
387
|
+
return Math.round(ceiling / 2 + (ceiling / 2) * random)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** A crashed run (its lease expired before it finished) and a job overdue by twice its period: one report each. */
|
|
391
|
+
async function surveil(job: { readonly name: string; readonly schedule: CronSchedule }, context: RunnerContext): Promise<void> {
|
|
392
|
+
const tags = { job: job.name }
|
|
393
|
+
if (await context.leases.reapCrashed(job.name)) {
|
|
394
|
+
const message = `job '${job.name}' failed: crashed (its run's lease expired before the run finished)`
|
|
395
|
+
console.error(`fabrika: ${message}`)
|
|
396
|
+
await context.reporter.report({ fingerprint: ['fabrika-job-failed', job.name], type: 'JobFailed', message, tags: { ...tags, code: 'crashed' } })
|
|
397
|
+
}
|
|
398
|
+
const period = periodAt(job.schedule, await context.leases.now())
|
|
399
|
+
const claim = period === null ? null : await context.leases.claimOverdue(job.name, period)
|
|
400
|
+
if (claim === null || period === null) {
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
const last = claim.lastSucceededAt === null
|
|
404
|
+
? `has never succeeded since it was first seen at ${claim.firstSeenAt.toISOString()}`
|
|
405
|
+
: `last succeeded at ${claim.lastSucceededAt.toISOString()}`
|
|
406
|
+
await context.reporter.report({
|
|
407
|
+
fingerprint: ['fabrika-job-missed', job.name],
|
|
408
|
+
type: 'JobMissed',
|
|
409
|
+
message: `job '${job.name}' ${last}; it is due every ${Math.round(period / 60_000)} min and has missed twice that`,
|
|
410
|
+
tags,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
interface Progress {
|
|
415
|
+
attempts: number
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* How a run takes the lease: `wait` is `fabrika job run`'s, in a process of its own; `try` is a kick's and a follow-up
|
|
420
|
+
* run's, which never wait for a lock.
|
|
421
|
+
*/
|
|
422
|
+
type Acquisition = 'wait' | 'try'
|
|
423
|
+
|
|
424
|
+
/** A run of the job, then one more while a kick arrived after the last one started and it succeeded. */
|
|
425
|
+
async function runJob(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition): Promise<JobRunResult> {
|
|
426
|
+
let result = await runOnce(job, context, clock, acquisition)
|
|
427
|
+
while (result.status === 'succeeded' && (await pendingTaken(job, context))) {
|
|
428
|
+
const next = await runOnce(job, context, clock, 'try')
|
|
429
|
+
if (next.status !== 'skipped') {
|
|
430
|
+
result = next
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
433
|
+
// A newer run holds the lease and may have read before the kick's row: the flag goes back to it.
|
|
434
|
+
const handed = await handOver(job, context, clock)
|
|
435
|
+
return handed ?? result
|
|
436
|
+
}
|
|
437
|
+
return result
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function pendingTaken(job: BoundJob, context: RunnerContext): Promise<boolean> {
|
|
441
|
+
try {
|
|
442
|
+
return await context.leases.takePending(job.name)
|
|
443
|
+
} catch {
|
|
444
|
+
// A driver error may quote the database URL; the flag stays for the next run.
|
|
445
|
+
console.error(`fabrika: the pending run of job '${job.name}' could not be read: driver_failed`)
|
|
446
|
+
return false
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function runOnce(job: BoundJob, context: RunnerContext, clock: JobClock, acquisition: Acquisition): Promise<JobRunResult> {
|
|
451
|
+
const progress: Progress = { attempts: 0 }
|
|
452
|
+
try {
|
|
453
|
+
return await runLeased(job, context, clock, progress, acquisition)
|
|
454
|
+
} catch {
|
|
455
|
+
// Only the lease store throws here: the handler's errors stay inside its attempt. Its message may quote the URL.
|
|
456
|
+
return failed(job, progress.attempts, failure('driver_failed'), context.reporter)
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function runLeased(
|
|
461
|
+
job: BoundJob,
|
|
462
|
+
context: RunnerContext,
|
|
463
|
+
clock: JobClock,
|
|
464
|
+
progress: Progress,
|
|
465
|
+
acquisition: Acquisition,
|
|
466
|
+
): Promise<JobRunResult> {
|
|
467
|
+
const { leases } = context
|
|
468
|
+
await leases.register(job.name)
|
|
469
|
+
const leaseMs = job.timeoutMs + LEASE_GRACE_MS
|
|
470
|
+
let fence: LeaseFence | null
|
|
471
|
+
if (acquisition === 'wait') {
|
|
472
|
+
await surveil(job, context)
|
|
473
|
+
fence = await leases.acquire(job.name, attemptId(), job.timeoutMs, leaseMs)
|
|
474
|
+
} else {
|
|
475
|
+
fence = await leases.tryAcquire(job.name, attemptId(), job.timeoutMs, leaseMs)
|
|
476
|
+
}
|
|
477
|
+
if (fence === null) {
|
|
478
|
+
return { status: 'skipped' }
|
|
479
|
+
}
|
|
480
|
+
// This run covers every kick before it: their rows committed before it reads.
|
|
481
|
+
await pendingTaken(job, context)
|
|
482
|
+
const attempts = job.artifact.retries + 1
|
|
483
|
+
progress.attempts = 1
|
|
484
|
+
let outcome = await attemptOnce(job, fence, progress.attempts, clock, context)
|
|
485
|
+
while (outcome !== null && progress.attempts < attempts) {
|
|
486
|
+
console.error(`fabrika: job '${job.name}' attempt ${progress.attempts} of ${attempts} failed: ${describeFailure(outcome)}`)
|
|
487
|
+
const delay = backoffMs(progress.attempts, clock.random())
|
|
488
|
+
// A new generation at once: nothing the failed attempt still has in flight commits from here on.
|
|
489
|
+
const next = await leases.renew(fence, attemptId(), delay + job.timeoutMs, delay + leaseMs)
|
|
490
|
+
if (next === null) {
|
|
491
|
+
return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
|
|
492
|
+
}
|
|
493
|
+
fence = next
|
|
494
|
+
await clock.wait(delay, new AbortController().signal)
|
|
495
|
+
progress.attempts++
|
|
496
|
+
outcome = await attemptOnce(job, fence, progress.attempts, clock, context)
|
|
497
|
+
}
|
|
498
|
+
if (outcome === null) {
|
|
499
|
+
if (await leases.finish(fence, 'succeeded')) {
|
|
500
|
+
return { status: 'succeeded', attempts: progress.attempts }
|
|
501
|
+
}
|
|
502
|
+
return failed(job, progress.attempts, failure('lease_lost'), context.reporter)
|
|
503
|
+
}
|
|
504
|
+
await leases.finish(fence, 'failed')
|
|
505
|
+
return failed(job, progress.attempts, outcome, context.reporter)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Why an attempt or a run failed: its code and, for a framework refusal, its reason and the reason's catalog text. */
|
|
509
|
+
interface JobFailure {
|
|
510
|
+
readonly code: JobFailureCode
|
|
511
|
+
readonly why: FailureReason | null
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const failure = (code: JobFailureCode): JobFailure => ({ code, why: null })
|
|
515
|
+
|
|
516
|
+
/** The code, then a refusal's reason and its fixed text: the same words a request's log line uses. */
|
|
517
|
+
const describeFailure = ({ code, why }: JobFailure): string => (why === null ? code : `${code}: ${why.reason}: ${why.text}`)
|
|
518
|
+
|
|
519
|
+
async function failed(job: { readonly name: string }, attempts: number, outcome: JobFailure, reporter: OperationsReporter): Promise<JobRunResult> {
|
|
520
|
+
const { code, why } = outcome
|
|
521
|
+
const message = `job '${job.name}' failed after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${code}`
|
|
522
|
+
console.error(`fabrika: job '${job.name}' failed after ${attempts} attempt${attempts === 1 ? '' : 's'}: ${describeFailure(outcome)}`)
|
|
523
|
+
const tags: Record<string, string> = { job: job.name, code }
|
|
524
|
+
if (why !== null) {
|
|
525
|
+
tags['reason'] = why.reason
|
|
526
|
+
}
|
|
527
|
+
await reporter.report({ fingerprint: ['fabrika-job-failed', job.name], type: 'JobFailed', message, tags })
|
|
528
|
+
return why === null ? { status: 'failed', attempts, code } : { status: 'failed', attempts, code, reason: why.reason }
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const TIMED_OUT: unique symbol = Symbol('fabrika.job.timedOut')
|
|
532
|
+
|
|
533
|
+
/** One run of a framework job: its lease, one attempt in a frame fenced by it, and the outcome reported like a job's. */
|
|
534
|
+
async function runFrameworkJob(bound: BoundFrameworkJob, context: RunnerContext, clock: JobClock): Promise<JobRunResult> {
|
|
535
|
+
const { leases } = context
|
|
536
|
+
let fence: LeaseFence | null
|
|
537
|
+
try {
|
|
538
|
+
await leases.register(bound.name)
|
|
539
|
+
await surveil(bound, context)
|
|
540
|
+
fence = await leases.acquire(bound.name, attemptId(), bound.job.timeoutMs, bound.job.timeoutMs + LEASE_GRACE_MS)
|
|
541
|
+
} catch {
|
|
542
|
+
return failed(bound, 0, failure('driver_failed'), context.reporter)
|
|
543
|
+
}
|
|
544
|
+
if (fence === null) {
|
|
545
|
+
console.error(`fabrika: a previous run of job '${bound.name}' still holds its lease; this run does not start`)
|
|
546
|
+
return { status: 'skipped' }
|
|
547
|
+
}
|
|
548
|
+
const frame = new RequestFrame(fence.holder, { fence })
|
|
549
|
+
const cancel = new AbortController()
|
|
550
|
+
const timer = new AbortController()
|
|
551
|
+
let outcome: JobFailure | null
|
|
552
|
+
try {
|
|
553
|
+
const ran = runInRequest(frame, () => bound.job.run(context.reporter, cancel.signal)).then(() => null, failureOf)
|
|
554
|
+
const timeout = clock.wait(bound.job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
|
|
555
|
+
const result = await Promise.race([ran, timeout])
|
|
556
|
+
outcome = result === TIMED_OUT ? failure('timeout') : result
|
|
557
|
+
} finally {
|
|
558
|
+
frame.end()
|
|
559
|
+
timer.abort()
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
// Writes are refused before the run hears it was stopped: a scanner that answers on abort commits nothing.
|
|
563
|
+
try {
|
|
564
|
+
await leases.cancelInFlight(fence)
|
|
565
|
+
} finally {
|
|
566
|
+
cancel.abort()
|
|
567
|
+
}
|
|
568
|
+
const finished = await leases.finish(fence, outcome === null ? 'succeeded' : 'failed')
|
|
569
|
+
if (outcome === null) {
|
|
570
|
+
return finished ? { status: 'succeeded', attempts: 1 } : failed(bound, 1, failure('lease_lost'), context.reporter)
|
|
571
|
+
}
|
|
572
|
+
} catch {
|
|
573
|
+
return failed(bound, 1, failure('driver_failed'), context.reporter)
|
|
574
|
+
}
|
|
575
|
+
return failed(bound, 1, outcome, context.reporter)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* `null` when the handler finished, else the attempt's failure code. Either way the attempt's frame ends and the
|
|
580
|
+
* statements it still has running are cancelled before the runner touches the lease again.
|
|
581
|
+
*/
|
|
582
|
+
async function attemptOnce(
|
|
583
|
+
job: BoundJob,
|
|
584
|
+
fence: LeaseFence,
|
|
585
|
+
attempt: number,
|
|
586
|
+
clock: JobClock,
|
|
587
|
+
context: RunnerContext,
|
|
588
|
+
): Promise<JobFailure | null> {
|
|
589
|
+
// The job's authority is what IAM signs for its key at the start of the attempt; a revoked key stops the next one.
|
|
590
|
+
const authority = await job.credential.authority()
|
|
591
|
+
if (!authority.ok) {
|
|
592
|
+
return failure(authority.reason === 'unavailable' ? 'iam_unavailable' : 'unauthorized')
|
|
593
|
+
}
|
|
594
|
+
const frame = new RequestFrame(fence.holder, { fence })
|
|
595
|
+
const cancel = new AbortController()
|
|
596
|
+
const timer = new AbortController()
|
|
597
|
+
const ctx = jobContext(job, frame, attempt, cancel.signal, context, authority.perms)
|
|
598
|
+
try {
|
|
599
|
+
const ran = runInRequest(frame, async () => {
|
|
600
|
+
await job.handler({ ctx })
|
|
601
|
+
}).then(() => null, failureOf)
|
|
602
|
+
const timeout = clock.wait(job.timeoutMs, timer.signal).then((): typeof TIMED_OUT => TIMED_OUT)
|
|
603
|
+
const result = await Promise.race([ran, timeout])
|
|
604
|
+
return result === TIMED_OUT ? failure('timeout') : result
|
|
605
|
+
} finally {
|
|
606
|
+
frame.end()
|
|
607
|
+
cancel.abort()
|
|
608
|
+
timer.abort()
|
|
609
|
+
await context.leases.cancelInFlight(fence)
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* By class, never by name: an application error can call itself anything. A refusal is classified as a request's is
|
|
615
|
+
* (`failureCode`), so both name it alike; every other thrown value is `handler_failed`.
|
|
616
|
+
*/
|
|
617
|
+
function failureOf(error: unknown): JobFailure {
|
|
618
|
+
if (error instanceof LeaseLostError) {
|
|
619
|
+
return failure('lease_lost')
|
|
620
|
+
}
|
|
621
|
+
const code = failureCode(error)
|
|
622
|
+
return code === 'query_refused' || code === 'capability_refused' ? { code, why: failureReason(error) } : failure('handler_failed')
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function jobContext(
|
|
626
|
+
job: BoundJob,
|
|
627
|
+
frame: RequestFrame,
|
|
628
|
+
attempt: number,
|
|
629
|
+
signal: AbortSignal,
|
|
630
|
+
context: RunnerContext,
|
|
631
|
+
perms: readonly PermissionEntry[],
|
|
632
|
+
): JobContext<string, string> {
|
|
633
|
+
const principal: JobPrincipal = Object.freeze({ kind: 'job', id: `job:${job.name}`, job: job.name })
|
|
634
|
+
const check = (action: string, dimension: string): void => {
|
|
635
|
+
if (frame.ended) {
|
|
636
|
+
throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it mints no capability`)
|
|
637
|
+
}
|
|
638
|
+
if (!job.artifact.actions.includes(action)) {
|
|
639
|
+
throw new CapabilityRefusedError(
|
|
640
|
+
'undeclared_action',
|
|
641
|
+
`job '${job.name}' does not declare this action (actions: ${job.artifact.actions.join(', ')})`,
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
if (!permits([...perms], action)) {
|
|
645
|
+
throw new CapabilityRefusedError('undeclared_action', `the token IAM signed for the key of job '${job.name}' does not carry this action`)
|
|
646
|
+
}
|
|
647
|
+
if (!Object.hasOwn(context.artifact.dimensions, dimension)) {
|
|
648
|
+
throw new CapabilityRefusedError('undeclared_dimension', `job '${job.name}' named a dimension the contract does not declare`)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return Object.freeze({
|
|
652
|
+
principal,
|
|
653
|
+
runId: frame.id,
|
|
654
|
+
attempt,
|
|
655
|
+
signal,
|
|
656
|
+
...(job.site === null || context.data === null ? {} : kitJobContext(frame, context.data, context.files, job.name, job.site, principal.id)),
|
|
657
|
+
...(job.links === null || context.membership === null ? {} : { links: jobLinksOf(job, job.links, context.membership, frame, context) }),
|
|
658
|
+
within<const Act extends string, const Dim extends string>(action: Act, dimension: Dim): Within<Dim, Act> {
|
|
659
|
+
check(action, dimension)
|
|
660
|
+
return context.mint.within(frame, dimension, action, principal.id, 'all')
|
|
661
|
+
},
|
|
662
|
+
async ref<const Act extends string, const Dim extends string>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>> {
|
|
663
|
+
check(action, dimension)
|
|
664
|
+
if (typeof id !== 'string' || id === '') {
|
|
665
|
+
throw new CapabilityRefusedError('missing_id', `job '${job.name}' named an object without an id`)
|
|
666
|
+
}
|
|
667
|
+
const database = context.database
|
|
668
|
+
const chain = database === null || !context.ownedDimensions.has(dimension) ? new Map<string, string>() : await database.locate(dimension, id)
|
|
669
|
+
if (chain === null) {
|
|
670
|
+
throw new NotFoundError('Not found')
|
|
671
|
+
}
|
|
672
|
+
return context.mint.ref(frame, dimension, action, principal.id, id, chain)
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** `ctx.links` of a job that issues: IAM is asked with the job's current token, under the attempt's id. */
|
|
678
|
+
function jobLinksOf(job: BoundJob, mount: JobLinkMount, iam: IamMembership, frame: RequestFrame, context: RunnerContext): object {
|
|
679
|
+
// An attempt that timed out or lost its lease asks IAM nothing: its replacement may already have acted.
|
|
680
|
+
const assertLive = (): void => {
|
|
681
|
+
if (frame.ended) {
|
|
682
|
+
throw new CapabilityRefusedError('ended', `attempt ${frame.id} of job '${job.name}' has ended; it asks IAM for no link`)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return jobLinkIssuing({
|
|
686
|
+
iam,
|
|
687
|
+
job: job.name,
|
|
688
|
+
mount,
|
|
689
|
+
containers: context.containers.get(mount.dimension) ?? [],
|
|
690
|
+
capabilities: context.capabilities,
|
|
691
|
+
assertLive,
|
|
692
|
+
async caller() {
|
|
693
|
+
assertLive()
|
|
694
|
+
const authority = await job.credential.authority()
|
|
695
|
+
if (!authority.ok) {
|
|
696
|
+
throw new ServerFault(
|
|
697
|
+
authority.reason === 'unavailable' ? 'iam_unavailable' : 'workload_unauthorized',
|
|
698
|
+
`IAM signs no token for the key of job '${job.name}'`,
|
|
699
|
+
503,
|
|
700
|
+
)
|
|
701
|
+
}
|
|
702
|
+
return { credential: authority.token, requestId: frame.id }
|
|
703
|
+
},
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* What a staged-kit job adds: `ctx.db`, whose reads run each on its own, and `ctx.transaction(work)`, the one place it
|
|
709
|
+
* writes and runs `tx.sql` — fenced by the attempt's lease, so an attempt that timed out or lost it commits nothing.
|
|
710
|
+
*/
|
|
711
|
+
function kitJobContext(
|
|
712
|
+
frame: RequestFrame,
|
|
713
|
+
data: DataAccess,
|
|
714
|
+
files: FileCollections | null,
|
|
715
|
+
name: string,
|
|
716
|
+
site: SiteDeclaration,
|
|
717
|
+
principal: string,
|
|
718
|
+
): Readonly<Record<string, unknown>> {
|
|
719
|
+
const reads = jobHandles(frame, data, site, principal, 'job', async () => data.direct(), () => false)
|
|
720
|
+
const job = `job '${name}'`
|
|
721
|
+
return {
|
|
722
|
+
db: siteData(reads),
|
|
723
|
+
sql: site.sql === null ? undeclaredSql(job, 'job') : jobSqlOutsideTransaction(job),
|
|
724
|
+
files: files === null ? noFileCollections(job) : files.site({
|
|
725
|
+
where: job,
|
|
726
|
+
frame,
|
|
727
|
+
check: boundTo(frame, () => false, 'ctx.files'),
|
|
728
|
+
session: async () => data.direct(),
|
|
729
|
+
readOnly: 'a job removes files inside ctx.transaction((tx) => …), as tx.files',
|
|
730
|
+
}),
|
|
731
|
+
transaction: async <R>(work: (tx: object) => Promise<R>): Promise<R> => {
|
|
732
|
+
// Outside its attempt there is no lease to fence the transaction with, so it never opens.
|
|
733
|
+
boundTo(frame, () => false)()
|
|
734
|
+
return data.transaction(async (session) => {
|
|
735
|
+
let open = true
|
|
736
|
+
const writes = jobHandles(frame, data, site, principal, 'mutation', async () => session, () => !open)
|
|
737
|
+
try {
|
|
738
|
+
const sql = site.sql === null ? undeclaredSql(job, 'job', 'tx.sql') : jobSql(frame, data, site.sql, session, () => !open)
|
|
739
|
+
const removing = files === null
|
|
740
|
+
? noFileCollections(job)
|
|
741
|
+
: files.site({ where: job, frame, check: boundTo(frame, () => !open, 'tx.files'), session: async () => session, readOnly: null })
|
|
742
|
+
return await work(Object.freeze({ db: siteData(writes), sql, files: removing }))
|
|
743
|
+
} finally {
|
|
744
|
+
open = false
|
|
745
|
+
}
|
|
746
|
+
})
|
|
747
|
+
},
|
|
748
|
+
}
|
|
749
|
+
}
|