@ultimat3/jobs 1.2.0 → 2.0.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/src/driver-pg.ts CHANGED
@@ -1,31 +1,43 @@
1
1
  // The DEFAULT driver: a Postgres queue. Zero infra to start — the database you already have
2
2
  // is the queue. `SELECT ... FOR UPDATE SKIP LOCKED` lets N workers claim disjoint batches
3
3
  // without a coordinator, and the visibility timeout (`visible_at`) makes a worker crash cost
4
- // one lease instead of one job. The statements themselves live in `driver-pg-sql.ts`.
4
+ // one lease instead of one job. The statements themselves live in `driver-pg-sql.ts`, the schema
5
+ // they run against in `driver-pg-ddl.ts`, and the row-to-record decoding in `driver-pg-rows.ts`.
5
6
 
6
7
  import type { Clock } from '@ultimat3/core';
7
8
  import { systemClock, uuid } from '@ultimat3/core';
9
+ import type { BackfillLedger } from './backfill-ledger';
8
10
  import { nowMs } from './clock';
9
11
  import type {
10
12
  ClaimedJob,
11
13
  ClaimOptions,
12
14
  EnqueueRequest,
13
15
  EnqueueResult,
16
+ HeartbeatOptions,
14
17
  JobDriver,
15
18
  JobFilter,
16
19
  JobIntrospection,
17
- JobRecord,
18
20
  NackOptions,
19
21
  QueueStats,
20
22
  } from './driver';
21
23
  import { DEFAULT_QUEUE } from './driver';
24
+ import type { BackfillRow, JobRow, StepRow } from './driver-pg-rows';
25
+ import { num, toBackfillRun, toJobRecord, toStepRecord } from './driver-pg-rows';
22
26
  import {
23
27
  SQL_ACK,
24
28
  SQL_ADVISORY_UNLOCK,
29
+ SQL_BACKFILL_FINISH,
30
+ SQL_BACKFILL_LIST,
31
+ SQL_BACKFILL_PROGRESS,
32
+ SQL_BACKFILL_START,
33
+ SQL_CANCEL,
25
34
  SQL_CLAIM,
26
35
  SQL_ENQUEUE,
27
36
  SQL_FIND_LIVE_BY_KEY,
28
37
  SQL_HEARTBEAT,
38
+ SQL_LEASE_ACQUIRE,
39
+ SQL_LEASE_RELEASE,
40
+ SQL_LEASE_RENEW,
29
41
  SQL_NACK,
30
42
  SQL_STATS,
31
43
  SQL_STEP_GET,
@@ -33,9 +45,20 @@ import {
33
45
  SQL_TRY_ADVISORY_LOCK,
34
46
  } from './driver-pg-sql';
35
47
  import { DriverUnavailableError, JobDuplicateError } from './errors';
36
- import type { StepRecord, StepStore } from './steps';
48
+ import type { HeldLease, LeaseStore } from './leases';
49
+ import type { StepStore } from './steps';
37
50
 
38
- /** The one thing this driver needs from the DB layer. Satisfied by `Bun.sql` and by a Tx. */
51
+ /**
52
+ * The one thing this driver needs from the DB layer, declared structurally so this package can
53
+ * depend on no database package at all.
54
+ *
55
+ * **Not satisfied by `Bun.sql`** — verified against Bun 1.3.14: `Bun.sql.query` is `undefined`.
56
+ * `Bun.sql` is a tagged template whose positional form is `unsafe`, so a `{ executor: Bun.sql }`
57
+ * would `TypeError` on the first claim. What satisfies it is a one-line adapter over a client that
58
+ * already speaks `(text, values)` — `@ultimat3/cli`'s `pgExecutorFor(client)` is the framework's
59
+ * own, wrapping `@ultimat3/db`'s `DbClient.query({ text, values })` — and a `DbTx`, which is a
60
+ * client on the transaction's own connection.
61
+ */
39
62
  export interface PgExecutor {
40
63
  query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
41
64
  }
@@ -45,91 +68,14 @@ export interface PgDriverOptions {
45
68
  readonly clock?: Clock;
46
69
  }
47
70
 
48
- interface StepRow {
49
- readonly run_id: string;
50
- readonly name: string;
51
- readonly status: string;
52
- readonly output: unknown;
53
- readonly started_at: number | string;
54
- readonly completed_at: number | string | null;
55
- readonly wake_at: number | string | null;
56
- readonly event: string | null;
57
- readonly correlation_key: string | null;
58
- readonly attempts: number;
59
- readonly error: string | null;
60
- }
61
-
62
- interface JobRow {
63
- readonly id: string;
64
- readonly name: string;
65
- readonly queue: string;
66
- readonly input: unknown;
67
- readonly idempotency_key: string;
68
- readonly run_id: string;
69
- readonly attempt: number;
70
- readonly max_attempts: number;
71
- readonly state: string;
72
- readonly tenant_id: string | null;
73
- readonly last_error: string | null;
74
- readonly claimed_by: string | null;
75
- readonly run_at: number | string;
76
- readonly visible_at: number | string | null;
77
- readonly created_at: number | string;
78
- readonly updated_at: number | string;
79
- }
80
-
81
- const num = (value: number | string | null | undefined): number =>
82
- value === null || value === undefined ? 0 : Number(value);
83
-
84
- const optionalNum = (value: number | string | null | undefined): number | undefined =>
85
- value === null || value === undefined ? undefined : Number(value);
86
-
87
- function toJobRecord(row: JobRow): JobRecord {
88
- const visibleAt = optionalNum(row.visible_at);
89
- return {
90
- id: row.id,
91
- name: row.name,
92
- queue: row.queue,
93
- input: row.input,
94
- idempotencyKey: row.idempotency_key,
95
- runId: row.run_id,
96
- attempt: row.attempt,
97
- maxAttempts: row.max_attempts,
98
- state: row.state as JobRecord['state'],
99
- runAt: num(row.run_at),
100
- createdAt: num(row.created_at),
101
- updatedAt: num(row.updated_at),
102
- ...(row.tenant_id === null ? {} : { tenantId: row.tenant_id }),
103
- ...(row.last_error === null ? {} : { lastError: row.last_error }),
104
- ...(row.claimed_by === null ? {} : { claimedBy: row.claimed_by }),
105
- ...(visibleAt === undefined ? {} : { visibleAt }),
106
- };
107
- }
108
-
109
- function toStepRecord(row: StepRow): StepRecord {
110
- const completedAt = optionalNum(row.completed_at);
111
- const wakeAt = optionalNum(row.wake_at);
112
- return {
113
- runId: row.run_id,
114
- name: row.name,
115
- status: row.status as StepRecord['status'],
116
- output: row.output,
117
- startedAt: num(row.started_at),
118
- attempts: row.attempts,
119
- ...(completedAt === undefined ? {} : { completedAt }),
120
- ...(wakeAt === undefined ? {} : { wakeAt }),
121
- ...(row.event === null ? {} : { event: row.event }),
122
- ...(row.correlation_key === null ? {} : { correlationKey: row.correlation_key }),
123
- ...(row.error === null ? {} : { error: row.error }),
124
- };
125
- }
126
-
127
71
  function resolveExecutor(injected: PgExecutor | undefined): PgExecutor {
128
72
  if (injected !== undefined) return injected;
129
73
  throw new DriverUnavailableError({
130
74
  driver: 'pg',
131
- cause: 'no PgExecutor was provided and Bun.sql is not configured',
132
- fix: 'set DATABASE_URL in .env then run `x db up`, or use driver: "memory" in app.config.ts',
75
+ // `Bun.sql` is named nowhere in this function and never was: there is no ambient fallback to
76
+ // be "not configured". An executor is injected by the boot or the driver has none.
77
+ cause: 'createPgDriver() was called with no executor, and this driver has no ambient fallback',
78
+ fix: 'set DATABASE_URL in .env so the boot builds one — x db migrate then x dev — or hand this process a queue with no database: setJobDriver(createMemoryDriver())',
133
79
  });
134
80
  }
135
81
 
@@ -171,6 +117,68 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
171
117
  };
172
118
  }
173
119
 
120
+ function pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
121
+ return {
122
+ async start(run) {
123
+ await exec().query(SQL_BACKFILL_START, [run.runId, run.name, run.checksum, run.appVersion]);
124
+ },
125
+ async progress(runId, at) {
126
+ await exec().query(SQL_BACKFILL_PROGRESS, [runId, at.rows, at.cursor]);
127
+ },
128
+ async finish(runId, at) {
129
+ await exec().query(SQL_BACKFILL_FINISH, [runId, at.status, at.rows]);
130
+ },
131
+ async list(filter = {}) {
132
+ const rows = await exec().query<BackfillRow>(SQL_BACKFILL_LIST, [
133
+ filter.name ?? null,
134
+ filter.status ?? null,
135
+ filter.runId ?? null,
136
+ filter.limit ?? 100,
137
+ ]);
138
+ return rows.map(toBackfillRun);
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Fleet-wide slots over `x_job_leases`. Every decision is ONE statement — the `(lease_key, slot)`
145
+ * primary key is what serialises two workers, so nothing here reads a count and then acts on it.
146
+ */
147
+ function pgLeaseStore(exec: () => PgExecutor): LeaseStore {
148
+ return {
149
+ async acquire(key, limit, ttlMs, holder) {
150
+ if (limit <= 0) return undefined;
151
+ const rows = await exec().query<{ slot: number | string }>(SQL_LEASE_ACQUIRE, [
152
+ key,
153
+ holder,
154
+ limit,
155
+ ttlMs,
156
+ ]);
157
+ const row = rows[0];
158
+ return row === undefined ? undefined : { key, slot: Number(row.slot), holder };
159
+ },
160
+ async renew(lease, ttlMs) {
161
+ const rows = await exec().query<{ slot: number | string }>(SQL_LEASE_RENEW, [
162
+ lease.key,
163
+ lease.slot,
164
+ lease.holder,
165
+ ttlMs,
166
+ ]);
167
+ return rows.length > 0;
168
+ },
169
+ async release(lease: HeldLease) {
170
+ await exec().query(SQL_LEASE_RELEASE, [lease.key, lease.slot, lease.holder]);
171
+ },
172
+ async held(key) {
173
+ const rows = await exec().query<{ n: number | string }>(
174
+ `select count(*)::int as n from x_job_leases where lease_key = $1 and expires_at > now()`,
175
+ [key],
176
+ );
177
+ return num(rows[0]?.n);
178
+ },
179
+ };
180
+ }
181
+
174
182
  export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
175
183
  const clock = options.clock ?? systemClock;
176
184
  let executor: PgExecutor | undefined;
@@ -224,16 +232,23 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
224
232
  throw new DriverUnavailableError({
225
233
  driver: 'pg',
226
234
  cause: `job ${jobId} does not exist`,
227
- fix: 'x jobs list --state dead --json',
235
+ fix: 'x jobs ls --state dead --json',
228
236
  });
229
237
  }
230
238
  return toJobRecord(row);
231
239
  },
240
+ async cancel(jobId, reason) {
241
+ const rows = await exec().query<JobRow>(SQL_CANCEL, [jobId, reason ?? null]);
242
+ const row = rows[0];
243
+ return row === undefined ? undefined : toJobRecord(row);
244
+ },
232
245
  };
233
246
 
234
247
  return {
235
248
  name: 'pg',
236
249
  steps: pgStepStore(exec),
250
+ backfills: pgBackfillLedger(exec),
251
+ leases: pgLeaseStore(exec),
237
252
  introspect,
238
253
 
239
254
  async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
@@ -248,14 +263,19 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
248
263
  request.maxAttempts,
249
264
  runAt,
250
265
  request.tenantId ?? null,
266
+ request.traceparent ?? null,
267
+ request.enqueuedBy ?? null,
251
268
  ]);
252
269
  const inserted = rows[0];
253
270
  if (inserted !== undefined) {
254
271
  return { id: inserted.id, runId: inserted.run_id, deduped: false };
255
272
  }
256
273
 
257
- // `do nothing` fired: a live job already owns this idempotency key.
274
+ // `do nothing` fired: a live job OF THIS NAME already owns this idempotency key. The name
275
+ // is in the lookup because it is in the index — without it this returned whichever other
276
+ // job happened to derive the same natural key, and the caller's work silently never ran.
258
277
  const existing = await exec().query<{ id: string; run_id: string }>(SQL_FIND_LIVE_BY_KEY, [
278
+ request.name,
259
279
  request.idempotencyKey,
260
280
  ]);
261
281
  const found = existing[0];
@@ -263,7 +283,7 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
263
283
  throw new DriverUnavailableError({
264
284
  driver: 'pg',
265
285
  cause: `enqueue of "${request.name}" was rejected but no live row holds its idempotency key`,
266
- fix: 'x db check the x_jobs_idempotency_live_idx index is missing or stale',
286
+ fix: 'x db migrate # reapplies SQL_JOBS_TABLE, whose x_jobs_name_idempotency_live_idx is what this lookup reads',
267
287
  });
268
288
  }
269
289
  if (request.onConflict === 'error') {
@@ -311,8 +331,15 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
311
331
  ]);
312
332
  },
313
333
 
314
- async heartbeat(jobId: string, heartbeatOptions): Promise<void> {
315
- await exec().query(SQL_HEARTBEAT, [jobId, heartbeatOptions.visibilityTimeoutMs]);
334
+ async heartbeat(jobId: string, heartbeatOptions: HeartbeatOptions): Promise<boolean> {
335
+ const rows = await exec().query<{ id: string }>(SQL_HEARTBEAT, [
336
+ jobId,
337
+ heartbeatOptions.visibilityTimeoutMs,
338
+ heartbeatOptions.workerId ?? null,
339
+ ]);
340
+ // No row means the job is no longer ours: cancelled from outside, or re-claimed after this
341
+ // lease lapsed. Either way the caller has to stop running it.
342
+ return rows.length > 0;
316
343
  },
317
344
 
318
345
  async stats(): Promise<readonly QueueStats[]> {
@@ -338,18 +365,37 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
338
365
  };
339
366
  }
340
367
 
341
- /** Advisory-lock leader election, used by the `scheduler` role. */
368
+ /**
369
+ * Advisory-lock leader election. **Correct only on a DEDICATED connection.**
370
+ *
371
+ * `pg_try_advisory_lock` takes a SESSION-level lock, and a session is a connection: hand this a
372
+ * pooled executor and the lock is released the instant that connection goes back to the pool, so
373
+ * every node reads itself as leader and a rolling restart double-fires every task. Postgres also
374
+ * refcounts the lock per acquisition, so a second `acquire()` on a session that already holds the
375
+ * key would need a second `release()` — hence the `held` guard below, which makes repeated
376
+ * `acquire()` calls (the scheduler renews every round) a no-op rather than a leak.
377
+ *
378
+ * `@ultimat3/realtime`'s `PgAdvisoryLock` is the shape that gets this right: it opens a connection
379
+ * of its own and *is* the lock. This package holds no wire protocol, so the executor it is handed
380
+ * is whatever boot built — which is a pool. **Use `createPgLeaseLeader` instead** unless you can
381
+ * prove the executor is a single dedicated session.
382
+ */
342
383
  export function createPgLeader(
343
384
  lockKey: number,
344
385
  options: PgDriverOptions = {},
345
386
  ): { acquire(): Promise<boolean>; release(): Promise<void> } {
346
387
  const exec = (): PgExecutor => resolveExecutor(options.executor);
388
+ let held = false;
347
389
  return {
348
390
  async acquire() {
391
+ if (held) return true;
349
392
  const rows = await exec().query<{ locked: boolean }>(SQL_TRY_ADVISORY_LOCK, [lockKey]);
350
- return rows[0]?.locked === true;
393
+ held = rows[0]?.locked === true;
394
+ return held;
351
395
  },
352
396
  async release() {
397
+ if (!held) return;
398
+ held = false;
353
399
  await exec().query(SQL_ADVISORY_UNLOCK, [lockKey]);
354
400
  },
355
401
  };
@@ -11,6 +11,7 @@ import type {
11
11
  ClaimOptions,
12
12
  EnqueueRequest,
13
13
  EnqueueResult,
14
+ HeartbeatOptions,
14
15
  JobDriver,
15
16
  NackOptions,
16
17
  QueueStats,
@@ -66,7 +67,7 @@ export function createRedisDriver(_options: RedisDriverOptions = {}): JobDriver
66
67
  nack(_jobId: string, _options: NackOptions): Promise<void> {
67
68
  return unavailable('nack');
68
69
  },
69
- heartbeat(_jobId: string, _options: { readonly visibilityTimeoutMs: number }): Promise<void> {
70
+ heartbeat(_jobId: string, _options: HeartbeatOptions): Promise<boolean> {
70
71
  return unavailable('heartbeat');
71
72
  },
72
73
  stats(): Promise<readonly QueueStats[]> {
package/src/driver.ts CHANGED
@@ -1,10 +1,31 @@
1
- // The queue contract. Every driver (pg, memory, redis, nats) implements exactly this, so
2
- // switching backends is a config line and ZERO job-code change. Six methods and no more:
3
- // claim/ack/nack with a visibility timeout is the smallest set that survives a worker crash.
4
-
1
+ // The queue contract. Every driver implements exactly this, so a job's code never names one.
2
+ // Six methods and no more: claim/ack/nack with a visibility timeout is the smallest set that
3
+ // survives a worker crash.
4
+ //
5
+ // This header used to say "switching backends is a config line" (`As of 2026-08`). There is no
6
+ // such config line: `JobsConfig.driver` has no reader anywhere and boot always builds
7
+ // `createPgDriver`. `pg` and `memory` are the two that exist; `redis` and `nats` are honest
8
+ // `X_NOT_IMPLEMENTED` stubs. What IS true is the second half — swapping the driver is
9
+ // `setJobDriver(other)` and ZERO job-code change — and that is what the interface buys.
10
+
11
+ import type { BackfillLedger } from './backfill-ledger';
12
+ import type { LeaseStore } from './leases';
5
13
  import type { StepStore } from './steps';
6
14
 
7
- export type JobState = 'ready' | 'delayed' | 'running' | 'suspended' | 'done' | 'failed' | 'dead';
15
+ /**
16
+ * `cancelled` is terminal and is NOT `dead`: a dead letter is work that failed and can be retried,
17
+ * a cancellation is work an operator stopped on purpose and `x jobs retry` must not resurrect by
18
+ * accident. It appears in no claim predicate, so the queue never hands a cancelled row out again.
19
+ */
20
+ export type JobState =
21
+ | 'ready'
22
+ | 'delayed'
23
+ | 'running'
24
+ | 'suspended'
25
+ | 'done'
26
+ | 'failed'
27
+ | 'dead'
28
+ | 'cancelled';
8
29
 
9
30
  export interface JobRecord {
10
31
  readonly id: string;
@@ -27,6 +48,14 @@ export interface JobRecord {
27
48
  readonly lastError?: string;
28
49
  readonly claimedBy?: string;
29
50
  readonly visibleAt?: number;
51
+ /**
52
+ * W3C `traceparent` of the request that queued this job. The job's span is opened as a CHILD of
53
+ * it, so a checkout trace shows the HTTP span, the action span and the charge that ran two
54
+ * seconds later as one trace rather than three unrelated roots.
55
+ */
56
+ readonly traceparent?: string;
57
+ /** Actor id of whoever asked for this work. AUDIT ONLY — see `EnqueueRequest.enqueuedBy`. */
58
+ readonly enqueuedBy?: string;
30
59
  }
31
60
 
32
61
  export type ConflictPolicy = 'dedupe' | 'error';
@@ -43,6 +72,21 @@ export interface EnqueueRequest {
43
72
  /** Reuse an existing run id when resuming, so step history is preserved. */
44
73
  readonly runId?: string;
45
74
  readonly onConflict?: ConflictPolicy;
75
+ /** W3C `traceparent` of the enqueuing request. The facade stamps it; callers rarely set it. */
76
+ readonly traceparent?: string;
77
+ /**
78
+ * Who asked for this work — an actor id, ATTRIBUTION AND NOT AUTHORITY.
79
+ *
80
+ * The framework picks one answer to "whose permissions does a job run with" and this is it: a
81
+ * job body runs with SYSTEM authority and this column is an audit trail. The alternative —
82
+ * resolving the enqueuer at claim time and impersonating them — is worse in exactly the case
83
+ * that matters: a job that sleeps three days, or dead-letters and is retried next quarter, would
84
+ * act as somebody whose role, org membership or employment has since changed. `02-primitives.md`
85
+ * already calls a job server-authoritative work; this makes the row say so too. A job that must
86
+ * act for a user takes that user's id in its INPUT and re-authorises it in the body, where the
87
+ * check is visible.
88
+ */
89
+ readonly enqueuedBy?: string;
46
90
  }
47
91
 
48
92
  export interface EnqueueResult {
@@ -102,6 +146,22 @@ export interface JobIntrospection {
102
146
  deadLetters(limit?: number): Promise<readonly JobRecord[]>;
103
147
  /** Re-queue a dead/failed job. `fromStep` drops step records from that step onward. */
104
148
  requeue(jobId: string, options?: { readonly fromStep?: string }): Promise<JobRecord>;
149
+ /**
150
+ * Stop a job from outside. The only answer to a runaway pass that was otherwise "scale the
151
+ * worker to zero" (which stops every job) or a hand-written `UPDATE` (which the running
152
+ * worker's next ack overwrote). Terminal for a queued row immediately; a RUNNING one stops at
153
+ * its next heartbeat, which no longer matches its own row and cancels the attempt.
154
+ *
155
+ * Optional on the interface for the reason `requeue` is not: a driver may have no way to
156
+ * address a single row. Answers `undefined` for a job id it does not hold.
157
+ */
158
+ cancel?(jobId: string, reason?: string): Promise<JobRecord | undefined>;
159
+ }
160
+
161
+ export interface HeartbeatOptions {
162
+ readonly visibilityTimeoutMs: number;
163
+ /** Renew only if this worker is still the claimant. Omit and any claimant matches. */
164
+ readonly workerId?: string;
105
165
  }
106
166
 
107
167
  export interface JobDriver {
@@ -112,9 +172,33 @@ export interface JobDriver {
112
172
  claim(options: ClaimOptions): Promise<readonly ClaimedJob[]>;
113
173
  ack(jobId: string): Promise<void>;
114
174
  nack(jobId: string, options: NackOptions): Promise<void>;
115
- /** Extends the lease of a long-running job so it is not double-claimed. */
116
- heartbeat(jobId: string, options: { readonly visibilityTimeoutMs: number }): Promise<void>;
175
+ /**
176
+ * Extends the lease of a long-running job so it is not double-claimed.
177
+ *
178
+ * Answers whether the renewal LANDED. `false` means this process no longer owns the job — it
179
+ * was cancelled, or its lease lapsed and another worker re-claimed it — and the caller must
180
+ * stop running it. A `void` return made both indistinguishable from success, so an external
181
+ * cancel had nothing to reach a running job with.
182
+ */
183
+ heartbeat(jobId: string, options: HeartbeatOptions): Promise<boolean>;
117
184
  stats(): Promise<readonly QueueStats[]>;
185
+ /**
186
+ * Optional, like `introspect`: `x_backfills` records what a `backfill()` pass has already swept,
187
+ * and a driver without one runs backfills with no bookkeeping rather than refusing them. It
188
+ * hangs here for the same reason `steps` does — durable state that ships in the queue's own DDL,
189
+ * so one install point covers both.
190
+ */
191
+ readonly backfills?: BackfillLedger;
192
+ /**
193
+ * Optional, like `introspect` and `backfills`: fleet-wide slot counting, which is the only thing
194
+ * that can make `job.concurrency` mean what its docstring says. The in-process limiter is a fast
195
+ * path over ONE heap and is multiplied by the replica count; this is the gate that is not.
196
+ * A driver without one can only hold the cap per process, so `createWorker().start()` THROWS
197
+ * `X_JOB_CONCURRENCY_UNENFORCEABLE` (`worker.ts`) naming every registered job that declared
198
+ * `concurrency` — refused rather than logged, because a documented guarantee that silently does
199
+ * nothing is worse than either alternative.
200
+ */
201
+ readonly leases?: LeaseStore;
118
202
  readonly introspect?: JobIntrospection;
119
203
  close?(): Promise<void>;
120
204
  }