@ultimat3/jobs 30.9.0 → 31.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/CLAUDE.md +72 -107
- package/README.md +202 -55
- package/package.json +8 -7
- package/src/adapter.ts +37 -0
- package/src/backfill-ledger.ts +2 -2
- package/src/backfill-rate.ts +1 -0
- package/src/clock.ts +2 -2
- package/src/drain-wait.ts +1 -0
- package/src/driver-memory.ts +2 -2
- package/src/driver-pg-ddl.ts +19 -0
- package/src/driver-pg-event-wake-sql.ts +43 -22
- package/src/driver-pg-sql.ts +68 -27
- package/src/driver-pg.ts +11 -10
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +14 -8
- package/src/error-codes.ts +27 -0
- package/src/errors-concurrency.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/events-cursor.ts +31 -0
- package/src/events-pg.ts +52 -6
- package/src/events.ts +72 -36
- package/src/export.ts +2 -2
- package/src/gdpr-audit.ts +75 -0
- package/src/gdpr-coverage.ts +94 -0
- package/src/gdpr-errors.ts +35 -0
- package/src/import.ts +2 -2
- package/src/index.ts +45 -206
- package/src/inspect-operator.ts +2 -2
- package/src/inspect.ts +1 -1
- package/src/job-instrument.ts +115 -0
- package/src/job.ts +41 -4
- package/src/jobs-retention.ts +23 -6
- package/src/leases.ts +13 -3
- package/src/ledger-book.ts +168 -0
- package/src/ledger-errors.ts +48 -0
- package/src/ledger.ts +145 -0
- package/src/limits.ts +57 -19
- package/src/lock-errors.ts +54 -0
- package/src/lock.ts +176 -0
- package/src/outbox-relay.ts +4 -0
- package/src/outbox.ts +3 -3
- package/src/partitions.ts +119 -0
- package/src/reindex-sweep.ts +94 -0
- package/src/reindex.ts +147 -0
- package/src/retry-classification.ts +3 -2
- package/src/retry-jitter.ts +32 -0
- package/src/retry.ts +13 -16
- package/src/scheduler.ts +10 -6
- package/src/steps-api.ts +44 -5
- package/src/steps-suspension.ts +34 -3
- package/src/steps-wait.ts +52 -4
- package/src/steps.ts +1 -0
- package/src/subject-jobs.ts +12 -4
- package/src/webhook.ts +2 -2
- package/src/worker-admit.ts +8 -5
- package/src/worker-drain-cutoff.ts +1 -0
- package/src/worker-loop.ts +28 -5
- package/src/worker-registry.ts +7 -5
- package/src/worker-run.ts +53 -9
- package/src/worker.ts +8 -8
package/src/jobs-retention.ts
CHANGED
|
@@ -32,22 +32,39 @@ import {
|
|
|
32
32
|
* to that one — so each statement alone would keep the steps forever. `SQL_JOBS_RETENTION_STEPS`
|
|
33
33
|
* settles a held run after this commits.
|
|
34
34
|
*
|
|
35
|
+
* Each window is its OWN branch, seeking its own index in `updated_at` order and stopping at its
|
|
36
|
+
* cutoff: one walk over all four states with a window per state stepped, every batch, over each
|
|
37
|
+
* `dead` row inside its 90 days that sat between the `done` rows past their 14 — 32–40 ms a batch
|
|
38
|
+
* in production. The two answers are merged, oldest first, to one batch; a row a branch locked
|
|
39
|
+
* past the batch's `limit` is released at commit, untouched.
|
|
40
|
+
*
|
|
35
41
|
* `held` is a LATERAL probe with a `limit`, not an `exists`: the planner flattens an `exists` into
|
|
36
42
|
* a semi-join and, at a 1,000-row batch, hashes the whole of `x_jobs` for it; the lateral form is one
|
|
37
43
|
* `x_jobs_run_idx` lookup per deleted run (EXPLAIN, 63,000 rows, 2026-10-17).
|
|
38
44
|
*/
|
|
39
45
|
export const SQL_JOBS_RETENTION_BATCH = `
|
|
40
|
-
with
|
|
41
|
-
select j.id, j.run_id from x_jobs j
|
|
42
|
-
where
|
|
43
|
-
|
|
44
|
-
or (j.state in ('failed', 'dead', 'cancelled')
|
|
45
|
-
and j.updated_at < statement_timestamp() - ($2::bigint * interval '1 millisecond')))
|
|
46
|
+
with done_rows as (
|
|
47
|
+
select j.id, j.run_id, j.updated_at from x_jobs j
|
|
48
|
+
where j.state = 'done'
|
|
49
|
+
and j.updated_at < statement_timestamp() - ($1::bigint * interval '1 millisecond')
|
|
46
50
|
and not exists (select 1 from x_outbox o where o.id = j.id and o.published_at is null)
|
|
47
51
|
and not exists (select 1 from x_backfills b where b.run_id = j.run_id and b.status = 'running')
|
|
48
52
|
order by j.updated_at
|
|
49
53
|
limit $3
|
|
50
54
|
for update of j skip locked
|
|
55
|
+
), ended_rows as (
|
|
56
|
+
select j.id, j.run_id, j.updated_at from x_jobs j
|
|
57
|
+
where j.state in ('failed', 'dead', 'cancelled')
|
|
58
|
+
and j.updated_at < statement_timestamp() - ($2::bigint * interval '1 millisecond')
|
|
59
|
+
and not exists (select 1 from x_outbox o where o.id = j.id and o.published_at is null)
|
|
60
|
+
and not exists (select 1 from x_backfills b where b.run_id = j.run_id and b.status = 'running')
|
|
61
|
+
order by j.updated_at
|
|
62
|
+
limit $3
|
|
63
|
+
for update of j skip locked
|
|
64
|
+
), doomed as (
|
|
65
|
+
select id, run_id from (select * from done_rows union all select * from ended_rows) d
|
|
66
|
+
order by updated_at
|
|
67
|
+
limit $3
|
|
51
68
|
), gone as (
|
|
52
69
|
delete from x_jobs where id in (select id from doomed) returning run_id
|
|
53
70
|
), held as (
|
package/src/leases.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// gives its slot back by expiry rather than by cleanup nobody runs.
|
|
6
6
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { liveClock } from '@ultimat3/core';
|
|
9
9
|
import { nowMs } from './clock';
|
|
10
10
|
|
|
11
11
|
/** A granted slot. `slot` plus `holder` is what renew and release are addressed by. */
|
|
@@ -13,6 +13,13 @@ export interface HeldLease {
|
|
|
13
13
|
readonly key: string;
|
|
14
14
|
readonly slot: number;
|
|
15
15
|
readonly holder: string;
|
|
16
|
+
/**
|
|
17
|
+
* The grant's FENCING number, assigned by the store on every acquire (a takeover too) and
|
|
18
|
+
* greater than every one it assigned before — `withLock`'s token. The store's counter and never
|
|
19
|
+
* a client clock: a host whose clock runs fast would otherwise win "refuse a smaller token".
|
|
20
|
+
* Optional so a store written before it still holds `job.concurrency`; `withLock` refuses one.
|
|
21
|
+
*/
|
|
22
|
+
readonly fence?: number;
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
export interface LeaseStore {
|
|
@@ -63,9 +70,11 @@ export const LEASE_SWEEP_INTERVAL_MS = 60_000;
|
|
|
63
70
|
* "a second worker is refused" test is a real test rather than a pg-only one.
|
|
64
71
|
*/
|
|
65
72
|
export function memoryLeaseStore(options: MemoryLeaseStoreOptions = {}): MemoryLeaseStore {
|
|
66
|
-
const clock = options.clock ??
|
|
73
|
+
const clock = options.clock ?? liveClock;
|
|
67
74
|
type Slots = Map<number, { holder: string; expiresAt: number }>;
|
|
68
75
|
const slots = new Map<string, Slots>();
|
|
76
|
+
// One counter per store, as the pg store's one sequence: increasing across every key.
|
|
77
|
+
let fences = 0;
|
|
69
78
|
|
|
70
79
|
/**
|
|
71
80
|
* The live slots of `key`, expired ones dropped — and the KEY dropped with its last slot. A
|
|
@@ -101,7 +110,8 @@ export function memoryLeaseStore(options: MemoryLeaseStoreOptions = {}): MemoryL
|
|
|
101
110
|
if (held.has(slot)) continue;
|
|
102
111
|
held.set(slot, { holder, expiresAt: nowMs(clock) + ttlMs });
|
|
103
112
|
slots.set(key, held);
|
|
104
|
-
|
|
113
|
+
fences += 1;
|
|
114
|
+
return Promise.resolve({ key, slot, holder, fence: fences });
|
|
105
115
|
}
|
|
106
116
|
return Promise.resolve(undefined);
|
|
107
117
|
},
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Where `ledger()` writes: the `LedgerBook` seam, the memory book a test reads, and the book over an
|
|
2
|
+
// app's own entity. The TABLE is the app's (axiom 8: its accounts, its retention, its tenancy); the
|
|
3
|
+
// mechanism is one idempotent statement keyed by `(postingKey, line)`, so a replay writes nothing.
|
|
4
|
+
|
|
5
|
+
import type { Driver, EntityCore, RowWrite } from '@ultimat3/entity';
|
|
6
|
+
import type { MoneyValue } from '@ultimat3/schema';
|
|
7
|
+
import { LedgerEntryInvalidError, LedgerPostingConflictError } from './ledger-errors';
|
|
8
|
+
|
|
9
|
+
/** One stored leg. `(tenant, postingKey, line)` is unique: the identity a replay lands on. */
|
|
10
|
+
export interface LedgerLine {
|
|
11
|
+
/** The posting's `orgId`: a key names one posting PER TENANT. Absent for an untenanted book. */
|
|
12
|
+
readonly tenant?: string | undefined;
|
|
13
|
+
readonly postingKey: string;
|
|
14
|
+
readonly line: number;
|
|
15
|
+
readonly account: string;
|
|
16
|
+
readonly amount: MoneyValue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LedgerBook {
|
|
20
|
+
/**
|
|
21
|
+
* Store one posting's lines, all or none, and answer how many THIS call wrote: every line, or
|
|
22
|
+
* `0` when the key is already stored with exactly these lines. A key stored with any other lines
|
|
23
|
+
* — another count, another line — is `X_LEDGER_POSTING_CONFLICT`, and nothing is written: the
|
|
24
|
+
* new lines beside the stored ones would unbalance the book. Atomic: never half a posting.
|
|
25
|
+
*/
|
|
26
|
+
write(lines: readonly LedgerLine[]): Promise<number>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sameAmount = (a: MoneyValue, b: MoneyValue): boolean =>
|
|
30
|
+
a.minor === b.minor && a.currency === b.currency && (a.scale ?? null) === (b.scale ?? null);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Refuse unless `stored` (the key's lines in the book) are exactly `incoming`. `fresh` is how many
|
|
34
|
+
* of `incoming` this write itself inserted: all of them is a new posting, none a replay — any
|
|
35
|
+
* other number means part of the key was already there, which is a conflict whatever it now reads.
|
|
36
|
+
*/
|
|
37
|
+
export function assertSamePosting(
|
|
38
|
+
incoming: readonly LedgerLine[],
|
|
39
|
+
stored: readonly Pick<LedgerLine, 'line' | 'account' | 'amount'>[],
|
|
40
|
+
fresh: number,
|
|
41
|
+
): void {
|
|
42
|
+
const key = incoming[0]?.postingKey ?? '';
|
|
43
|
+
const conflict = (line: number | null): never => {
|
|
44
|
+
throw new LedgerPostingConflictError({
|
|
45
|
+
key,
|
|
46
|
+
stored: stored.length - fresh,
|
|
47
|
+
incoming: incoming.length,
|
|
48
|
+
line,
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
if ((fresh !== 0 && fresh !== incoming.length) || stored.length !== incoming.length) {
|
|
52
|
+
conflict(null);
|
|
53
|
+
}
|
|
54
|
+
const byLine = new Map(stored.map((row) => [row.line, row]));
|
|
55
|
+
for (const line of incoming) {
|
|
56
|
+
const held = byLine.get(line.line);
|
|
57
|
+
if (
|
|
58
|
+
held === undefined ||
|
|
59
|
+
held.account !== line.account ||
|
|
60
|
+
!sameAmount(held.amount, line.amount)
|
|
61
|
+
) {
|
|
62
|
+
conflict(line.line);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The memory book, plus what a test reads back. */
|
|
68
|
+
export interface MemoryLedgerBook extends LedgerBook {
|
|
69
|
+
lines(): readonly LedgerLine[];
|
|
70
|
+
/** One account's balance in one currency, in minor units. */
|
|
71
|
+
balance(account: string, currency: string): number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const lineKey = (line: Pick<LedgerLine, 'tenant' | 'postingKey' | 'line'>): string =>
|
|
75
|
+
`${line.tenant ?? ''}\u0000${line.postingKey}\u0000${line.line}`;
|
|
76
|
+
|
|
77
|
+
/** Which posting a line belongs to: one per (tenant, key). A posting id, not a credential. */
|
|
78
|
+
const postingOf = (line: Pick<LedgerLine, 'tenant' | 'postingKey'>): string =>
|
|
79
|
+
`${line.tenant ?? ''}\u0000${line.postingKey}`;
|
|
80
|
+
|
|
81
|
+
export function memoryLedgerBook(): MemoryLedgerBook {
|
|
82
|
+
const stored = new Map<string, LedgerLine>();
|
|
83
|
+
return {
|
|
84
|
+
async write(lines) {
|
|
85
|
+
const first = lines[0];
|
|
86
|
+
const posting = first === undefined ? undefined : postingOf(first);
|
|
87
|
+
const held = [...stored.values()].filter((line) => postingOf(line) === posting);
|
|
88
|
+
// Judged before any write: a refused posting leaves the book as it was.
|
|
89
|
+
if (held.length > 0) {
|
|
90
|
+
assertSamePosting(lines, held, 0);
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
for (const line of lines) stored.set(lineKey(line), line);
|
|
94
|
+
return lines.length;
|
|
95
|
+
},
|
|
96
|
+
lines: () => [...stored.values()],
|
|
97
|
+
balance: (account, currency) =>
|
|
98
|
+
[...stored.values()]
|
|
99
|
+
.filter((line) => line.account === account && line.amount.currency === currency)
|
|
100
|
+
.reduce((net, line) => net + line.amount.minor, 0),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EntityLedgerBookOptions<Row extends LedgerLine> {
|
|
105
|
+
/** The driver the app's `database()` reads through, so tenancy and row observers hold. */
|
|
106
|
+
readonly driver: Driver;
|
|
107
|
+
/**
|
|
108
|
+
* The app's lines entity: `postingKey` text, `line` integer, `account` text, `amount` money, a
|
|
109
|
+
* UNIQUE index (or the primary key) on `(postingKey, line)` — on `(<tenant>, postingKey, line)`
|
|
110
|
+
* when the entity has a tenant column, which the posting's `orgId` fills — and whatever else it
|
|
111
|
+
* keeps defaulted. Without that unique Postgres refuses the statement (`42P10`).
|
|
112
|
+
*/
|
|
113
|
+
readonly entity: EntityCore<Row>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The book over an app entity: in ONE transaction, `insert … on conflict do nothing`, then the
|
|
118
|
+
* key's stored lines read back and judged (`assertSamePosting`) — a refusal rolls this write's
|
|
119
|
+
* lines back, and a concurrent write of the same key with other lines waits on the first's rows,
|
|
120
|
+
* then meets them committed and is refused.
|
|
121
|
+
*/
|
|
122
|
+
export function entityLedgerBook<Row extends LedgerLine>(
|
|
123
|
+
options: EntityLedgerBookOptions<Row>,
|
|
124
|
+
): LedgerBook {
|
|
125
|
+
return {
|
|
126
|
+
write(lines) {
|
|
127
|
+
const tenant = options.entity.$tenantColumn;
|
|
128
|
+
const first = lines[0];
|
|
129
|
+
// A tenanted book holds one posting per (tenant, key): the org is the row's, never inferred.
|
|
130
|
+
if (tenant !== null && (first === undefined || first.tenant === undefined)) {
|
|
131
|
+
throw new LedgerEntryInvalidError({
|
|
132
|
+
key: first?.postingKey ?? '',
|
|
133
|
+
index: null,
|
|
134
|
+
field: 'orgId',
|
|
135
|
+
got: first?.tenant,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const scope: Record<string, unknown> = tenant === null ? {} : { [tenant]: first?.tenant };
|
|
139
|
+
// The lines ARE rows of `Row` minus its defaulted columns, which the statement fills.
|
|
140
|
+
const rows = lines.map(({ tenant: _, ...line }) => ({
|
|
141
|
+
...scope,
|
|
142
|
+
...line,
|
|
143
|
+
})) as unknown as readonly RowWrite<Row>[];
|
|
144
|
+
const target = (
|
|
145
|
+
tenant === null ? ['postingKey', 'line'] : [tenant, 'postingKey', 'line']
|
|
146
|
+
) as (keyof Row & string)[];
|
|
147
|
+
const repo = options.driver.repo(options.entity);
|
|
148
|
+
return options.driver.transactor().run(async (tx) => {
|
|
149
|
+
const written = await repo.upsertAll(rows, { onConflict: target, onMatch: 'nothing', tx });
|
|
150
|
+
const held = await repo.findMany({
|
|
151
|
+
// The tenant named, not left to the run's scope: outside a job no scope exists, and an
|
|
152
|
+
// unscoped tenanted read is refused (`X_TENANCY_UNSCOPED`).
|
|
153
|
+
where: [
|
|
154
|
+
{ column: 'postingKey', op: 'eq', value: first?.postingKey },
|
|
155
|
+
...(tenant === null
|
|
156
|
+
? []
|
|
157
|
+
: [{ column: tenant, op: 'eq' as const, value: first?.tenant }]),
|
|
158
|
+
],
|
|
159
|
+
// One past what a match holds: a longer stored posting is seen as longer, never cut.
|
|
160
|
+
limit: lines.length + 1,
|
|
161
|
+
tx,
|
|
162
|
+
});
|
|
163
|
+
assertSamePosting(lines, held.rows, written.length);
|
|
164
|
+
return written.length;
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The two `X_LEDGER_*` refusals a posting can end on — both about the POSTING, raised before a line
|
|
2
|
+
// is written, so a refused posting leaves the book exactly as it was. Amounts and accounts are the
|
|
3
|
+
// app's own strings and integers; nothing `unknown` is interpolated (`describeValue` renders those).
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { describeValue } from '@ultimat3/schema';
|
|
7
|
+
import './error-codes';
|
|
8
|
+
|
|
9
|
+
/** One entry, or the posting's shape, is not something a balanced book can hold. */
|
|
10
|
+
export class LedgerEntryInvalidError extends UltimateError {
|
|
11
|
+
constructor(input: { key: string; index: number | null; field: string; got: unknown }) {
|
|
12
|
+
const where = input.index === null ? 'the posting' : `entries[${input.index}]`;
|
|
13
|
+
super({
|
|
14
|
+
code: 'X_LEDGER_ENTRY_INVALID',
|
|
15
|
+
cause: `ledger posting ${JSON.stringify(input.key)}: ${where}.${input.field} is ${describeValue(input.got)}`,
|
|
16
|
+
fix: `{ account: 'cash', amount: { minor: 1299, currency: 'EUR' } } # ${where} in the enqueue of the ledger job: a named account, integer minor units (fromDecimal('12.99', 'EUR') from @ultimat3/money), at least two entries`,
|
|
17
|
+
meta: { key: input.key, index: input.index, field: input.field },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The entries of one currency do not net to zero. `net` is that currency's sum, in minor units. */
|
|
23
|
+
export class LedgerUnbalancedError extends UltimateError {
|
|
24
|
+
constructor(input: { key: string; currency: string; net: number }) {
|
|
25
|
+
super({
|
|
26
|
+
code: 'X_LEDGER_UNBALANCED',
|
|
27
|
+
cause: `ledger posting ${JSON.stringify(input.key)} nets ${input.net} minor units of ${input.currency}, not zero — every currency's entries must sum to zero`,
|
|
28
|
+
fix: `{ account: '<balancing account>', amount: { minor: ${-input.net}, currency: '${input.currency}' } } # one more entry in the posting's entries, or correct the amounts`,
|
|
29
|
+
meta: { key: input.key, currency: input.currency, net: input.net },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A key already stored with other lines: writing beside them would unbalance the book. */
|
|
35
|
+
export class LedgerPostingConflictError extends UltimateError {
|
|
36
|
+
constructor(input: { key: string; stored: number; incoming: number; line: number | null }) {
|
|
37
|
+
const differs =
|
|
38
|
+
input.line === null
|
|
39
|
+
? `holds ${input.stored} line(s) and this posting has ${input.incoming}`
|
|
40
|
+
: `holds another line ${input.line}`;
|
|
41
|
+
super({
|
|
42
|
+
code: 'X_LEDGER_POSTING_CONFLICT',
|
|
43
|
+
cause: `ledger posting ${JSON.stringify(input.key)} is already in the book and ${differs} — a key names one posting, and its lines never change`,
|
|
44
|
+
fix: `{ key: ${JSON.stringify(`${input.key}:adjust-1`)}, entries: [ … ] } # the posting in the enqueue of the ledger job: a correction under a new key, or the entries exactly as first posted`,
|
|
45
|
+
meta: { key: input.key, stored: input.stored, incoming: input.incoming, line: input.line },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// `ledger()`: double-entry postings as a FACTORY over `job()`, never a ninth primitive. A posting
|
|
2
|
+
// is balanced Money entries under one key; the job refuses an unbalanced or float posting before a
|
|
3
|
+
// line is written, then writes every line in ONE durable step through the app's `LedgerBook` —
|
|
4
|
+
// idempotent by `(key, line)`, so a retry, a replay or a second enqueue of one key posts it once.
|
|
5
|
+
|
|
6
|
+
import type { MoneyValue, StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
|
+
import { isCurrencyCode, isMoneyScale, t } from '@ultimat3/schema';
|
|
8
|
+
import type { DurationInput } from './clock';
|
|
9
|
+
import type { JobHandle } from './job';
|
|
10
|
+
import { job } from './job';
|
|
11
|
+
import type { LedgerBook, LedgerLine } from './ledger-book';
|
|
12
|
+
import { LedgerEntryInvalidError, LedgerUnbalancedError } from './ledger-errors';
|
|
13
|
+
import type { RetryPolicy } from './retry';
|
|
14
|
+
import { DEFAULT_RETRY } from './retry';
|
|
15
|
+
import type { JobTenant } from './tenant';
|
|
16
|
+
|
|
17
|
+
/** One leg: an account the app names and a signed amount. Debit or credit is the sign's, not ours. */
|
|
18
|
+
export interface LedgerEntry {
|
|
19
|
+
readonly account: string;
|
|
20
|
+
readonly amount: MoneyValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What the job is enqueued with. `key` is the posting's identity — a payment id, a transfer id. */
|
|
24
|
+
export interface LedgerPosting {
|
|
25
|
+
readonly key: string;
|
|
26
|
+
readonly entries: readonly LedgerEntry[];
|
|
27
|
+
/** The tenant the posting belongs to, read by `tenant: (p) => p.orgId ?? …` when the app has one. */
|
|
28
|
+
readonly orgId?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LedgerDefinition {
|
|
32
|
+
/** REQUIRED: a durable key — the queue row and the idempotency key carry it. */
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly tenant: JobTenant<LedgerPosting>;
|
|
35
|
+
/** Where lines land — a thunk, read per run (`entityLedgerBook`, or the app's own). */
|
|
36
|
+
readonly book: () => LedgerBook;
|
|
37
|
+
readonly queue?: string;
|
|
38
|
+
readonly retry?: RetryPolicy;
|
|
39
|
+
readonly timeout?: DurationInput;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What one run answers: the lines the posting has, and how many THIS run wrote. */
|
|
43
|
+
export interface LedgerReport {
|
|
44
|
+
readonly key: string;
|
|
45
|
+
readonly lines: number;
|
|
46
|
+
readonly written: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A posting's legs: at least two, since one entry can only balance at zero, which moves nothing. */
|
|
50
|
+
export const MIN_LEDGER_ENTRIES = 2;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every rule `assertBalancedPosting` can say without adding anything up: a key, at least
|
|
54
|
+
* `MIN_LEDGER_ENTRIES` legs, a named account, a Money amount. Stated in the schema so a sample an
|
|
55
|
+
* exercise pass or a client draws from it is a posting the run could accept — only the per-currency
|
|
56
|
+
* sum, which no schema can express, is left to the run.
|
|
57
|
+
*/
|
|
58
|
+
const postingSchema: StandardSchemaV1<unknown, LedgerPosting> = t.object({
|
|
59
|
+
key: t.string.min(1),
|
|
60
|
+
entries: t.array(t.object({ account: t.string.min(1), amount: t.money }), {
|
|
61
|
+
min: MIN_LEDGER_ENTRIES,
|
|
62
|
+
}),
|
|
63
|
+
orgId: t.string.optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function assertEntry(key: string, entry: LedgerEntry, index: number): void {
|
|
67
|
+
const refuse = (field: string, got: unknown): never => {
|
|
68
|
+
throw new LedgerEntryInvalidError({ key, index, field, got });
|
|
69
|
+
};
|
|
70
|
+
if (typeof entry.account !== 'string' || entry.account.trim() === '') {
|
|
71
|
+
refuse('account', entry.account);
|
|
72
|
+
}
|
|
73
|
+
const { minor, currency, scale } = entry.amount;
|
|
74
|
+
if (!Number.isSafeInteger(minor)) refuse('amount.minor', minor);
|
|
75
|
+
if (!isCurrencyCode(currency)) refuse('amount.currency', currency);
|
|
76
|
+
if (scale !== undefined && !isMoneyScale(scale)) refuse('amount.scale', scale);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Refuse a posting that is not balanced Money, before anything is written. Exported so the action
|
|
81
|
+
* that enqueues one refuses it at the request — the job refuses it again, since a queue row is input.
|
|
82
|
+
* Within one currency every entry carries the same `scale`: a sum across two scales is not a sum.
|
|
83
|
+
*/
|
|
84
|
+
export function assertBalancedPosting(posting: LedgerPosting): void {
|
|
85
|
+
const { key } = posting;
|
|
86
|
+
if (typeof key !== 'string' || key === '') {
|
|
87
|
+
throw new LedgerEntryInvalidError({ key: String(key), index: null, field: 'key', got: key });
|
|
88
|
+
}
|
|
89
|
+
if (posting.entries.length < MIN_LEDGER_ENTRIES) {
|
|
90
|
+
throw new LedgerEntryInvalidError({
|
|
91
|
+
key,
|
|
92
|
+
index: null,
|
|
93
|
+
field: 'entries.length',
|
|
94
|
+
got: posting.entries.length,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const sums = new Map<string, { net: number; scale: number | undefined }>();
|
|
98
|
+
posting.entries.forEach((entry, index) => {
|
|
99
|
+
assertEntry(key, entry, index);
|
|
100
|
+
const { minor, currency, scale } = entry.amount;
|
|
101
|
+
const seen = sums.get(currency);
|
|
102
|
+
if (seen !== undefined && seen.scale !== scale) {
|
|
103
|
+
throw new LedgerEntryInvalidError({ key, index, field: 'amount.scale', got: scale });
|
|
104
|
+
}
|
|
105
|
+
const net = (seen?.net ?? 0) + minor;
|
|
106
|
+
// Safe integers summed past 2^53 stop being exact: refused rather than rounded.
|
|
107
|
+
if (!Number.isSafeInteger(net)) {
|
|
108
|
+
throw new LedgerEntryInvalidError({ key, index, field: 'amount.minor', got: minor });
|
|
109
|
+
}
|
|
110
|
+
sums.set(currency, { net, scale });
|
|
111
|
+
});
|
|
112
|
+
for (const [currency, { net }] of [...sums].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
113
|
+
if (net !== 0) throw new LedgerUnbalancedError({ key, currency, net });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The posting as the lines a book stores: one per entry, numbered from 0 in the posting's order. */
|
|
118
|
+
export const ledgerLines = (posting: LedgerPosting): readonly LedgerLine[] =>
|
|
119
|
+
posting.entries.map((entry, line) => ({
|
|
120
|
+
...(posting.orgId === undefined ? {} : { tenant: posting.orgId }),
|
|
121
|
+
postingKey: posting.key,
|
|
122
|
+
line,
|
|
123
|
+
account: entry.account,
|
|
124
|
+
amount: entry.amount,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
export function ledger(definition: LedgerDefinition): JobHandle<LedgerPosting> {
|
|
128
|
+
return job<LedgerPosting>({
|
|
129
|
+
name: definition.name,
|
|
130
|
+
input: postingSchema,
|
|
131
|
+
tenant: definition.tenant,
|
|
132
|
+
// One live run per posting; the book's `(postingKey, line)` holds it after the run is gone.
|
|
133
|
+
idempotencyKey: (posting) => `${definition.name}:${posting.key}`,
|
|
134
|
+
retry: definition.retry ?? DEFAULT_RETRY,
|
|
135
|
+
...(definition.queue === undefined ? {} : { queue: definition.queue }),
|
|
136
|
+
...(definition.timeout === undefined ? {} : { timeout: definition.timeout }),
|
|
137
|
+
run: async ({ step, input }): Promise<LedgerReport> => {
|
|
138
|
+
assertBalancedPosting(input);
|
|
139
|
+
const lines = ledgerLines(input);
|
|
140
|
+
// ONE step: every line or none, and a replay answers the persisted count without a write.
|
|
141
|
+
const written = await step.run('post', () => definition.book().write(lines));
|
|
142
|
+
return { key: input.key, lines: lines.length, written };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
package/src/limits.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// what `job.concurrency` is enforced with.
|
|
13
13
|
|
|
14
14
|
import type { Clock } from '@ultimat3/core';
|
|
15
|
-
import { assert, finiteCount,
|
|
15
|
+
import { assert, finiteCount, liveClock } from '@ultimat3/core';
|
|
16
16
|
import { nowMs } from './clock';
|
|
17
17
|
|
|
18
18
|
export interface RateLimit {
|
|
@@ -114,6 +114,28 @@ const SWEEP_EVERY_MS = 60_000;
|
|
|
114
114
|
*/
|
|
115
115
|
export const DEFAULT_MAX_LIMIT_TENANTS = 20_000;
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* One tenant's rate window: start stamps in the order taken, the spent ones before `head`. A queue
|
|
119
|
+
* with a head index, so a stamp leaving is one increment and never a copy of the window.
|
|
120
|
+
*/
|
|
121
|
+
interface StartWindow {
|
|
122
|
+
stamps: number[];
|
|
123
|
+
head: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Stamps still in the window. */
|
|
127
|
+
const live = (window: StartWindow): number => window.stamps.length - window.head;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Drop the spent prefix once it is both large and most of the array: a copy amortised over at least
|
|
131
|
+
* as many acquires as it moves, so the window costs O(1) a claim and its memory stays O(live).
|
|
132
|
+
*/
|
|
133
|
+
const compact = (window: StartWindow): void => {
|
|
134
|
+
if (window.head < 1_024 || window.head * 2 < window.stamps.length) return;
|
|
135
|
+
window.stamps = window.stamps.slice(window.head);
|
|
136
|
+
window.head = 0;
|
|
137
|
+
};
|
|
138
|
+
|
|
117
139
|
/** Tenant key resolution. Structural on purpose: `jobs` must not import the auth types. */
|
|
118
140
|
export function tenantKeyFrom(actor: { readonly orgId?: string } | undefined): string {
|
|
119
141
|
return actor?.orgId ?? NO_TENANT;
|
|
@@ -121,7 +143,7 @@ export function tenantKeyFrom(actor: { readonly orgId?: string } | undefined): s
|
|
|
121
143
|
|
|
122
144
|
export function concurrencyLimiter(
|
|
123
145
|
config: LimitConfig,
|
|
124
|
-
clock: Clock =
|
|
146
|
+
clock: Clock = liveClock,
|
|
125
147
|
options: { readonly maxTenants?: number | undefined } = {},
|
|
126
148
|
): Limiter {
|
|
127
149
|
const requested = options.maxTenants ?? DEFAULT_MAX_LIMIT_TENANTS;
|
|
@@ -169,7 +191,7 @@ export function concurrencyLimiter(
|
|
|
169
191
|
// `inFlight` had nothing to answer it from and silently widened to "this tenant, everywhere".
|
|
170
192
|
// Deleted at zero by `bump`, exactly like the two above, so it is bounded by live leases.
|
|
171
193
|
const byQueueTenant = new Map<string, number>();
|
|
172
|
-
const starts = new Map<string,
|
|
194
|
+
const starts = new Map<string, StartWindow>();
|
|
173
195
|
const refusals = new Map<string, { reason: LimitReason; atMs: number }>();
|
|
174
196
|
let global = 0;
|
|
175
197
|
let lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
@@ -202,14 +224,16 @@ export function concurrencyLimiter(
|
|
|
202
224
|
*/
|
|
203
225
|
const sweep = (at: number): void => {
|
|
204
226
|
lastSweepMs = at;
|
|
205
|
-
for (const [tenant,
|
|
206
|
-
if (
|
|
227
|
+
for (const [tenant, window] of starts) {
|
|
228
|
+
if (live(window) === 0 || (window.stamps.at(-1) ?? 0) <= at - rateWindowMs) {
|
|
229
|
+
starts.delete(tenant);
|
|
230
|
+
}
|
|
207
231
|
}
|
|
208
232
|
for (const [key, refusal] of refusals) {
|
|
209
233
|
if (refusal.atMs <= at - REFUSAL_TTL_MS) refusals.delete(key);
|
|
210
234
|
}
|
|
211
235
|
if (starts.size > maxTenants) {
|
|
212
|
-
const emptiest = [...starts.entries()].sort((a, b) => a[1]
|
|
236
|
+
const emptiest = [...starts.entries()].sort((a, b) => live(a[1]) - live(b[1]));
|
|
213
237
|
for (const [tenant] of emptiest) {
|
|
214
238
|
if (starts.size <= evictTo) break;
|
|
215
239
|
starts.delete(tenant);
|
|
@@ -237,12 +261,24 @@ export function concurrencyLimiter(
|
|
|
237
261
|
const rateBlocked = (tenant: string, at: number): boolean => {
|
|
238
262
|
const rate = config.ratePerTenant;
|
|
239
263
|
if (rate === undefined) return false;
|
|
240
|
-
const window =
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
264
|
+
const window = starts.get(tenant);
|
|
265
|
+
if (window === undefined) return rate.limit <= 0;
|
|
266
|
+
// Spent stamps leave from the HEAD, never by copying the window: a filter per acquire made a
|
|
267
|
+
// window of n stamps O(n) a claim — 20k acquires were 3.9 s of event loop.
|
|
268
|
+
while (
|
|
269
|
+
window.head < window.stamps.length &&
|
|
270
|
+
(window.stamps[window.head] ?? 0) <= at - rate.windowMs
|
|
271
|
+
) {
|
|
272
|
+
window.head += 1;
|
|
273
|
+
}
|
|
274
|
+
// An empty window is a tenant with no rate state at all — deleted rather than kept, which is
|
|
275
|
+
// what kept one array per org that ever ran a job.
|
|
276
|
+
if (live(window) === 0) {
|
|
277
|
+
starts.delete(tenant);
|
|
278
|
+
return rate.limit <= 0;
|
|
279
|
+
}
|
|
280
|
+
compact(window);
|
|
281
|
+
return live(window) >= rate.limit;
|
|
246
282
|
};
|
|
247
283
|
|
|
248
284
|
return {
|
|
@@ -270,7 +306,9 @@ export function concurrencyLimiter(
|
|
|
270
306
|
bump(byTenant, tenant, 1);
|
|
271
307
|
bump(byQueueTenant, refusalKey, 1);
|
|
272
308
|
if (config.ratePerTenant !== undefined) {
|
|
273
|
-
|
|
309
|
+
const window = starts.get(tenant);
|
|
310
|
+
if (window === undefined) starts.set(tenant, { stamps: [at], head: 0 });
|
|
311
|
+
else window.stamps.push(at);
|
|
274
312
|
}
|
|
275
313
|
refusals.delete(refusalKey);
|
|
276
314
|
maybeSweep(at);
|
|
@@ -296,12 +334,12 @@ export function concurrencyLimiter(
|
|
|
296
334
|
if (!free() || config.ratePerTenant === undefined) return;
|
|
297
335
|
// ONE stamp of this lease's own instant: two leases taken in the same millisecond wrote
|
|
298
336
|
// equal stamps, and either is this one. Absent when a sweep already dropped the window.
|
|
299
|
-
const
|
|
300
|
-
const index = stamps
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
337
|
+
const window = starts.get(tenant);
|
|
338
|
+
const index = window?.stamps.lastIndexOf(at) ?? -1;
|
|
339
|
+
if (window === undefined || index < window.head) return;
|
|
340
|
+
// Searched from the tail, where a lease handed back unrun almost always is.
|
|
341
|
+
window.stamps.splice(index, 1);
|
|
342
|
+
if (live(window) === 0) starts.delete(tenant);
|
|
305
343
|
},
|
|
306
344
|
};
|
|
307
345
|
},
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The three `X_LOCK_*` refusals `withLock()` ends on: not taken within `wait`, lost mid-work, and
|
|
2
|
+
// nothing to hold it in. Codes, titles and retry classes are `error-codes.ts`'s; the key is printed
|
|
3
|
+
// JSON-quoted, since an app builds it from its own data (`invoice:${id}`).
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import './error-codes';
|
|
7
|
+
|
|
8
|
+
/** Another holder kept the key for the whole `wait` bound. Retryable: contention ends. */
|
|
9
|
+
export class LockNotAcquiredError extends UltimateError {
|
|
10
|
+
constructor(input: { key: string; waitMs: number; holders: readonly string[] }) {
|
|
11
|
+
super({
|
|
12
|
+
code: 'X_LOCK_NOT_ACQUIRED',
|
|
13
|
+
cause: `withLock(${JSON.stringify(input.key)}) waited ${input.waitMs}ms and another holder (${input.holders.length === 0 ? 'released a moment ago' : input.holders.join(', ')}) kept the key throughout`,
|
|
14
|
+
fix: `withLock(${JSON.stringify(input.key)}, fn, { ttl, wait: '${Math.max(1, Math.ceil((input.waitMs * 2) / 1000))}s' }) # in the calling file: a longer wait:, or let the caller retry`,
|
|
15
|
+
meta: { key: input.key, waitMs: input.waitMs, holders: [...input.holders] },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A renewal was refused, or none landed within one `ttl`: the key may be someone else's now. The
|
|
22
|
+
* reason `fn`'s signal aborts with — a write fenced on the token is what stops the stale holder.
|
|
23
|
+
*/
|
|
24
|
+
export class LockLostError extends UltimateError {
|
|
25
|
+
constructor(input: { key: string; token: number; ttlMs: number }) {
|
|
26
|
+
super({
|
|
27
|
+
code: 'X_LOCK_LOST',
|
|
28
|
+
cause: `withLock(${JSON.stringify(input.key)}) lost the lock held as ${input.token}: no renewal landed within its ttl of ${input.ttlMs}ms, so another holder may have it`,
|
|
29
|
+
fix: `withLock(${JSON.stringify(input.key)}, fn, { ttl: '${Math.ceil((input.ttlMs * 4) / 1000)}s' }) # in the calling file: a longer ttl:, and fn hands lock.signal to every write it makes`,
|
|
30
|
+
meta: { key: input.key, token: input.token, ttlMs: input.ttlMs },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* No job driver installed, one without a `leases` store, or a store that answers no `fence`: no
|
|
37
|
+
* fleet-wide place to hold a key behind a token the database assigned.
|
|
38
|
+
*/
|
|
39
|
+
export class LockStoreMissingError extends UltimateError {
|
|
40
|
+
constructor(input: { key: string; driver: string | undefined; fence?: boolean }) {
|
|
41
|
+
const why =
|
|
42
|
+
input.fence === true
|
|
43
|
+
? 'the lease store granted the key with no fence, so there is no token a write can be refused by'
|
|
44
|
+
: input.driver === undefined
|
|
45
|
+
? 'no job driver is installed'
|
|
46
|
+
: `the installed job driver "${input.driver}" has no leases`;
|
|
47
|
+
super({
|
|
48
|
+
code: 'X_LOCK_STORE_MISSING',
|
|
49
|
+
cause: `withLock(${JSON.stringify(input.key)}) has no lease store to lock in: ${why}`,
|
|
50
|
+
fix: 'jobs: postgresJobDriver() # in apps/<app>/runtime.ts — or withLock(key, fn, { ttl, store }) in the calling file',
|
|
51
|
+
meta: { key: input.key, driver: input.driver ?? null },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|