@owlmeans/planning-postgres 0.1.18-rc.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 +88 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/planning-postgres/SKILL.md +292 -0
- package/build/consts.d.ts +45 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +45 -0
- package/build/consts.js.map +1 -0
- package/build/errors.d.ts +10 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +13 -0
- package/build/errors.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +8 -0
- package/build/index.js.map +1 -0
- package/build/resource.d.ts +32 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +94 -0
- package/build/resource.js.map +1 -0
- package/build/schemas.d.ts +20 -0
- package/build/schemas.d.ts.map +1 -0
- package/build/schemas.js +64 -0
- package/build/schemas.js.map +1 -0
- package/build/service.d.ts +16 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +34 -0
- package/build/service.js.map +1 -0
- package/build/sql.d.ts +61 -0
- package/build/sql.d.ts.map +1 -0
- package/build/sql.js +90 -0
- package/build/sql.js.map +1 -0
- package/build/store/bus.d.ts +52 -0
- package/build/store/bus.d.ts.map +1 -0
- package/build/store/bus.js +167 -0
- package/build/store/bus.js.map +1 -0
- package/build/store/cards.d.ts +35 -0
- package/build/store/cards.d.ts.map +1 -0
- package/build/store/cards.js +112 -0
- package/build/store/cards.js.map +1 -0
- package/build/store/fold.d.ts +56 -0
- package/build/store/fold.d.ts.map +1 -0
- package/build/store/fold.js +453 -0
- package/build/store/fold.js.map +1 -0
- package/build/store/index.d.ts +22 -0
- package/build/store/index.d.ts.map +1 -0
- package/build/store/index.js +206 -0
- package/build/store/index.js.map +1 -0
- package/build/store/links.d.ts +18 -0
- package/build/store/links.d.ts.map +1 -0
- package/build/store/links.js +54 -0
- package/build/store/links.js.map +1 -0
- package/build/store/schemas.d.ts +32 -0
- package/build/store/schemas.d.ts.map +1 -0
- package/build/store/schemas.js +143 -0
- package/build/store/schemas.js.map +1 -0
- package/build/store/specs.d.ts +10 -0
- package/build/store/specs.d.ts.map +1 -0
- package/build/store/specs.js +47 -0
- package/build/store/specs.js.map +1 -0
- package/build/store/transitions.d.ts +44 -0
- package/build/store/transitions.d.ts.map +1 -0
- package/build/store/transitions.js +164 -0
- package/build/store/transitions.js.map +1 -0
- package/build/types.d.ts +72 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +56 -0
- package/src/consts.ts +54 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +9 -0
- package/src/resource.ts +133 -0
- package/src/schemas.ts +84 -0
- package/src/service.ts +45 -0
- package/src/sql.ts +135 -0
- package/src/store/bus.ts +217 -0
- package/src/store/cards.ts +153 -0
- package/src/store/fold.ts +560 -0
- package/src/store/index.ts +237 -0
- package/src/store/links.ts +77 -0
- package/src/store/schemas.ts +174 -0
- package/src/store/specs.ts +72 -0
- package/src/store/transitions.ts +221 -0
- package/src/types.ts +73 -0
- package/tests/bus.spec.ts +142 -0
- package/tests/conformance.spec.ts +37 -0
- package/tests/context.ts +143 -0
- package/tests/fold.spec.ts +213 -0
- package/tests/schema.spec.ts +89 -0
- package/tests/sql.spec.ts +50 -0
- package/tests/sync.spec.ts +61 -0
- package/tests/tsconfig.json +17 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CommitState, isProject, PlanningUnsupported, TransitionAction,
|
|
3
|
+
} from '@owlmeans/planning'
|
|
4
|
+
import type { CommitEvent, PlanningStore, Transition, Workcard } from '@owlmeans/planning'
|
|
5
|
+
import { advisoryKey } from '@owlmeans/postgres-resource'
|
|
6
|
+
import { failPending, foldPending } from '@owlmeans/server-planning/store'
|
|
7
|
+
import type { CommitHub, CommitListener, FoldResult } from '@owlmeans/server-planning/store'
|
|
8
|
+
import type { Pool } from 'pg'
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_RECOVER_LIMIT, LOST_ALLOCATION, MAX_FOLD_PASSES, MAX_FOLD_ROUNDS, PLANNING_POSTGRES_STORE,
|
|
11
|
+
} from '../consts.js'
|
|
12
|
+
import { clientRunner, col, insertOf, LOCK_NOT_AVAILABLE, pgFault } from '../sql.js'
|
|
13
|
+
import type { PlanningTables, SqlContext } from '../sql.js'
|
|
14
|
+
import type { PlanningPostgresLimits } from '../types.js'
|
|
15
|
+
import type { PlanningBus } from './bus.js'
|
|
16
|
+
import { dropCard, readCard, readCardRow, writeCard } from './cards.js'
|
|
17
|
+
import { dropLinks, listLinks, putLink } from './links.js'
|
|
18
|
+
import { bumpRevision, purgeSchemaLayers } from './schemas.js'
|
|
19
|
+
import { commitTransition, listTransitions, readTransition, readTransitionByKey } from './transitions.js'
|
|
20
|
+
|
|
21
|
+
const EMPTY: FoldResult = Object.freeze({ card: null, folded: 0, failed: 0, followUp: false })
|
|
22
|
+
|
|
23
|
+
const safely = async (label: string, run: () => Promise<unknown> | unknown): Promise<void> => {
|
|
24
|
+
try {
|
|
25
|
+
await run()
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`planning-postgres: ${label} failed:`, error)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One open fold transaction: its runner, a savepoint helper, and what must happen after it commits. */
|
|
32
|
+
export interface FoldContext extends SqlContext {
|
|
33
|
+
/** Run as one unit: a failure rolls back to before it, and the transaction stays usable. */
|
|
34
|
+
savepoint: <R>(run: () => Promise<R>) => Promise<R>
|
|
35
|
+
/** Settled events, delivered in order once the transaction commits. */
|
|
36
|
+
events: CommitEvent[]
|
|
37
|
+
/** Organizations whose schema revision moved in this transaction. */
|
|
38
|
+
schemas: Set<string>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FoldEngineDeps {
|
|
42
|
+
pool: () => Promise<Pool>
|
|
43
|
+
tables: () => Promise<PlanningTables>
|
|
44
|
+
limits: PlanningPostgresLimits
|
|
45
|
+
ids: () => string
|
|
46
|
+
now: () => string
|
|
47
|
+
bus: PlanningBus
|
|
48
|
+
hub: CommitHub
|
|
49
|
+
committed: () => CommitListener | undefined
|
|
50
|
+
/** Told of every organization whose schemas this process changed, after the commit. */
|
|
51
|
+
schemasTouched: (entityId: string) => void
|
|
52
|
+
closed: () => boolean
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FoldEngine {
|
|
56
|
+
/** Fold now, waiting for another folder's lock — what `project()` runs. */
|
|
57
|
+
fold: (cardId: string) => Promise<FoldResult>
|
|
58
|
+
/** Fold in the background unless another folder holds the card — deduplicated per card. */
|
|
59
|
+
heal: (cardId: string) => void
|
|
60
|
+
recover: (opts?: { olderThanMs?: number, limit?: number }) => Promise<number>
|
|
61
|
+
/** Purge a project (outside a fold) in one transaction under the project's lock. */
|
|
62
|
+
purge: (project: string, entityId: string) => Promise<number>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type LockMode = 'wait' | 'try'
|
|
66
|
+
|
|
67
|
+
/** A transaction a statement failed in — rolled back, never committed. */
|
|
68
|
+
class Poisoned extends Error { }
|
|
69
|
+
|
|
70
|
+
const poisoned = (lock: string, cause: unknown): Poisoned => {
|
|
71
|
+
const failure = new Poisoned(`planning-postgres: fold of ${lock} aborted by a failed statement`)
|
|
72
|
+
;(failure as { cause?: unknown }).cause = cause
|
|
73
|
+
return failure
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lockTimedOut = (error: unknown): boolean => pgFault(error).code === LOCK_NOT_AVAILABLE
|
|
77
|
+
|
|
78
|
+
const strip = (event: CommitEvent): Omit<CommitEvent, 'record'> => {
|
|
79
|
+
const { record: _record, ...bare } = event
|
|
80
|
+
return bare
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The inline fold of a Postgres planning store.
|
|
85
|
+
*
|
|
86
|
+
* Every fold of a card is ONE transaction under `pg_advisory_xact_lock(planning:<card table>:<card>)`,
|
|
87
|
+
* with `SET LOCAL lock_timeout`. Inside it: the prelude, then `foldPending` over a view of the ports
|
|
88
|
+
* bound to that transaction (each transition's writes in a savepoint), then a NOTIFY per settled
|
|
89
|
+
* event. Events are delivered to the commit hub and the `after` hooks only once it COMMITS — never
|
|
90
|
+
* inside the transaction or the lock, so a hook may write to the card it saw commit.
|
|
91
|
+
*
|
|
92
|
+
* A transaction a statement failed in is poisoned and never committed. The fold is retried once;
|
|
93
|
+
* failing again, the card's pending transitions are failed in a fresh transaction (`failPending`)
|
|
94
|
+
* so no waiter is left to its timeout. A lock that could not be taken means another process is
|
|
95
|
+
* folding the card: nothing is failed, and a heal follows.
|
|
96
|
+
*/
|
|
97
|
+
export const makeFoldEngine = (deps: FoldEngineDeps): FoldEngine => {
|
|
98
|
+
const { limits } = deps
|
|
99
|
+
const chains = new Map<string, Promise<unknown>>()
|
|
100
|
+
const healing = new Set<string>()
|
|
101
|
+
|
|
102
|
+
/** One transaction of this process per card at a time — the advisory lock serializes processes. */
|
|
103
|
+
const serial = <R>(card: string, run: () => Promise<R>): Promise<R> => {
|
|
104
|
+
const previous = chains.get(card) ?? Promise.resolve()
|
|
105
|
+
const next = previous.catch(() => undefined).then(run)
|
|
106
|
+
chains.set(card, next)
|
|
107
|
+
const release = (): void => {
|
|
108
|
+
if (chains.get(card) === next) {
|
|
109
|
+
chains.delete(card)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
void next.then(release, release)
|
|
113
|
+
return next
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const transaction = async <R>(
|
|
117
|
+
lock: string, mode: LockMode, run: (ctx: FoldContext) => Promise<R>
|
|
118
|
+
): Promise<{ skipped: true } | { skipped: false, result: R, ctx: FoldContext }> => {
|
|
119
|
+
const tables = await deps.tables()
|
|
120
|
+
const client = await (await deps.pool()).connect()
|
|
121
|
+
let poison: unknown
|
|
122
|
+
let savepoints = 0
|
|
123
|
+
const runner = clientRunner(client, error => { poison ??= error })
|
|
124
|
+
const ctx: FoldContext = {
|
|
125
|
+
runner,
|
|
126
|
+
tables,
|
|
127
|
+
events: [],
|
|
128
|
+
schemas: new Set(),
|
|
129
|
+
savepoint: async <T>(unit: () => Promise<T>): Promise<T> => {
|
|
130
|
+
const name = `planning_unit_${++savepoints}`
|
|
131
|
+
const before = poison
|
|
132
|
+
await runner.query(`SAVEPOINT ${name}`)
|
|
133
|
+
try {
|
|
134
|
+
const value = await unit()
|
|
135
|
+
await runner.query(`RELEASE SAVEPOINT ${name}`)
|
|
136
|
+
return value
|
|
137
|
+
} catch (error) {
|
|
138
|
+
try {
|
|
139
|
+
await client.query(`ROLLBACK TO SAVEPOINT ${name}`)
|
|
140
|
+
await client.query(`RELEASE SAVEPOINT ${name}`)
|
|
141
|
+
// Rolled back to before the unit: whatever it broke is undone, the transaction is usable.
|
|
142
|
+
poison = before
|
|
143
|
+
} catch {
|
|
144
|
+
// The rollback itself failed — the transaction stays poisoned.
|
|
145
|
+
}
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await client.query('BEGIN')
|
|
153
|
+
await client.query(`SET LOCAL lock_timeout = '${Math.max(0, Math.floor(limits.lockTimeoutMs))}ms'`)
|
|
154
|
+
const [first, second] = advisoryKey(`planning:${tables.card.qualified}:${lock}`)
|
|
155
|
+
if (mode === 'try') {
|
|
156
|
+
const taken = await client.query<{ ok: boolean }>('SELECT pg_try_advisory_xact_lock($1, $2) AS ok', [first, second])
|
|
157
|
+
if (taken.rows[0]?.ok !== true) {
|
|
158
|
+
await client.query('ROLLBACK')
|
|
159
|
+
return { skipped: true }
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
await client.query('SELECT pg_advisory_xact_lock($1, $2)', [first, second])
|
|
163
|
+
}
|
|
164
|
+
const result = await run(ctx)
|
|
165
|
+
if (poison != null) {
|
|
166
|
+
throw poisoned(lock, poison)
|
|
167
|
+
}
|
|
168
|
+
await client.query('COMMIT')
|
|
169
|
+
return { skipped: false, result, ctx }
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await client.query('ROLLBACK').catch(() => undefined)
|
|
172
|
+
// The statement that broke the transaction is the cause — not the "transaction is aborted"
|
|
173
|
+
// every statement after it answers.
|
|
174
|
+
throw poison != null && !(error instanceof Poisoned) ? poisoned(lock, poison) : error
|
|
175
|
+
} finally {
|
|
176
|
+
client.release()
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── The transaction-bound view foldPending and failPending run over ──────────────────────────
|
|
181
|
+
|
|
182
|
+
const refuse = (what: string) => async (): Promise<never> => { throw new PlanningUnsupported(`fold-view:${what}`) }
|
|
183
|
+
|
|
184
|
+
const viewOf = (ctx: FoldContext): PlanningStore => ({
|
|
185
|
+
alias: PLANNING_POSTGRES_STORE,
|
|
186
|
+
transitions: {
|
|
187
|
+
append: refuse('append'),
|
|
188
|
+
get: async id => await readTransition(ctx, id),
|
|
189
|
+
byKey: async (entityId, key) => await readTransitionByKey(ctx, entityId, key),
|
|
190
|
+
list: async (where, opts) => await listTransitions(ctx, where, opts?.size ?? 0),
|
|
191
|
+
nextSeq: refuse('nextSeq'),
|
|
192
|
+
head: refuse('head'),
|
|
193
|
+
commit: async (id, commit) => { await commitTransition(ctx, id, commit) },
|
|
194
|
+
purge: refuse('purge'),
|
|
195
|
+
},
|
|
196
|
+
cards: {
|
|
197
|
+
get: async (id, entityId) => await readCard(ctx, id, entityId),
|
|
198
|
+
list: refuse('list'),
|
|
199
|
+
count: refuse('count'),
|
|
200
|
+
summary: refuse('summary'),
|
|
201
|
+
put: async card => { await writeCard(ctx, card) },
|
|
202
|
+
drop: async (id, entityId) => { await dropCard(ctx, id, entityId) },
|
|
203
|
+
project: refuse('project'),
|
|
204
|
+
purge: async (project, entityId) => await purgeInside(ctx, project, entityId),
|
|
205
|
+
},
|
|
206
|
+
links: {
|
|
207
|
+
list: async where => await listLinks(ctx, where),
|
|
208
|
+
put: async link => await putLink(ctx, link, deps.ids),
|
|
209
|
+
drop: async where => await dropLinks(ctx, where),
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// ─── Purge ─────────────────────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Everything under a project, in the open transaction: the recursive walk over `parents` finds
|
|
217
|
+
* every doomed card (nested projects too); then links, transitions, the doomed projects' schema
|
|
218
|
+
* layers and the cards themselves go — in that order. The project's own `delete` transitions stay
|
|
219
|
+
* as its tombstone, so a waiter still reads the delete committed.
|
|
220
|
+
*/
|
|
221
|
+
const purgeInside = async (ctx: FoldContext, project: string, entityId: string): Promise<number> => {
|
|
222
|
+
const { card: cards, transition: log, link: links } = ctx.tables
|
|
223
|
+
const parents = cards.byProperty.parents
|
|
224
|
+
const doomed = (await ctx.runner.query<{ id: string }>(
|
|
225
|
+
`WITH RECURSIVE doomed(id) AS (`
|
|
226
|
+
+ ` SELECT $1::text`
|
|
227
|
+
+ ` UNION SELECT child.${col(cards, 'id')}::text FROM ${cards.qualified} AS child JOIN doomed`
|
|
228
|
+
+ ` ON child.${col(cards, 'parents')} @> ARRAY[doomed.id]::${parents.sqlType}`
|
|
229
|
+
+ ` WHERE child.${col(cards, 'entityId')} = $2`
|
|
230
|
+
+ `) SELECT id FROM doomed`,
|
|
231
|
+
[project, entityId]
|
|
232
|
+
)).map(row => row.id)
|
|
233
|
+
|
|
234
|
+
let count = 0
|
|
235
|
+
count += (await ctx.runner.query(
|
|
236
|
+
`DELETE FROM ${links.qualified} WHERE ${col(links, 'entityId')} = $1 AND (${col(links, 'from')} = ANY($2)`
|
|
237
|
+
+ ` OR ${col(links, 'to')} = ANY($2) OR ${col(links, 'project')} = ANY($2)) RETURNING ${col(links, 'id')}`,
|
|
238
|
+
[entityId, doomed]
|
|
239
|
+
)).length
|
|
240
|
+
count += (await ctx.runner.query(
|
|
241
|
+
`DELETE FROM ${log.qualified} WHERE ${col(log, 'entityId')} = $1 AND (${col(log, 'card')} = ANY($2) OR ${col(log, 'project')} = ANY($2))`
|
|
242
|
+
+ ` AND NOT (${col(log, 'card')} = $3 AND ${col(log, 'action')} = $4) RETURNING ${col(log, 'id')}`,
|
|
243
|
+
[entityId, doomed, project, TransitionAction.Delete]
|
|
244
|
+
)).length
|
|
245
|
+
const layers = await purgeSchemaLayers(ctx, entityId, doomed)
|
|
246
|
+
if (layers > 0) {
|
|
247
|
+
await bumpRevision(ctx, entityId, deps.now(), deps.ids)
|
|
248
|
+
await deps.bus.notify(ctx.runner, { t: 's', e: entityId })
|
|
249
|
+
ctx.schemas.add(entityId)
|
|
250
|
+
count += layers
|
|
251
|
+
}
|
|
252
|
+
count += (await ctx.runner.query(
|
|
253
|
+
`DELETE FROM ${cards.qualified} WHERE ${col(cards, 'entityId')} = $1 AND ${col(cards, 'id')} = ANY($2) RETURNING ${col(cards, 'id')}`,
|
|
254
|
+
[entityId, doomed]
|
|
255
|
+
)).length
|
|
256
|
+
|
|
257
|
+
return count
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── The prelude ───────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/** The organization of a card that has a log but no row (its create has not folded). */
|
|
263
|
+
const logEntityOf = async (ctx: FoldContext, cardId: string): Promise<string | null> => {
|
|
264
|
+
const log = ctx.tables.transition
|
|
265
|
+
const rows = await ctx.runner.query<{ entityId: string }>(
|
|
266
|
+
`SELECT ${col(log, 'entityId')} AS "entityId" FROM ${log.qualified} WHERE ${col(log, 'card')} = $1 ORDER BY ${col(log, 'seq')} LIMIT 1`,
|
|
267
|
+
[cardId]
|
|
268
|
+
)
|
|
269
|
+
return rows[0]?.entityId ?? null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Failed rows over allocations that never got one — what lets the fold step over a hole. */
|
|
273
|
+
const placeholders = async (
|
|
274
|
+
ctx: FoldContext, card: Workcard, from: number, to: number, project?: string
|
|
275
|
+
): Promise<boolean> => {
|
|
276
|
+
const at = deps.now()
|
|
277
|
+
for (let seq = from; seq <= to; seq++) {
|
|
278
|
+
const row: Transition = {
|
|
279
|
+
id: deps.ids(), entityId: card.entityId, card: card.id!, kind: card.kind, type: card.type,
|
|
280
|
+
...(project != null ? { project } : {}),
|
|
281
|
+
seq, action: TransitionAction.Update, changes: {}, actor: { service: PLANNING_POSTGRES_STORE },
|
|
282
|
+
cause: LOST_ALLOCATION, at, commit: { state: CommitState.Failed, at, error: LOST_ALLOCATION },
|
|
283
|
+
}
|
|
284
|
+
const log = ctx.tables.transition
|
|
285
|
+
const statement = insertOf(log, row as unknown as Record<string, unknown>, {
|
|
286
|
+
tail: `ON CONFLICT (${col(log, 'card')}, ${col(log, 'seq')}) DO NOTHING RETURNING ${col(log, 'id')}`,
|
|
287
|
+
})
|
|
288
|
+
if ((await ctx.runner.query(statement.text, statement.params)).length === 0) {
|
|
289
|
+
// The real row landed after all: the fold reads it on its next round.
|
|
290
|
+
return false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return true
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** What one pass of the prelude found: the card's organization and how much `foldPending` may fold. */
|
|
297
|
+
interface Plan {
|
|
298
|
+
entityId: string
|
|
299
|
+
/** Consecutive pending rows at the cursor — exactly what `foldPending` folds this pass. */
|
|
300
|
+
run: number
|
|
301
|
+
/** The log goes on after the run in a way the next pass handles (a failed row, a gap). */
|
|
302
|
+
more: boolean
|
|
303
|
+
/** The read hit `foldBatch`: a next round has more to read. */
|
|
304
|
+
full: boolean
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Walk the log past `card.seq`: a FAILED row at the cursor moves the cursor past it; PENDING rows
|
|
309
|
+
* from the cursor on are the run `foldPending` folds; a GAP (a row past the expected seq, or an
|
|
310
|
+
* allocated head with no row at all) is an append still in flight while it is younger than
|
|
311
|
+
* `gapGraceMs` — the fold stops there, and the appender's own `project()` folds it — and a lost
|
|
312
|
+
* allocation after that: one failed `lost-allocation` placeholder per missing seq, and on.
|
|
313
|
+
* `nextSeq` and `append` are two round trips here, so without this a transient gap or an
|
|
314
|
+
* already-failed row would make the next write fail as out of order.
|
|
315
|
+
*
|
|
316
|
+
* A card with no row folds its log as it is: a create from seq 1, and anything else fails out of
|
|
317
|
+
* order. `null` when there is neither a row nor a log.
|
|
318
|
+
*/
|
|
319
|
+
const prelude = async (ctx: FoldContext, cardId: string): Promise<Plan | null> => {
|
|
320
|
+
const card = await readCardRow(ctx, cardId)
|
|
321
|
+
if (card == null) {
|
|
322
|
+
const entityId = await logEntityOf(ctx, cardId)
|
|
323
|
+
if (entityId == null) {
|
|
324
|
+
return null
|
|
325
|
+
}
|
|
326
|
+
const pending = await listTransitions(ctx, { entityId, card: cardId, state: CommitState.Pending }, limits.foldBatch)
|
|
327
|
+
return { entityId, run: pending.items.length, more: false, full: pending.total > pending.items.length }
|
|
328
|
+
}
|
|
329
|
+
const entityId = card.entityId
|
|
330
|
+
const rows = (await listTransitions(ctx, { entityId, card: cardId, sinceSeq: card.seq }, limits.foldBatch)).items
|
|
331
|
+
|
|
332
|
+
const now = Date.now()
|
|
333
|
+
let expected = card.seq + 1
|
|
334
|
+
let advanced = card.seq
|
|
335
|
+
let run = 0
|
|
336
|
+
let more = false
|
|
337
|
+
let consumed = true
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
if (row.seq < expected) {
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
342
|
+
if (row.seq > expected) {
|
|
343
|
+
if (run > 0) {
|
|
344
|
+
// The run ends at the hole; the next pass meets it at the cursor.
|
|
345
|
+
more = true
|
|
346
|
+
consumed = false
|
|
347
|
+
break
|
|
348
|
+
}
|
|
349
|
+
if (now - Date.parse(row.at) < limits.gapGraceMs || !await placeholders(ctx, card, expected, row.seq - 1, row.project)) {
|
|
350
|
+
consumed = false
|
|
351
|
+
break
|
|
352
|
+
}
|
|
353
|
+
advanced = row.seq - 1
|
|
354
|
+
expected = row.seq
|
|
355
|
+
}
|
|
356
|
+
if (row.commit.state === CommitState.Failed) {
|
|
357
|
+
if (run > 0) {
|
|
358
|
+
more = true
|
|
359
|
+
consumed = false
|
|
360
|
+
break
|
|
361
|
+
}
|
|
362
|
+
advanced = row.seq
|
|
363
|
+
expected = row.seq + 1
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
if (row.commit.state === CommitState.Pending) {
|
|
367
|
+
run++
|
|
368
|
+
expected++
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
// Committed yet past the card's seq: nothing a fold may touch.
|
|
372
|
+
consumed = false
|
|
373
|
+
break
|
|
374
|
+
}
|
|
375
|
+
const full = rows.length >= limits.foldBatch
|
|
376
|
+
if (consumed && run === 0 && !full) {
|
|
377
|
+
const head = card.head ?? card.seq
|
|
378
|
+
if (head > advanced && card.headAt != null && now - Date.parse(card.headAt) >= limits.gapGraceMs
|
|
379
|
+
&& await placeholders(ctx, card, advanced + 1, head, rows.at(-1)?.project ?? (isProject(card) ? card.id : undefined))) {
|
|
380
|
+
advanced = head
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (advanced > card.seq) {
|
|
384
|
+
const spec = ctx.tables.card
|
|
385
|
+
await ctx.runner.query(
|
|
386
|
+
`UPDATE ${spec.qualified} SET ${col(spec, 'seq')} = $2, ${col(spec, 'head')} = GREATEST(COALESCE(${col(spec, 'head')}, ${col(spec, 'seq')}), $2)`
|
|
387
|
+
+ ` WHERE ${col(spec, 'id')} = $1`,
|
|
388
|
+
[cardId, advanced]
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return { entityId, run, more, full: full && consumed }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Folding ───────────────────────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
const notifyAll = async (ctx: FoldContext): Promise<void> => {
|
|
398
|
+
for (const event of ctx.events) {
|
|
399
|
+
await deps.bus.notify(ctx.runner, { t: 'c', e: strip(event) })
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Passes of prelude + `foldPending`, each folding exactly the run the prelude measured, until the
|
|
405
|
+
* log is folded, a young gap stops it, or a pass makes no progress.
|
|
406
|
+
*/
|
|
407
|
+
const foldInside = async (ctx: FoldContext, cardId: string): Promise<FoldResult> => {
|
|
408
|
+
const total: FoldResult = { card: null, folded: 0, failed: 0, followUp: false }
|
|
409
|
+
for (let pass = 0; pass < MAX_FOLD_PASSES; pass++) {
|
|
410
|
+
const plan = await prelude(ctx, cardId)
|
|
411
|
+
if (plan == null) {
|
|
412
|
+
break
|
|
413
|
+
}
|
|
414
|
+
total.followUp = plan.full
|
|
415
|
+
if (plan.run === 0) {
|
|
416
|
+
total.card = await readCard(ctx, cardId, plan.entityId)
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
const result = await foldPending(viewOf(ctx), cardId, plan.entityId, {
|
|
420
|
+
now: deps.now,
|
|
421
|
+
limit: plan.run,
|
|
422
|
+
unit: ctx.savepoint,
|
|
423
|
+
publish: async event => { ctx.events.push(event) },
|
|
424
|
+
})
|
|
425
|
+
total.card = result.card
|
|
426
|
+
total.folded += result.folded
|
|
427
|
+
total.failed += result.failed
|
|
428
|
+
if (!plan.more || result.folded + result.failed === 0) {
|
|
429
|
+
break
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await notifyAll(ctx)
|
|
433
|
+
return total
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const failInside = async (ctx: FoldContext, cardId: string, reason: string): Promise<FoldResult> => {
|
|
437
|
+
const entityId = (await readCardRow(ctx, cardId))?.entityId ?? await logEntityOf(ctx, cardId)
|
|
438
|
+
if (entityId == null) {
|
|
439
|
+
return EMPTY
|
|
440
|
+
}
|
|
441
|
+
const failed = await failPending(viewOf(ctx), cardId, entityId, reason, {
|
|
442
|
+
now: deps.now, publish: async event => { ctx.events.push(event) },
|
|
443
|
+
})
|
|
444
|
+
await notifyAll(ctx)
|
|
445
|
+
return { card: null, folded: 0, failed, followUp: false }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** After the commit, in order: the hub (this process's subscribers and waiters), then the hooks. */
|
|
449
|
+
const deliver = async (ctx: FoldContext): Promise<void> => {
|
|
450
|
+
for (const entityId of ctx.schemas) {
|
|
451
|
+
deps.schemasTouched(entityId)
|
|
452
|
+
}
|
|
453
|
+
for (const event of ctx.events) {
|
|
454
|
+
await safely('commit publish', () => deps.hub.publish(event))
|
|
455
|
+
if (event.state === CommitState.Committed) {
|
|
456
|
+
const committed = deps.committed()
|
|
457
|
+
if (committed != null) {
|
|
458
|
+
await safely('committed hooks', () => committed(event))
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const reasonOf = (error: unknown): string => {
|
|
465
|
+
const cause = (error as { cause?: unknown } | null)?.cause ?? error
|
|
466
|
+
return cause instanceof Error ? cause.message : `${cause}`
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** One round: fold, retry once, then fail what is pending — or yield to a folder holding the lock. */
|
|
470
|
+
const round = async (cardId: string, mode: LockMode): Promise<FoldResult> => {
|
|
471
|
+
const attempt = () => serial(cardId, () => transaction(cardId, mode, ctx => foldInside(ctx, cardId)))
|
|
472
|
+
let outcome: Awaited<ReturnType<typeof attempt>>
|
|
473
|
+
try {
|
|
474
|
+
outcome = await attempt()
|
|
475
|
+
} catch (first) {
|
|
476
|
+
if (lockTimedOut(first)) {
|
|
477
|
+
engine.heal(cardId)
|
|
478
|
+
return EMPTY
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
outcome = await attempt()
|
|
482
|
+
} catch (second) {
|
|
483
|
+
if (lockTimedOut(second)) {
|
|
484
|
+
engine.heal(cardId)
|
|
485
|
+
return EMPTY
|
|
486
|
+
}
|
|
487
|
+
console.error(`planning-postgres: fold of ${cardId} failed twice, failing its pending transitions:`, second)
|
|
488
|
+
try {
|
|
489
|
+
outcome = await serial(cardId, () => transaction(cardId, 'wait', ctx => failInside(ctx, cardId, reasonOf(second))))
|
|
490
|
+
} catch (third) {
|
|
491
|
+
console.error(`planning-postgres: cannot fail the pending transitions of ${cardId}:`, third)
|
|
492
|
+
return EMPTY
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (outcome.skipped) {
|
|
497
|
+
return EMPTY
|
|
498
|
+
}
|
|
499
|
+
await deliver(outcome.ctx)
|
|
500
|
+
return outcome.result
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const foldWith = async (cardId: string, mode: LockMode): Promise<FoldResult> => {
|
|
504
|
+
let result: FoldResult = EMPTY
|
|
505
|
+
for (let rounds = 0; rounds < MAX_FOLD_ROUNDS && !deps.closed(); rounds++) {
|
|
506
|
+
result = await round(cardId, mode)
|
|
507
|
+
if (!result.followUp) {
|
|
508
|
+
break
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return result
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const engine: FoldEngine = {
|
|
515
|
+
fold: cardId => foldWith(cardId, 'wait'),
|
|
516
|
+
|
|
517
|
+
heal: cardId => {
|
|
518
|
+
if (healing.has(cardId) || deps.closed()) {
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
healing.add(cardId)
|
|
522
|
+
void foldWith(cardId, 'try')
|
|
523
|
+
.catch(error => console.error(`planning-postgres: heal of ${cardId} failed:`, error))
|
|
524
|
+
.finally(() => { healing.delete(cardId) })
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
recover: async opts => {
|
|
528
|
+
const tables = await deps.tables()
|
|
529
|
+
const log = tables.transition
|
|
530
|
+
const cutoff = new Date(Date.now() - (opts?.olderThanMs ?? limits.recoverAfterMs)).toISOString()
|
|
531
|
+
const pool = await deps.pool()
|
|
532
|
+
const stale = (await pool.query<{ card: string }>(
|
|
533
|
+
`SELECT ${col(log, 'card')} AS card, MIN(${col(log, 'at')}) AS first FROM ${log.qualified}`
|
|
534
|
+
+ ` WHERE (${col(log, 'commit')}->>'state') = 'pending' AND ${col(log, 'at')} < $1`
|
|
535
|
+
+ ` GROUP BY ${col(log, 'card')} ORDER BY first LIMIT $2`,
|
|
536
|
+
[cutoff, opts?.limit ?? DEFAULT_RECOVER_LIMIT]
|
|
537
|
+
)).rows
|
|
538
|
+
let folded = 0
|
|
539
|
+
for (const { card } of stale) {
|
|
540
|
+
if (deps.closed()) {
|
|
541
|
+
break
|
|
542
|
+
}
|
|
543
|
+
await foldWith(card, 'try')
|
|
544
|
+
folded++
|
|
545
|
+
}
|
|
546
|
+
return folded
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
purge: async (project, entityId) => {
|
|
550
|
+
const outcome = await serial(project, () => transaction(project, 'wait', ctx => purgeInside(ctx, project, entityId)))
|
|
551
|
+
if (outcome.skipped) {
|
|
552
|
+
return 0
|
|
553
|
+
}
|
|
554
|
+
await deliver(outcome.ctx)
|
|
555
|
+
return outcome.result
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return engine
|
|
560
|
+
}
|