vibecodeagent 0.0.0-stage → 0.1.65

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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/THIRD-PARTY-NOTICES.txt +1427 -0
  4. package/dist/agen.js +138920 -0
  5. package/dist/bin.js +1245 -0
  6. package/dist/daemon.js +163701 -0
  7. package/dist/node_modules/node-ensure/index.js +10 -0
  8. package/dist/node_modules/node-ensure/package.json +31 -0
  9. package/dist/node_modules/pdf-parse/lib/pdf-parse.js +102 -0
  10. package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js +18538 -0
  11. package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.worker.js +45507 -0
  12. package/dist/node_modules/pdf-parse/package.json +49 -0
  13. package/dist/node_modules/sql.js/dist/sql-wasm.js +186 -0
  14. package/dist/node_modules/sql.js/dist/sql-wasm.wasm +0 -0
  15. package/dist/node_modules/sql.js/package.json +58 -0
  16. package/dist/resources/skills/builtin/.gitkeep +0 -0
  17. package/dist/resources/skills/builtin/NOTICE.md +35 -0
  18. package/dist/resources/skills/builtin/README.md +74 -0
  19. package/dist/resources/skills/builtin/backend-engineering/SKILL.md +102 -0
  20. package/dist/resources/skills/builtin/backend-engineering/references/api-contracts.md +320 -0
  21. package/dist/resources/skills/builtin/backend-engineering/references/auth-and-secrets.md +268 -0
  22. package/dist/resources/skills/builtin/backend-engineering/references/concurrency-and-jobs.md +386 -0
  23. package/dist/resources/skills/builtin/backend-engineering/references/data-modelling.md +367 -0
  24. package/dist/resources/skills/builtin/backend-engineering/references/deploy-and-config.md +228 -0
  25. package/dist/resources/skills/builtin/backend-engineering/references/migrations.md +320 -0
  26. package/dist/resources/skills/builtin/backend-engineering/references/observability.md +301 -0
  27. package/dist/resources/skills/builtin/backend-engineering/references/plan-template.md +152 -0
  28. package/dist/resources/skills/builtin/backend-engineering/references/review-audit.md +213 -0
  29. package/dist/resources/skills/builtin/backend-engineering/references/security.md +251 -0
  30. package/dist/resources/skills/builtin/backend-engineering/references/stack-go.md +238 -0
  31. package/dist/resources/skills/builtin/backend-engineering/references/stack-node.md +274 -0
  32. package/dist/resources/skills/builtin/backend-engineering/references/stack-python.md +296 -0
  33. package/dist/resources/skills/builtin/backend-engineering/references/testing-and-perf.md +298 -0
  34. package/dist/resources/skills/builtin/backend-engineering/references/validation-and-errors.md +266 -0
  35. package/dist/resources/skills/builtin/dasar-vibecode/SKILL.md +312 -0
  36. package/dist/resources/skills/builtin/excel-maker/SKILL.md +71 -0
  37. package/dist/resources/skills/builtin/frontend-design/SKILL.md +131 -0
  38. package/dist/resources/skills/builtin/frontend-design/data/app-interface.csv +33 -0
  39. package/dist/resources/skills/builtin/frontend-design/data/catalog-summary.json +78 -0
  40. package/dist/resources/skills/builtin/frontend-design/data/charts.csv +26 -0
  41. package/dist/resources/skills/builtin/frontend-design/data/colors.csv +193 -0
  42. package/dist/resources/skills/builtin/frontend-design/data/data-provenance.json +1348 -0
  43. package/dist/resources/skills/builtin/frontend-design/data/google-font-licenses.json +20185 -0
  44. package/dist/resources/skills/builtin/frontend-design/data/google-fonts.csv +1935 -0
  45. package/dist/resources/skills/builtin/frontend-design/data/icons.csv +106 -0
  46. package/dist/resources/skills/builtin/frontend-design/data/landing.csv +35 -0
  47. package/dist/resources/skills/builtin/frontend-design/data/motion.csv +18 -0
  48. package/dist/resources/skills/builtin/frontend-design/data/phosphor-icons-upstream.json +32582 -0
  49. package/dist/resources/skills/builtin/frontend-design/data/products.csv +193 -0
  50. package/dist/resources/skills/builtin/frontend-design/data/react-performance.csv +45 -0
  51. package/dist/resources/skills/builtin/frontend-design/data/stacks/angular.csv +51 -0
  52. package/dist/resources/skills/builtin/frontend-design/data/stacks/astro.csv +54 -0
  53. package/dist/resources/skills/builtin/frontend-design/data/stacks/avalonia.csv +57 -0
  54. package/dist/resources/skills/builtin/frontend-design/data/stacks/flutter.csv +53 -0
  55. package/dist/resources/skills/builtin/frontend-design/data/stacks/html-tailwind.csv +60 -0
  56. package/dist/resources/skills/builtin/frontend-design/data/stacks/javafx.csv +76 -0
  57. package/dist/resources/skills/builtin/frontend-design/data/stacks/jetpack-compose.csv +53 -0
  58. package/dist/resources/skills/builtin/frontend-design/data/stacks/laravel.csv +51 -0
  59. package/dist/resources/skills/builtin/frontend-design/data/stacks/nextjs.csv +62 -0
  60. package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxt-ui.csv +71 -0
  61. package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxtjs.csv +68 -0
  62. package/dist/resources/skills/builtin/frontend-design/data/stacks/react-native.csv +52 -0
  63. package/dist/resources/skills/builtin/frontend-design/data/stacks/react.csv +62 -0
  64. package/dist/resources/skills/builtin/frontend-design/data/stacks/shadcn.csv +69 -0
  65. package/dist/resources/skills/builtin/frontend-design/data/stacks/svelte.csv +56 -0
  66. package/dist/resources/skills/builtin/frontend-design/data/stacks/swiftui.csv +51 -0
  67. package/dist/resources/skills/builtin/frontend-design/data/stacks/threejs.csv +54 -0
  68. package/dist/resources/skills/builtin/frontend-design/data/stacks/uno.csv +60 -0
  69. package/dist/resources/skills/builtin/frontend-design/data/stacks/uwp.csv +56 -0
  70. package/dist/resources/skills/builtin/frontend-design/data/stacks/vue.csv +50 -0
  71. package/dist/resources/skills/builtin/frontend-design/data/stacks/winui.csv +60 -0
  72. package/dist/resources/skills/builtin/frontend-design/data/stacks/wpf.csv +57 -0
  73. package/dist/resources/skills/builtin/frontend-design/data/styles.csv +89 -0
  74. package/dist/resources/skills/builtin/frontend-design/data/typography.csv +75 -0
  75. package/dist/resources/skills/builtin/frontend-design/data/ui-reasoning.csv +193 -0
  76. package/dist/resources/skills/builtin/frontend-design/data/ux-guidelines.csv +120 -0
  77. package/dist/resources/skills/builtin/frontend-design/references/animation-patterns.md +705 -0
  78. package/dist/resources/skills/builtin/frontend-design/references/app-polish.md +117 -0
  79. package/dist/resources/skills/builtin/frontend-design/references/apple-fluid.md +278 -0
  80. package/dist/resources/skills/builtin/frontend-design/references/art-direction.md +280 -0
  81. package/dist/resources/skills/builtin/frontend-design/references/case-studies.md +599 -0
  82. package/dist/resources/skills/builtin/frontend-design/references/craft.md +128 -0
  83. package/dist/resources/skills/builtin/frontend-design/references/expo.md +251 -0
  84. package/dist/resources/skills/builtin/frontend-design/references/layout-systems.md +533 -0
  85. package/dist/resources/skills/builtin/frontend-design/references/libraries.md +72 -0
  86. package/dist/resources/skills/builtin/frontend-design/references/motion.md +222 -0
  87. package/dist/resources/skills/builtin/frontend-design/references/picker.md +197 -0
  88. package/dist/resources/skills/builtin/frontend-design/references/plan-template.md +73 -0
  89. package/dist/resources/skills/builtin/frontend-design/references/prototype.md +85 -0
  90. package/dist/resources/skills/builtin/frontend-design/references/recipes-expo.md +385 -0
  91. package/dist/resources/skills/builtin/frontend-design/references/recipes-web.md +421 -0
  92. package/dist/resources/skills/builtin/frontend-design/references/review-audit.md +107 -0
  93. package/dist/resources/skills/builtin/frontend-design/references/sonner.md +143 -0
  94. package/dist/resources/skills/builtin/frontend-design/references/technical-stack.md +543 -0
  95. package/dist/resources/skills/builtin/frontend-design/references/theming.md +60 -0
  96. package/dist/resources/skills/builtin/frontend-design/references/typography.md +393 -0
  97. package/dist/resources/skills/builtin/frontend-design/references/ux-rules.md +256 -0
  98. package/dist/resources/skills/builtin/frontend-design/references/vocabulary.md +169 -0
  99. package/dist/resources/skills/builtin/frontend-design/scripts/core.py +993 -0
  100. package/dist/resources/skills/builtin/frontend-design/scripts/design_system.py +1643 -0
  101. package/dist/resources/skills/builtin/frontend-design/scripts/reasoning_contract.py +123 -0
  102. package/dist/resources/skills/builtin/frontend-design/scripts/search.py +171 -0
  103. package/dist/resources/skills/builtin/frontend-design/scripts/validate_data.py +1091 -0
  104. package/dist/resources/skills/builtin/kerja-agentik/SKILL.md +117 -0
  105. package/dist/resources/skills/builtin/pdf-maker/SKILL.md +51 -0
  106. package/dist/resources/skills/builtin/tinjau-keamanan/SKILL.md +53 -0
  107. package/dist/resources/skills/builtin/word-maker/SKILL.md +58 -0
  108. package/dist/resources/skills/builtin/write-swift/SKILL.md +388 -0
  109. package/package.json +35 -4
@@ -0,0 +1,386 @@
1
+ # Concurrency and Deferred Work
2
+
3
+ This file owns the mechanics of two things happening at once and of work that happens later: idempotency storage, locks, transaction boundaries, isolation levels, the outbox, queues, retries, and scheduled jobs. The idempotency **contract** — the `Idempotency-Key` header, which endpoints require it, the status code the client sees — belongs to [api-contracts.md](api-contracts.md); this file is what happens behind it. Queue-depth alerts and consumer-lag SLOs are [observability.md](observability.md). Process lifecycle for the HTTP server, pool sizes, and timeout config are [deploy-and-config.md](deploy-and-config.md). Locking behaviour of DDL against a live table is [migrations.md](migrations.md).
4
+
5
+ ## Operating Posture
6
+
7
+ Every write is a concurrency question. Answer it before you write the handler, not after the incident.
8
+
9
+ Two failure modes, and the first is worse:
10
+
11
+ 1. **The duplicate nobody sees.** The job ran twice, the card was charged twice, the email went out twice. Nothing in the logs is red. You learn about it from a customer, days later, and the money already moved.
12
+ 2. **The stall.** A connection held across a third-party timeout drains the pool, and endpoints that never touched your code start returning 500. Loud, ugly, and fixed by a restart.
13
+
14
+ **Never invent a retry policy, a backoff schedule, a visibility timeout, or a TTL.** Take them from the tables below or from the project's own config. Never write `time.sleep(2 ** attempt)` with no cap and no jitter because it looks familiar — that line *is* the retry storm, written out.
15
+
16
+ ### Gate: does this need to be asynchronous at all?
17
+
18
+ | Situation | Answer |
19
+ | --- | --- |
20
+ | Work is <100ms and the caller needs the result | Inline. Write no job. |
21
+ | Work is slow but the caller needs the result | Inline, with a timeout; if that's unacceptable, the endpoint returns `202` + a status resource ([api-contracts.md](api-contracts.md)) |
22
+ | Work must survive the request dying (charge, email, webhook fan-out) | Queue |
23
+ | Work is triggered by time, not by a request | Scheduler that **enqueues** — see *Cron is not a queue* |
24
+ | "It feels cleaner as a job" | Inline. Say so and move on. |
25
+
26
+ A queue you added for tidiness costs a consumer process, a DLQ, an alert, a second idempotency story, and a new class of bug where the row is committed but the job hasn't run yet. Producing zero background jobs is a valid outcome.
27
+
28
+ ## 1. Idempotency Keys — the mechanics
29
+
30
+ ### Storage shape
31
+
32
+ One row per key, written before the work starts.
33
+
34
+ ```sql
35
+ CREATE TABLE idempotency_keys (
36
+ tenant_id uuid NOT NULL, -- scope: see the leak below
37
+ key text NOT NULL, -- client-supplied, opaque
38
+ fingerprint bytea NOT NULL, -- sha256(method | path | raw body)
39
+ state text NOT NULL, -- 'in_progress' | 'completed'
40
+ status_code int, -- replayed verbatim
41
+ response_body jsonb, -- replayed verbatim
42
+ created_at timestamptz NOT NULL DEFAULT now(),
43
+ expires_at timestamptz NOT NULL, -- created_at + 24 hours
44
+ PRIMARY KEY (tenant_id, key)
45
+ );
46
+ CREATE INDEX ON idempotency_keys (expires_at);
47
+ ```
48
+
49
+ **The key is scoped to the tenant, never global.** A global key space means tenant B sends `Idempotency-Key: abc` and gets tenant A's stored response body back. That is not a race condition, it is an authorization leak with a concurrency costume on.
50
+
51
+ **Fingerprint the raw bytes, before parsing.** Hash the body as received. If you hash the parsed struct after defaults are applied, the day you change a default the same retry produces a different fingerprint and every in-flight client retry starts failing with a mismatch — a bug that appears at deploy time and disappears when you roll back, which is the hardest kind to read.
52
+
53
+ **TTL is 24 hours** (the same window Stripe keeps results for). Long enough that a client's retry queue drains, short enough that the table stays small. Delete expired rows on a schedule; do not rely on the index alone.
54
+
55
+ ### The algorithm
56
+
57
+ The insert *is* the lock. There is no `SELECT` first — that is the classic check-then-act race.
58
+
59
+ ```sql
60
+ INSERT INTO idempotency_keys (tenant_id, key, fingerprint, state, expires_at)
61
+ VALUES ($1, $2, $3, 'in_progress', now() + interval '24 hours')
62
+ ON CONFLICT (tenant_id, key) DO NOTHING
63
+ RETURNING key;
64
+ ```
65
+
66
+ 1. **1 row returned** → you own this key. Execute the work. In the *same* transaction as the business write, `UPDATE` the row to `state='completed'` with `status_code` and `response_body`. Commit. Return the response.
67
+ 2. **0 rows returned** → someone got there first. `SELECT` the existing row and branch:
68
+
69
+ | Existing row | Response |
70
+ | --- | --- |
71
+ | `state='completed'`, fingerprint matches | Replay `status_code` + `response_body` verbatim. Add `Idempotency-Replayed: true`. |
72
+ | `state='in_progress'`, fingerprint matches | `409` with `Retry-After: 1`. The first request is still running; do not queue behind it, do not execute. |
73
+ | Fingerprint differs, any state | `422`, code `idempotency_key_reuse`. The key was reused for a different request — that is a client bug, and executing it would silently corrupt their retry model. |
74
+
75
+ The `409`-while-in-progress and `422`-on-mismatch mapping is the one in the IETF `Idempotency-Key` header draft; keep it identical to whatever [api-contracts.md](api-contracts.md) states.
76
+
77
+ 3. **On failure**, delete the `in_progress` row (or mark it failed) so a genuine retry can proceed. A crashed request that leaves `in_progress` behind forever turns a retryable failure into a permanent `409`; expiry is the backstop, and 24 hours of `409` is not a backstop anyone enjoys.
78
+
79
+ **The stored response must be written in the same transaction as the effect it describes.** Two transactions means a window where the charge exists and the key row says `in_progress`, and the retry re-charges.
80
+
81
+ ## 2. Optimistic Locking — a version column
82
+
83
+ For rows a human edits, where conflicts are rare and a retry is acceptable.
84
+
85
+ ```sql
86
+ UPDATE documents
87
+ SET title = $1, body = $2, version = version + 1, updated_at = now()
88
+ WHERE id = $3 AND version = $4;
89
+ ```
90
+
91
+ **Check the affected row count. Always.** `rowcount = 1` succeeded; `rowcount = 0` means someone else committed between the read and the write. Map that to `409` with a code the client can branch on (`version_conflict`), and include the current version in the problem detail so a UI can offer a merge.
92
+
93
+ `rowcount = 0` is ambiguous — the row may have been deleted rather than updated. If the difference matters to the client, follow with `SELECT 1 FROM documents WHERE id = $3`: absent → `404`, present → `409`.
94
+
95
+ The symptom of a missing version check is not an error. It is the last write silently winning: two editors save, the second overwrites the first, both see success, and the lost edit is discovered a week later with no log line anywhere.
96
+
97
+ - `updated_at` is **not** a version. Two writes inside the same clock tick compare equal, and clock skew across app servers makes it worse.
98
+ - A monotonic `integer` is enough. Don't reach for a hash of the row.
99
+ - Use optimistic locking when conflicts are rare; use pessimistic locking when they are the normal case (counters, inventory, queue claims).
100
+
101
+ ## 3. Pessimistic Locking
102
+
103
+ `SELECT ... FOR UPDATE` inside a transaction takes a row lock until commit. It is correct for read-modify-write where you cannot tolerate a retry loop.
104
+
105
+ ```sql
106
+ BEGIN;
107
+ SELECT balance FROM accounts WHERE id = $1 FOR UPDATE; -- blocks other writers
108
+ UPDATE accounts SET balance = balance - $2 WHERE id = $1;
109
+ COMMIT;
110
+ ```
111
+
112
+ | Clause | Use it when |
113
+ | --- | --- |
114
+ | `FOR UPDATE` | You will modify the row or its key columns |
115
+ | `FOR NO KEY UPDATE` | You will only touch non-key columns — it does not block the `FOR KEY SHARE` lock a foreign-key insert takes, so child inserts keep flowing |
116
+ | `FOR SHARE` | You need the row to stay unchanged for the rest of the transaction but won't write it |
117
+ | `FOR UPDATE SKIP LOCKED` | Queue-like table: hand each worker a different row |
118
+ | `FOR UPDATE NOWAIT` | Fail immediately rather than wait — raises `55P03`, map to `409` |
119
+
120
+ ### Queue-like tables: SKIP LOCKED
121
+
122
+ Never `SELECT ... FOR UPDATE` the head of a jobs table from N workers — they serialize into a single-file line. `SKIP LOCKED` makes each worker step over rows already claimed:
123
+
124
+ ```sql
125
+ UPDATE jobs SET state = 'running', started_at = now(), attempts = attempts + 1
126
+ WHERE id IN (
127
+ SELECT id FROM jobs
128
+ WHERE state = 'queued' AND run_after <= now()
129
+ ORDER BY run_after
130
+ FOR UPDATE SKIP LOCKED
131
+ LIMIT 20
132
+ )
133
+ RETURNING *;
134
+ ```
135
+
136
+ `LIMIT 20` is a batch, not a magic number: bigger batches amortize round-trips but extend the transaction and widen the redelivery window on a crash. Keep the whole claim under 50ms.
137
+
138
+ ### Deadlock, worked
139
+
140
+ Two transfers run at once. T1 moves money from account 1 to 2; T2 moves from 2 to 1.
141
+
142
+ ```
143
+ T1: BEGIN; SELECT ... WHERE id = 1 FOR UPDATE; -- holds 1
144
+ T2: BEGIN; SELECT ... WHERE id = 2 FOR UPDATE; -- holds 2
145
+ T1: SELECT ... WHERE id = 2 FOR UPDATE; -- waits on T2
146
+ T2: SELECT ... WHERE id = 1 FOR UPDATE; -- waits on T1 → cycle
147
+ ```
148
+
149
+ Postgres runs deadlock detection after `deadlock_timeout` (default `1s`), then kills one transaction with SQLSTATE `40P01`. So the symptom is not a hang: it is a 1-second latency spike plus a random failure under load, and it is invisible in staging where only one request runs at a time.
150
+
151
+ **The fix is a consistent acquisition order, not a retry.** Sort the ids and lock ascending, always:
152
+
153
+ ```sql
154
+ SELECT id, balance FROM accounts
155
+ WHERE id = ANY($1) ORDER BY id
156
+ FOR UPDATE; -- $1 = [least(from,to), greatest(from,to)]
157
+ ```
158
+
159
+ The same rule generalizes: if two code paths lock `orders` and `inventory`, every path locks `orders` first. Write the order down in a comment at both call sites.
160
+
161
+ ### Locking something that isn't a row
162
+
163
+ Advisory locks. `pg_try_advisory_xact_lock(hashtext('import:tenant-42'))` returns `false` instead of waiting and releases at commit — correct for "only one importer per tenant". Session-scoped `pg_try_advisory_lock` must be released explicitly and leaks on a crashed connection until the backend dies.
164
+
165
+ ## 4. Isolation Levels
166
+
167
+ | Level | Still permits (in Postgres) | Use for |
168
+ | --- | --- | --- |
169
+ | `READ COMMITTED` (default) | Non-repeatable reads, phantoms, **lost updates via read-modify-write in application code** | Almost everything |
170
+ | `REPEATABLE READ` | Write skew (two transactions each read a consistent snapshot and write a combination neither would allow) | Multi-statement reports that must see one snapshot |
171
+ | `SERIALIZABLE` | Nothing — SSI aborts one of the offenders | Invariants across rows: "at least one doctor on call", balance checks spanning accounts |
172
+
173
+ Postgres permits no dirty reads at any level, so `READ UNCOMMITTED` is a synonym for `READ COMMITTED` there. `REPEATABLE READ` in Postgres is snapshot isolation and already prevents phantom reads — stricter than the SQL standard requires, and still not enough for write skew.
174
+
175
+ **The trap that bites everyone:** `READ COMMITTED` does not prevent a lost update when the read-modify-write happens in your process.
176
+
177
+ ```python
178
+ row = db.query("SELECT balance FROM accounts WHERE id = 1") # 100
179
+ new = row.balance - 10 # 90 in Python
180
+ db.exec("UPDATE accounts SET balance = %s WHERE id = 1", new) # writes 90
181
+ ```
182
+
183
+ Two concurrent runs both read 100 and both write 90. One withdrawal vanished. There is no error, and the row count is 1 both times. Three real fixes: do the arithmetic in SQL (`SET balance = balance - 10`, which Postgres re-evaluates against the updated row at `READ COMMITTED`), take `FOR UPDATE` on the read, or use a version column. Raising the isolation level to `REPEATABLE READ` turns the silent loss into a `40001` error — which is progress only if you retry it.
184
+
185
+ ### Serialization failures must be retried
186
+
187
+ `40001` (serialization_failure) and `40P01` (deadlock_detected) are not bugs to log; they are the database telling you to run it again.
188
+
189
+ ```
190
+ Retry only on SQLSTATE 40001 and 40P01. Max 3 attempts.
191
+ Backoff: 25ms, 50ms, 100ms, each × random(0.5, 1.5).
192
+ ```
193
+
194
+ **The retry lives outside the transaction, wrapping it.** A retry inside the transaction is impossible — the transaction is already aborted, and every statement after the failure returns `25P02`. Which means the retried function must be safely re-runnable end to end: it may contain database work and pure computation, and nothing else. If the function sends an email, the retry sends a second email. See the next section.
195
+
196
+ ## 5. Never Do Network I/O Inside a Transaction
197
+
198
+ Not an HTTP call, not a queue publish, not an S3 upload, not a cache write, not a remote log flush, not a `sleep`. This is its own section because it is the single most expensive concurrency mistake, and it never shows up in code review as a concurrency mistake — it looks like tidy code that does everything in one place.
199
+
200
+ The failure, in order:
201
+
202
+ 1. `BEGIN` checks a connection out of the pool. The pool has 10.
203
+ 2. Inside the transaction you call a payment provider. Node's `fetch` and Python's `requests` have **no default timeout** — the call can hang until the OS gives up, minutes later.
204
+ 3. That connection is pinned for the whole hang. Ten concurrent requests to this endpoint and the pool is empty.
205
+ 4. Every *other* endpoint now waits on connection acquisition and fails. Your dashboard shows `/users` and `/health` timing out while the actual dependency that broke was a payment provider nothing on that path touches.
206
+
207
+ Plus two slower harms that survive the incident: row locks held for the duration turn a 40ms endpoint into a lock queue, and a long-open transaction pins the `xmin` horizon so `VACUUM` cannot reclaim *any* dead row newer than it — the table bloats for as long as the call hangs.
208
+
209
+ **Rules:**
210
+
211
+ - A transaction contains database statements and pure computation. Nothing else.
212
+ - Do the network call **before** the transaction (and make the call itself idempotent with a key), or **after** it via the outbox below.
213
+ - Need to react to the provider's answer? Two transactions and a state machine: `pending` → call → `confirmed`/`failed`. The intermediate state is visible and recoverable; a held connection is neither.
214
+ - Set the guardrails at the connection level so a mistake is loud rather than silent:
215
+
216
+ ```sql
217
+ SET statement_timeout = '5s'; -- default 0 = unlimited
218
+ SET idle_in_transaction_session_timeout = '10s'; -- default 0 = forever
219
+ ```
220
+
221
+ `idle_in_transaction_session_timeout` is the one that catches this class directly: a transaction sitting idle while your code awaits a socket gets killed instead of holding the connection. Budget transaction wall time under 100ms and treat anything over 1s as a defect to explain.
222
+
223
+ ## 6. The Outbox Pattern
224
+
225
+ The answer to "write to the database and publish an event". You cannot do both atomically across two systems, so stop trying.
226
+
227
+ The dual-write failures, both real:
228
+
229
+ - **Commit, then publish**, and the process dies in between → the row exists and the event never happens. Nothing is logged. The downstream service is permanently out of sync and nobody finds out until a reconciliation nobody wrote.
230
+ - **Publish, then commit**, and the commit rolls back → consumers receive an event about a state that does not exist. They read the database, find nothing, and the bug reads as a race in the *consumer*, which is where the next three days of debugging go.
231
+
232
+ The outbox makes the event part of the transaction:
233
+
234
+ ```sql
235
+ CREATE TABLE outbox (
236
+ id bigserial PRIMARY KEY, -- monotonic: publish order
237
+ aggregate_type text NOT NULL,
238
+ aggregate_id text NOT NULL, -- partition key at the broker
239
+ event_type text NOT NULL,
240
+ payload jsonb NOT NULL,
241
+ created_at timestamptz NOT NULL DEFAULT now(),
242
+ published_at timestamptz
243
+ );
244
+ CREATE INDEX ON outbox (id) WHERE published_at IS NULL; -- partial: stays tiny
245
+ ```
246
+
247
+ 1. In the business transaction: `INSERT INTO orders ...` and `INSERT INTO outbox ...`. One commit, both or neither.
248
+ 2. A relay polls every **500ms**, claims a batch with `SKIP LOCKED`, publishes, marks published:
249
+
250
+ ```sql
251
+ SELECT * FROM outbox WHERE published_at IS NULL
252
+ ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;
253
+ -- publish each (outside the claim transaction if the broker is slow)
254
+ UPDATE outbox SET published_at = now() WHERE id = ANY($1);
255
+ ```
256
+
257
+ 3. Crash after publishing and before the `UPDATE` → the event is published twice. **That is the design, not a leak.** The outbox is at-least-once; the consumer is idempotent (section 7).
258
+ 4. Delete or partition-drop rows older than 7 days. An outbox nobody prunes becomes the largest table in the database.
259
+
260
+ Per-aggregate ordering holds only if one relay publishes a given `aggregate_id` at a time and the broker partitions on it — a parallel relay sharded by `hash(aggregate_id)` preserves this; a relay sharded by `id % N` does not. If polling latency is unacceptable, replace the relay with logical decoding (Debezium reading the WAL) — same table, same guarantees, no poll interval.
261
+
262
+ ## 7. Queues, Retries, and Dead Letters
263
+
264
+ **At-least-once is the default, therefore every consumer must be idempotent.** Exactly-once *delivery* across a network does not exist; what vendors sell under that name is at-least-once plus deduplication with a window (SQS FIFO dedupes over 5 minutes) or transactional scope (Kafka EOS holds only inside Kafka). Neither covers your side effect. Assume every message arrives twice and make that boring.
265
+
266
+ | Operation | How to make it idempotent |
267
+ | --- | --- |
268
+ | Insert a row | `INSERT ... ON CONFLICT DO NOTHING` on a natural or message-derived key |
269
+ | Update a field | Set an absolute value, never `x = x + 1` |
270
+ | Increment a counter | Insert a ledger row keyed by `message_id`, then aggregate |
271
+ | Charge a card | Pass the message id as the provider's idempotency key |
272
+ | Send an email | Unique constraint on `(user_id, template, dedupe_window)` before sending |
273
+ | Call a non-idempotent third party | A `processed_messages(message_id)` table with a unique constraint, written in the same transaction as the effect |
274
+
275
+ **Send an id, not the entity.** Payloads go stale between publish and consume, and brokers cap size (SQS 256 KiB, Kafka's `message.max.bytes` ~1 MiB by default). The consumer re-reads current state — from the **primary**, not a read replica: a consumer fast enough to beat replication finds no row and fails with "not found", which reads as a queue bug and is actually replica lag.
276
+
277
+ ### Backoff with jitter
278
+
279
+ ```
280
+ delay = random_between(0, min(cap, base * 2 ** attempt))
281
+ base = 1s cap = 300s max_attempts = 5
282
+ ```
283
+
284
+ | Attempt | Delay drawn from |
285
+ | --- | --- |
286
+ | 1 | 0–1s |
287
+ | 2 | 0–2s |
288
+ | 3 | 0–4s |
289
+ | 4 | 0–8s |
290
+ | 5 | 0–16s → then DLQ |
291
+
292
+ **Jitter is the part people delete because it looks unnecessary.** Without it, every consumer that failed during a downstream outage retries at the identical instant — a synchronized wall of traffic that re-kills the dependency in the same second it recovers, then re-synchronizes on the next failure. The dependency looks like it is flapping; it is being flapped. AWS's own backoff-and-jitter measurements put full jitter ahead of both plain exponential and "exponential plus a little jitter" on total work and completion time. Use the formula above; do not soften it into `delay * random(0.9, 1.1)`.
293
+
294
+ ### Non-retryable errors go straight to the DLQ
295
+
296
+ Retrying a validation error five times produces five guaranteed failures and delays the DLQ alert by four minutes.
297
+
298
+ | Error class | Retry? |
299
+ | --- | --- |
300
+ | Network timeout, `5xx`, `429`, connection reset | Yes, with backoff |
301
+ | Serialization failure / deadlock (`40001`, `40P01`) | Yes, immediately (section 4) |
302
+ | Malformed payload, schema mismatch, unknown event type | No — DLQ on attempt 1 |
303
+ | `4xx` from a downstream that is not `429` | No — DLQ on attempt 1 |
304
+ | Referenced entity permanently deleted | No — ack and drop, with a log line |
305
+
306
+ ### DLQ and poison messages
307
+
308
+ A dead-letter queue is a queue with an owner, not a graveyard. It needs: an alert on `depth > 0 sustained 15 minutes` ([observability.md](observability.md)), enough context stored to diagnose (original payload, last error, attempt count), and a documented replay path. **Never auto-replay a DLQ into its source queue** — a poison message loops forever and the loop is invisible because each individual delivery looks like a first attempt.
309
+
310
+ **Visibility timeout must exceed p99 handler time — use 3×.** If the handler takes longer than the timeout, the broker redelivers while the first worker is still running: two workers on the same row, duplicate side effects, and a duplicate rate that rises with load, which is exactly when nobody has time to read it. Handlers that can legitimately exceed the timeout must heartbeat/extend, not raise the timeout for everyone.
311
+
312
+ ## 8. Cron Is Not a Queue
313
+
314
+ Cron has no retries, no visibility, no backpressure, no dead-letter path, and its failure output is an email to an address nobody configured. **A scheduled job's only job is to enqueue.** Put the actual work behind the queue, where retries, DLQ, and metrics already exist.
315
+
316
+ **Two instances, one schedule.** This is the default state of the world, not an edge case: Kubernetes documents `CronJob` as at-least-once ("may create two Jobs, or none"), `concurrencyPolicy: Forbid` only stops overlap the controller knows about, and any pair of app instances running an in-process scheduler will both fire. So every scheduled job takes a lock before doing anything:
317
+
318
+ ```sql
319
+ -- transaction-scoped, auto-released at COMMIT/ROLLBACK; returns false instead of waiting
320
+ SELECT pg_try_advisory_xact_lock(hashtext('job:nightly-invoices'));
321
+ ```
322
+
323
+ For a job too long to sit inside one transaction (which is most of them — see section 5), use a lease instead:
324
+
325
+ ```sql
326
+ UPDATE job_leases
327
+ SET owner = $1, expires_at = now() + interval '5 minutes'
328
+ WHERE name = 'nightly-invoices'
329
+ AND (owner IS NULL OR expires_at < now())
330
+ RETURNING name; -- 0 rows = someone else holds it, exit 0 quietly
331
+ ```
332
+
333
+ Heartbeat the lease every 60s while working, and make the expiry longer than the heartbeat interval by at least 3×. A lease that expires mid-run hands a second worker the same job — the exact thing the lock exists to prevent.
334
+
335
+ Three more decisions to make explicitly rather than by default:
336
+
337
+ - **Schedule in UTC.** A job at 02:30 local time runs twice on the fall-back day and zero times on the spring-forward day. Billing that runs twice is an incident.
338
+ - **Missed runs.** If the box was down at 03:00, does the 03:20 recovery run it? The silent default is "no". For invoicing and expiry sweeps that is wrong; decide, and encode the decision as a query over what still needs doing rather than a wall-clock trigger.
339
+ - **Overlap.** If run N is still going when N+1 fires, the lock skips N+1 for free — emit a counter for it. A skip counter that climbs is the earliest warning that the job no longer fits its interval.
340
+
341
+ ## 9. Graceful Consumer Shutdown
342
+
343
+ Ordered, and the order is the whole point. On `SIGTERM`:
344
+
345
+ 1. **Stop accepting.** Break the poll loop / pause the subscription. Do not fetch another message.
346
+ 2. **Finish in-flight**, with a deadline.
347
+ 3. **Ack** what completed. Nack or simply stop extending visibility on what didn't, so it redelivers cleanly.
348
+ 4. **Flush** metrics and traces, then close the DB pool.
349
+ 5. `exit 0`.
350
+
351
+ ```
352
+ drain_deadline = 25s # strictly under Kubernetes' 30s default
353
+ # terminationGracePeriodSeconds
354
+ ```
355
+
356
+ Exceed the grace period and the runtime sends `SIGKILL` mid-handler: the message redelivers and the work runs twice, so a sloppy shutdown converts every deploy into a duplicate-work event. If handlers can genuinely run longer than the grace period, either raise `terminationGracePeriodSeconds` deliberately and keep the drain deadline under it, or chunk the handler so it can checkpoint and resume.
357
+
358
+ **Never ack before doing the work.** Ack after the transaction commits. Ack-first plus a crash means the work is lost, the queue is empty, and there is no artifact anywhere saying it should have happened — the one queue failure mode with no evidence at all. Related: release locks and leases in a path that runs on the failure branch only if that is what you meant; a `finally` that nacks on success is a duplicate generator.
359
+
360
+ HTTP-server drain, readiness-probe ordering, and the deploy sequencing around it are [deploy-and-config.md](deploy-and-config.md).
361
+
362
+ ## Never Ship
363
+
364
+ | Never | Instead |
365
+ | --- | --- |
366
+ | `SELECT` then `INSERT` to enforce uniqueness | Unique constraint + `ON CONFLICT` |
367
+ | An `UPDATE` whose affected-row count you don't check | Check it; 0 rows is a `409` or a `404` |
368
+ | Read-modify-write in application code at `READ COMMITTED` | Arithmetic in SQL, `FOR UPDATE`, or a version column |
369
+ | Retrying `40001` inside the aborted transaction | Retry the whole transaction from outside, max 3 |
370
+ | Any network call between `BEGIN` and `COMMIT` | Before the transaction, or after it via the outbox |
371
+ | Commit-then-publish (or publish-then-commit) | Outbox table in the same transaction |
372
+ | A consumer that assumes one delivery | Idempotent by construction; at-least-once is the contract |
373
+ | Retries without jitter, or without a cap | `random(0, min(300s, 1s * 2**n))`, 5 attempts |
374
+ | Retrying a `4xx` or a parse error | Straight to DLQ on attempt 1 |
375
+ | A DLQ with no alert and no replay path | Alert on depth > 0 for 15 min; document the replay |
376
+ | Auto-replaying a DLQ into its source queue | Manual replay after a fix |
377
+ | Visibility timeout ≈ p99 handler time | 3× p99, or heartbeat/extend |
378
+ | Two code paths locking the same tables in different orders | One documented order, ascending by id |
379
+ | A scheduled job with no lock | Advisory lock or a heartbeated lease |
380
+ | Cron doing the work | Cron enqueues; the consumer works |
381
+ | Ack before commit | Ack after commit |
382
+ | Idempotency keys in a global namespace | Scoped to the tenant |
383
+
384
+ ## Output
385
+
386
+ Write the code. Then, in a few lines: the concurrency answer for this change ("two of these at once → the unique constraint rejects the second, client gets 409"), the exact retry/backoff/TTL values you used and where they came from, and what you verified — the two-concurrent-request check, not the single-request one. If you declined to add a queue, a lock, or an idempotency key, say which and why in one line; "this stays synchronous" is a complete answer. Deeper conflict scenarios and load-time failure hunting are [review-audit.md](review-audit.md).