vouchington-tooling 0.28.0 → 0.29.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: bounded-iteration
3
+ description: Bound the rows each run reads when writing or reviewing scheduled jobs, reconcilers, cleanups, dispatchers, backfills, exports, list endpoints, or any loop that reads rows.
4
+ ---
5
+
6
+ # Bounded iteration
7
+
8
+ Read applicable `AGENTS.md` and the repository's local database adapter when present.
9
+ Local policy overrides these portable conventions.
10
+
11
+ A run's cost is the rows it reads and the work it starts, not the memory it holds. Read
12
+ [sweep patterns](references/sweep-patterns.md) for worked SQL and loop shapes.
13
+
14
+ 1. **Choose work, not entities.** Read only rows that need work, through a predicate an index
15
+ serves: a work-item or dirty-marker row, a parent id, a due timestamp with a partial index, or
16
+ a time or UUIDv7 window past a high-water mark. Column-to-column comparisons, `OR` of unrelated
17
+ conditions, and `EXISTS` as the only condition are not filters. (`postgres-required-predicates`)
18
+ 2. **Cap every run.** Every loop has a per-run cap (batches, rows, or a deadline), reports whether
19
+ work remains, and resumes from a persisted position. Never loop until empty, and never default
20
+ a cap to infinity.
21
+ 3. **Bound every statement.** A SELECT, UPDATE, or DELETE that can touch many rows has a `LIMIT`.
22
+ For writes the `LIMIT` goes in a CTE, with `FOR UPDATE SKIP LOCKED` when workers compete.
23
+ (`postgres-lock-ordering`)
24
+ 4. **A stream is not a bound.** A cursor or stream bounds memory, not work. Cap the rows streamed
25
+ per run.
26
+ 5. **Resume without restarting.** Keep a keyset position on the UUIDv7 key or a
27
+ `<verb>_through_at` time position, fix the sweep's end when it starts, and rescan an overlap
28
+ when insert order differs from commit order. Cursor tables follow
29
+ [table shapes](../postgres-schema-design/references/table-shapes.md).
30
+ (`postgres-generated-column-predicates`)
31
+ 6. **No unbounded fan-out.** Use `Promise.all` only over a bounded result, with bounded
32
+ concurrency.
33
+ 7. **Keyset pagination.** No `OFFSET`. Clamp the client's page size on the server. The API schema
34
+ keeps a static ceiling; the default and the effective maximum are runtime-tunable but never
35
+ above it. (`postgres-no-offset`)
36
+ 8. **Tunables are runtime configuration.** Batch sizes, page sizes, caps, and windows come from
37
+ the consumer's runtime configuration with a validated default and a hard maximum, and are bound
38
+ as SQL parameters. `LIMIT 1` and an external API's page maximum are contracts, not tunables.
39
+ 9. **Work that needs every key** (rebuilding a membership filter, a reindex) keeps derived state
40
+ current on write. A full pass is an explicit, capped, resumable backfill, not a scheduled pass
41
+ over everything.
42
+ 10. **Verify with a plan.** EXPLAIN at representative cardinality shows no Seq Scan on an
43
+ unbounded table, and an index that supplies the `ORDER BY`, so the scan stops at the
44
+ `LIMIT` instead of reading and sorting every match.
45
+
46
+ For work-item, lease, and cursor tables, read [postgres-schema-design](../postgres-schema-design/SKILL.md).
47
+ For query shape and streaming, read [performance patterns](../postgres-node-performance-tuning/references/performance-patterns.md).
48
+ For UUIDv7 windows and pruning, use [UUIDv7 partitioning](../postgres-partitioning-uuid-v7/SKILL.md).
49
+ Names in parentheses are generic `jonathanong/no-mistakes` rules; check that the installed release
50
+ has one before relying on it. No generic rule checks run caps, streams, or fan-out.
51
+ Consumer policy owns run caps, tunable names and ceilings, schedules, and enforcement configuration.
@@ -0,0 +1,342 @@
1
+ # Sweep patterns
2
+
3
+ Synthetic examples for [bounded iteration](../SKILL.md). `db.query` stands for the consumer's
4
+ executor and `config` for its runtime configuration; every cap in SQL is a bound parameter.
5
+ `min_uuidv7(timestamptz)` stands for the consumer's helper that returns the smallest UUIDv7 for a
6
+ timestamp, like the lower-bound helper in
7
+ [performance patterns](../../postgres-node-performance-tuning/references/performance-patterns.md).
8
+ Comments on tables and columns are omitted for space.
9
+
10
+ ## Due timestamp, partial index, capped claim
11
+
12
+ A work-item row exists only while work is pending, so the index covers only rows a claim can pick:
13
+
14
+ ```sql
15
+ CREATE INDEX idx_invoice_delivery_work_items__available_at
16
+ ON invoice_delivery_work_items (available_at, id)
17
+ WHERE completed_at IS NULL;
18
+ ```
19
+
20
+ One claim takes at most `$1` due rows. Competing workers skip rows another worker holds, and the
21
+ lease moves `available_at` to its expiry, so an abandoned claim is due again through the same index
22
+ without an `OR` in the predicate:
23
+
24
+ ```sql
25
+ WITH due AS (
26
+ SELECT id
27
+ FROM invoice_delivery_work_items
28
+ WHERE completed_at IS NULL AND available_at <= now()
29
+ ORDER BY available_at, id
30
+ LIMIT $1
31
+ FOR UPDATE SKIP LOCKED
32
+ )
33
+ UPDATE invoice_delivery_work_items AS item
34
+ SET lease_token = gen_random_uuid(),
35
+ leased_at = now(),
36
+ lease_expires_at = now() + $2::interval,
37
+ available_at = now() + $2::interval,
38
+ attempt_count = item.attempt_count + 1
39
+ FROM due
40
+ WHERE item.id = due.id
41
+ RETURNING item.id, item.invoice_id, item.lease_token;
42
+ ```
43
+
44
+ Renewal moves `lease_expires_at` and `available_at` to the same new deadline, so a renewed lease
45
+ is not due. Renewal, completion, and failure all add `AND lease_token = $mine`; see
46
+ [table shapes](../../postgres-schema-design/references/table-shapes.md).
47
+
48
+ ## Dirty marker or work item
49
+
50
+ Do not walk every order to find the few that need reconciling. The writer that changes an order
51
+ marks it in the same transaction, and the reconciler claims only marks, as above:
52
+
53
+ ```sql
54
+ CREATE TABLE order_reconcile_work_items (
55
+ id uuid PRIMARY KEY DEFAULT uuidv7(),
56
+ order_id uuid NOT NULL UNIQUE REFERENCES orders (id) ON DELETE CASCADE,
57
+ available_at timestamptz NOT NULL DEFAULT now(),
58
+ lease_token uuid,
59
+ leased_at timestamptz,
60
+ lease_expires_at timestamptz,
61
+ attempt_count integer NOT NULL DEFAULT 0,
62
+ generation integer NOT NULL DEFAULT 0,
63
+ completed_at timestamptz
64
+ );
65
+
66
+ INSERT INTO order_reconcile_work_items (order_id)
67
+ VALUES ($1)
68
+ ON CONFLICT (order_id) DO UPDATE
69
+ SET available_at = GREATEST(order_reconcile_work_items.available_at, now()),
70
+ completed_at = NULL,
71
+ generation = order_reconcile_work_items.generation + 1;
72
+ ```
73
+
74
+ The claim above applies with `order_id` for `invoice_id`, and its `RETURNING` also includes
75
+ `generation`, so `$claimed` is captured in the same statement as the lease. Completion adds
76
+ `AND generation = $claimed`. A re-mark never makes a leased row due early, since
77
+ the claim moved `available_at` to the lease expiry. A completion that finds a newer generation
78
+ releases the lease with `available_at = now()`, and the next claim takes the row again. Run cost
79
+ follows the number of changes, not the number of orders. A job fanned out per account reads by
80
+ parent id, `WHERE account_id = $1`, for the one account it was started for.
81
+
82
+ ## High-water-mark window with an overlap
83
+
84
+ An export reads `invoices` added since the last sweep. The cursor row holds the position and the
85
+ sweep's fixed end, and the job seeds its own row:
86
+
87
+ ```sql
88
+ CREATE TABLE invoice_export_cursors (
89
+ is_singleton boolean PRIMARY KEY DEFAULT true CHECK (is_singleton),
90
+ cursor_invoice_id uuid,
91
+ sweep_upper_bound_invoice_id uuid
92
+ );
93
+
94
+ INSERT INTO invoice_export_cursors DEFAULT VALUES ON CONFLICT DO NOTHING;
95
+ ```
96
+
97
+ A UUIDv7 is assigned at insert, not commit, so a row below the last sweep's end can commit after
98
+ that sweep passed it. A new sweep starts an overlap before the previous end and fixes its own end
99
+ now, only when the previous sweep reached its end. `$1` is the overlap, and NULL means the start,
100
+ so the first sweep begins at the start. The overlap must exceed the longest insert-to-commit
101
+ delay, which the consumer enforces, for example with a transaction timeout; a row that commits
102
+ after the overlap has passed is missed for good. Work that cannot tolerate a miss writes a
103
+ work-item row in the same transaction instead:
104
+
105
+ ```sql
106
+ UPDATE invoice_export_cursors
107
+ SET cursor_invoice_id =
108
+ min_uuidv7(uuid_extract_timestamp(sweep_upper_bound_invoice_id) - $1::interval),
109
+ sweep_upper_bound_invoice_id = min_uuidv7(now())
110
+ WHERE is_singleton
111
+ AND cursor_invoice_id IS NOT DISTINCT FROM sweep_upper_bound_invoice_id;
112
+ ```
113
+
114
+ Each batch moves strictly forward inside the sweep, where `$1` is the cursor (the nil UUID for the
115
+ start), `$2` the sweep's end, and `$3` the batch size:
116
+
117
+ ```sql
118
+ SELECT id, account_id, total_cents
119
+ FROM invoices
120
+ WHERE id > $1 AND id <= $2
121
+ ORDER BY id
122
+ LIMIT $3;
123
+ ```
124
+
125
+ Persist the last `id` as the cursor after each batch with a compare-and-set, so a stale or
126
+ overlapping worker cannot move the position backward or into another sweep. `$1` is the new
127
+ position, `$2` the cursor this batch started from (NULL at the start), and `$3` the sweep's end.
128
+ Zero updated rows means another worker moved on, so the run stops:
129
+
130
+ ```sql
131
+ UPDATE invoice_export_cursors
132
+ SET cursor_invoice_id = $1
133
+ WHERE is_singleton
134
+ AND cursor_invoice_id IS NOT DISTINCT FROM $2::uuid
135
+ AND sweep_upper_bound_invoice_id = $3;
136
+ ```
137
+
138
+ A short batch ends the sweep: set the cursor to the sweep's end the same way, and the next run
139
+ starts a new sweep. Rows in the overlap are read twice, so the export must be idempotent.
140
+
141
+ A time position works the same way on a column the database sets at insert, such as
142
+ `received_at timestamptz NOT NULL DEFAULT clock_timestamp()`, with the same overlap rule. Never use a business
143
+ time such as `occurred_at`: a late-arriving row carries an old timestamp and falls behind every
144
+ later window. Avoid `now()` too, because it records the transaction's start, not the insert. Fix the window's end when the window opens, keep a composite `(time, id)` keyset
145
+ position inside it, and read
146
+ `WHERE (received_at, id) > ($1, $2) AND received_at <= $3 ORDER BY received_at, id`, where `$3` is
147
+ the window's end. Rows that share a timestamp are neither skipped nor repeated, and new arrivals
148
+ wait for the next window. Advance `<verb>_through_at` to the window's end only after the window is
149
+ drained, and start the next window an overlap earlier.
150
+
151
+ ## Capped batch loop with `hasMore`
152
+
153
+ The run stops at a batch cap or a deadline and says whether work remains. A short batch is the
154
+ only proof that nothing is left. Rows a `SKIP LOCKED` claim passed over are held by another
155
+ claimer, who owns them; if that claim rolls back, they are due again on the next scheduled run,
156
+ so a short batch still ends this run's backlog. A cap that stops the run reports
157
+ `hasMore: true`, even when the last batch happened to drain the work; the continuation finds
158
+ nothing and stops:
159
+
160
+ ```ts
161
+ interface RunResult {
162
+ processed: number
163
+ hasMore: boolean
164
+ }
165
+
166
+ export async function runOrderReconcile(config: ReconcileConfig): Promise<RunResult> {
167
+ const deadline = Date.now() + config.runDeadlineMs
168
+ let processed = 0
169
+ for (let batch = 0; batch < config.maxBatchesPerRun; batch += 1) {
170
+ if (Date.now() >= deadline) return { processed, hasMore: true }
171
+ const items = await claimDueItems(config.batchSize, config.leaseInterval)
172
+ await reconcileOrders(items)
173
+ processed += items.length
174
+ if (items.length < config.batchSize) return { processed, hasMore: false }
175
+ }
176
+ return { processed, hasMore: true }
177
+ }
178
+
179
+ export async function orderReconcileJob(config: ReconcileConfig): Promise<void> {
180
+ const { hasMore } = await runOrderReconcile(config)
181
+ if (hasMore) await enqueueContinuation('order-reconcile')
182
+ }
183
+ ```
184
+
185
+ The persisted position is the work-item table itself: claimed and completed rows leave the due
186
+ set. A cursor sweep persists its position after each batch. The job asks for a continuation
187
+ instead of looping on.
188
+
189
+ ## Tunables
190
+
191
+ Read each cap from runtime configuration with a validated default and a hard maximum. A bad value
192
+ fails at startup, and no cap is unset or infinite:
193
+
194
+ ```ts
195
+ interface Bound {
196
+ default: number
197
+ max: number
198
+ }
199
+
200
+ export function readBound(name: string, raw: string | undefined, bound: Bound): number {
201
+ const value = raw === undefined ? bound.default : Number(raw)
202
+ if (!Number.isInteger(value) || value < 1 || value > bound.max) {
203
+ throw new RangeError(`${name} must be an integer from 1 to ${bound.max}`)
204
+ }
205
+ return value
206
+ }
207
+
208
+ const batchSize = readBound('ORDER_RECONCILE_BATCH_SIZE', env.ORDER_RECONCILE_BATCH_SIZE, {
209
+ default: 200,
210
+ max: 1000,
211
+ })
212
+ ```
213
+
214
+ Bind the value as a parameter, `LIMIT $1`, never a literal in SQL text. `LIMIT 1` for a single-row
215
+ lookup and an external API's documented page maximum are contracts, not tunables.
216
+
217
+ ## List endpoint
218
+
219
+ The API schema keeps a static ceiling. The runtime default and effective maximum are tunable but
220
+ never above it, and the server rejects an invalid size and clamps a large one:
221
+
222
+ ```ts
223
+ const API_MAX_PAGE_SIZE = 100
224
+
225
+ export function pageSize(requested: number | undefined, config: PageConfig): number {
226
+ const ceiling = Math.min(config.maxPageSize, API_MAX_PAGE_SIZE)
227
+ const value = requested ?? config.defaultPageSize
228
+ if (!Number.isInteger(value) || value < 1) {
229
+ throw new RangeError('Page size must be a positive integer')
230
+ }
231
+ return Math.min(value, ceiling)
232
+ }
233
+ ```
234
+
235
+ Page by keyset on the key the index serves. Fetch `pageSize + 1` rows to learn whether another page
236
+ exists, return `pageSize`, and encode the last returned `id` as the next cursor. The first page
237
+ omits `id < $2`:
238
+
239
+ ```sql
240
+ SELECT id, total_cents
241
+ FROM orders
242
+ WHERE account_id = $1 AND id < $2
243
+ ORDER BY id DESC
244
+ LIMIT $3;
245
+ ```
246
+
247
+ ## Bounded DELETE through a CTE
248
+
249
+ DELETE has no `LIMIT`, so select the ids in a CTE and delete by join. The partial index serves the
250
+ retention predicate, and `$1` is the cutoff and `$2` the batch size:
251
+
252
+ ```sql
253
+ CREATE INDEX idx_invoice_delivery_work_items__completed_at
254
+ ON invoice_delivery_work_items (completed_at, id)
255
+ WHERE completed_at IS NOT NULL;
256
+
257
+ WITH expired AS (
258
+ SELECT id
259
+ FROM invoice_delivery_work_items
260
+ WHERE completed_at IS NOT NULL AND completed_at < $1
261
+ ORDER BY completed_at, id
262
+ LIMIT $2
263
+ FOR UPDATE SKIP LOCKED
264
+ )
265
+ DELETE FROM invoice_delivery_work_items AS item
266
+ USING expired
267
+ WHERE item.id = expired.id;
268
+ ```
269
+
270
+ Report `hasMore` when the deleted row count equals the batch size, and let the next run continue.
271
+
272
+ ## Deduplicate against a large table
273
+
274
+ Do not load every existing key to diff against a candidate list. Ask the table about the
275
+ candidates only, in chunks whose size is a bounded tunable. The candidates themselves come from a
276
+ capped page:
277
+
278
+ ```sql
279
+ SELECT external_ref
280
+ FROM order_imports
281
+ WHERE account_id = $1 AND external_ref = ANY($2::text[]);
282
+ ```
283
+
284
+ ```ts
285
+ const known = new Set<string>()
286
+ for (const chunk of chunks(candidates, config.lookupChunkSize)) {
287
+ const rows = await db.query(findKnownRefsSql, [accountId, chunk])
288
+ for (const row of rows) known.add(row.external_ref)
289
+ }
290
+ const fresh = candidates.filter((ref) => !known.has(ref))
291
+ ```
292
+
293
+ When the goal is only to insert the new rows, `INSERT ... ON CONFLICT DO NOTHING RETURNING` skips
294
+ the lookup.
295
+
296
+ ## Work that needs every key
297
+
298
+ A membership filter or a reindex needs every key, but not on every run. Keep the derived state
299
+ current on write: the insert or a dirty marker updates it. A full rebuild is a backfill started on
300
+ purpose, with a row cap per run and a keyset cursor to resume from, as in the high-water-mark
301
+ window. A scheduler never starts it as a pass over everything.
302
+
303
+ ## Verify with a plan
304
+
305
+ Run each statement with representative parameters against representative table sizes, because a
306
+ small table scans sequentially for good reason. The `due` query of the claim above should plan as
307
+ a `Limit` over a `LockRows` over an index scan on the partial index, with no `Sort` between the
308
+ scan and the `Limit`. Read the rows each node returns, not only the scan type: an index scan that
309
+ feeds a `Sort` below the `Limit` still reads every match. A `Seq Scan` on a table that grows is
310
+ also a finding:
311
+
312
+ ```sql
313
+ PREPARE claim_due (integer) AS
314
+ SELECT id
315
+ FROM invoice_delivery_work_items
316
+ WHERE completed_at IS NULL AND available_at <= now()
317
+ ORDER BY available_at, id
318
+ LIMIT $1
319
+ FOR UPDATE SKIP LOCKED;
320
+
321
+ BEGIN;
322
+ EXPLAIN (ANALYZE, BUFFERS) EXECUTE claim_due (200);
323
+ ROLLBACK;
324
+ ```
325
+
326
+ `EXPLAIN ANALYZE` runs the statement, so check the claim `UPDATE` or the `DELETE` inside a
327
+ transaction that you roll back, or on a disposable copy. Under contention, `SKIP LOCKED` passes over rows other claimers hold, and those rows sit under the
328
+ `Limit`, so the scan reads the cap plus the rows concurrent claims hold. That stays bounded by the
329
+ number of workers times the batch size when claims are short, so keep the claim transaction to the
330
+ claim statement, and check the index scan's actual rows while several workers claim at once.
331
+
332
+ ## Anti-patterns
333
+
334
+ | Anti-pattern | Why it is unbounded | Bounded shape |
335
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------ |
336
+ | Loop until empty: `while (true)` claim, process, break on 0 | New and failing rows keep the run alive; the run has no end | A batch or deadline cap, `hasMore`, and a continuation |
337
+ | A stream with no cap: `for await` over every account | Memory stays flat while run time and database work grow with the table | Stream a keyset window and cap the rows per run |
338
+ | A key-only walk: select every account id, then check each | Touches every entity to find the few with work | Read work items, dirty markers, or a due timestamp |
339
+ | A literal `LIMIT 500` in SQL text | Cannot be tuned per environment or during an incident | A validated tunable with a hard maximum, bound as `LIMIT $1` |
340
+ | `Promise.all(accounts.map(sync))` over a result of any size | One task per row exhausts the pool or the upstream API | A bounded batch with bounded concurrency |
341
+ | `OFFSET` pagination | Every page rescans and discards the rows before it | Keyset on the indexed key |
342
+ | `UPDATE` or `DELETE` with a predicate but no `LIMIT` | One statement locks and rewrites an unbounded row set in one transaction | A CTE that selects a capped, `SKIP LOCKED` id set |
@@ -21,6 +21,12 @@
21
21
  "pluginVersion": "0.11.0",
22
22
  "path": "blackboard/SKILL.md"
23
23
  },
24
+ {
25
+ "name": "bounded-iteration",
26
+ "plugin": "vouchington-database",
27
+ "pluginVersion": "0.4.0",
28
+ "path": "bounded-iteration/SKILL.md"
29
+ },
24
30
  {
25
31
  "name": "dependabot",
26
32
  "plugin": "vouchington-workflow",
@@ -103,19 +109,19 @@
103
109
  {
104
110
  "name": "postgres-node-performance-tuning",
105
111
  "plugin": "vouchington-database",
106
- "pluginVersion": "0.3.0",
112
+ "pluginVersion": "0.4.0",
107
113
  "path": "postgres-node-performance-tuning/SKILL.md"
108
114
  },
109
115
  {
110
116
  "name": "postgres-partitioning-uuid-v7",
111
117
  "plugin": "vouchington-database",
112
- "pluginVersion": "0.3.0",
118
+ "pluginVersion": "0.4.0",
113
119
  "path": "postgres-partitioning-uuid-v7/SKILL.md"
114
120
  },
115
121
  {
116
122
  "name": "postgres-schema-design",
117
123
  "plugin": "vouchington-database",
118
- "pluginVersion": "0.3.0",
124
+ "pluginVersion": "0.4.0",
119
125
  "path": "postgres-schema-design/SKILL.md"
120
126
  },
121
127
  {
@@ -12,7 +12,8 @@ Local policy overrides these defaults; do not reload an already-read adapter or
12
12
  Measure the query plan and the workload shape before changing code.
13
13
 
14
14
  - Select only the required columns.
15
- - Bound each result set. Paginate or stream a large read.
15
+ - Bound each result set. Paginate or stream a large read. A stream bounds memory, not work: cap
16
+ the rows per run as in [bounded iteration](../bounded-iteration/SKILL.md).
16
17
  - Batch writes inside an explicit transaction limit.
17
18
  - Keep connection-pool usage bounded.
18
19
  - Validate a query change at representative cardinality. Watch latency, memory, lock time, and
@@ -18,4 +18,6 @@ Local policy overrides these portable conventions.
18
18
 
19
19
  For query shape, read [performance patterns](../postgres-node-performance-tuning/references/performance-patterns.md).
20
20
  For partition lifecycle, use [UUIDv7 partitioning](../postgres-partitioning-uuid-v7/SKILL.md).
21
+ For how jobs choose, cap, and resume over work-item and cursor tables, read
22
+ [bounded iteration](../bounded-iteration/SKILL.md).
21
23
  Consumer policy owns lifecycle, retention, migration strategy, and enforcement configuration.