vibecodeagent 0.0.0-stage → 0.1.64
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/LICENSE +21 -0
- package/README.md +37 -2
- package/THIRD-PARTY-NOTICES.txt +1427 -0
- package/dist/agen.js +138895 -0
- package/dist/bin.js +1245 -0
- package/dist/daemon.js +163664 -0
- package/dist/node_modules/node-ensure/index.js +10 -0
- package/dist/node_modules/node-ensure/package.json +31 -0
- package/dist/node_modules/pdf-parse/lib/pdf-parse.js +102 -0
- package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js +18538 -0
- package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.worker.js +45507 -0
- package/dist/node_modules/pdf-parse/package.json +49 -0
- package/dist/node_modules/sql.js/dist/sql-wasm.js +186 -0
- package/dist/node_modules/sql.js/dist/sql-wasm.wasm +0 -0
- package/dist/node_modules/sql.js/package.json +58 -0
- package/dist/resources/skills/builtin/.gitkeep +0 -0
- package/dist/resources/skills/builtin/NOTICE.md +35 -0
- package/dist/resources/skills/builtin/README.md +74 -0
- package/dist/resources/skills/builtin/backend-engineering/SKILL.md +102 -0
- package/dist/resources/skills/builtin/backend-engineering/references/api-contracts.md +320 -0
- package/dist/resources/skills/builtin/backend-engineering/references/auth-and-secrets.md +268 -0
- package/dist/resources/skills/builtin/backend-engineering/references/concurrency-and-jobs.md +386 -0
- package/dist/resources/skills/builtin/backend-engineering/references/data-modelling.md +367 -0
- package/dist/resources/skills/builtin/backend-engineering/references/deploy-and-config.md +228 -0
- package/dist/resources/skills/builtin/backend-engineering/references/migrations.md +320 -0
- package/dist/resources/skills/builtin/backend-engineering/references/observability.md +301 -0
- package/dist/resources/skills/builtin/backend-engineering/references/plan-template.md +152 -0
- package/dist/resources/skills/builtin/backend-engineering/references/review-audit.md +213 -0
- package/dist/resources/skills/builtin/backend-engineering/references/security.md +251 -0
- package/dist/resources/skills/builtin/backend-engineering/references/stack-go.md +238 -0
- package/dist/resources/skills/builtin/backend-engineering/references/stack-node.md +274 -0
- package/dist/resources/skills/builtin/backend-engineering/references/stack-python.md +296 -0
- package/dist/resources/skills/builtin/backend-engineering/references/testing-and-perf.md +298 -0
- package/dist/resources/skills/builtin/backend-engineering/references/validation-and-errors.md +266 -0
- package/dist/resources/skills/builtin/dasar-vibecode/SKILL.md +312 -0
- package/dist/resources/skills/builtin/excel-maker/SKILL.md +71 -0
- package/dist/resources/skills/builtin/frontend-design/SKILL.md +131 -0
- package/dist/resources/skills/builtin/frontend-design/data/app-interface.csv +33 -0
- package/dist/resources/skills/builtin/frontend-design/data/catalog-summary.json +78 -0
- package/dist/resources/skills/builtin/frontend-design/data/charts.csv +26 -0
- package/dist/resources/skills/builtin/frontend-design/data/colors.csv +193 -0
- package/dist/resources/skills/builtin/frontend-design/data/data-provenance.json +1348 -0
- package/dist/resources/skills/builtin/frontend-design/data/google-font-licenses.json +20185 -0
- package/dist/resources/skills/builtin/frontend-design/data/google-fonts.csv +1935 -0
- package/dist/resources/skills/builtin/frontend-design/data/icons.csv +106 -0
- package/dist/resources/skills/builtin/frontend-design/data/landing.csv +35 -0
- package/dist/resources/skills/builtin/frontend-design/data/motion.csv +18 -0
- package/dist/resources/skills/builtin/frontend-design/data/phosphor-icons-upstream.json +32582 -0
- package/dist/resources/skills/builtin/frontend-design/data/products.csv +193 -0
- package/dist/resources/skills/builtin/frontend-design/data/react-performance.csv +45 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/angular.csv +51 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/astro.csv +54 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/avalonia.csv +57 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/flutter.csv +53 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/html-tailwind.csv +60 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/javafx.csv +76 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/jetpack-compose.csv +53 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/laravel.csv +51 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/nextjs.csv +62 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxt-ui.csv +71 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxtjs.csv +68 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/react-native.csv +52 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/react.csv +62 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/shadcn.csv +69 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/svelte.csv +56 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/swiftui.csv +51 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/threejs.csv +54 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/uno.csv +60 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/uwp.csv +56 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/vue.csv +50 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/winui.csv +60 -0
- package/dist/resources/skills/builtin/frontend-design/data/stacks/wpf.csv +57 -0
- package/dist/resources/skills/builtin/frontend-design/data/styles.csv +89 -0
- package/dist/resources/skills/builtin/frontend-design/data/typography.csv +75 -0
- package/dist/resources/skills/builtin/frontend-design/data/ui-reasoning.csv +193 -0
- package/dist/resources/skills/builtin/frontend-design/data/ux-guidelines.csv +120 -0
- package/dist/resources/skills/builtin/frontend-design/references/animation-patterns.md +705 -0
- package/dist/resources/skills/builtin/frontend-design/references/app-polish.md +117 -0
- package/dist/resources/skills/builtin/frontend-design/references/apple-fluid.md +278 -0
- package/dist/resources/skills/builtin/frontend-design/references/art-direction.md +280 -0
- package/dist/resources/skills/builtin/frontend-design/references/case-studies.md +599 -0
- package/dist/resources/skills/builtin/frontend-design/references/craft.md +128 -0
- package/dist/resources/skills/builtin/frontend-design/references/expo.md +251 -0
- package/dist/resources/skills/builtin/frontend-design/references/layout-systems.md +533 -0
- package/dist/resources/skills/builtin/frontend-design/references/libraries.md +72 -0
- package/dist/resources/skills/builtin/frontend-design/references/motion.md +222 -0
- package/dist/resources/skills/builtin/frontend-design/references/picker.md +197 -0
- package/dist/resources/skills/builtin/frontend-design/references/plan-template.md +73 -0
- package/dist/resources/skills/builtin/frontend-design/references/prototype.md +85 -0
- package/dist/resources/skills/builtin/frontend-design/references/recipes-expo.md +385 -0
- package/dist/resources/skills/builtin/frontend-design/references/recipes-web.md +421 -0
- package/dist/resources/skills/builtin/frontend-design/references/review-audit.md +107 -0
- package/dist/resources/skills/builtin/frontend-design/references/sonner.md +143 -0
- package/dist/resources/skills/builtin/frontend-design/references/technical-stack.md +543 -0
- package/dist/resources/skills/builtin/frontend-design/references/theming.md +60 -0
- package/dist/resources/skills/builtin/frontend-design/references/typography.md +393 -0
- package/dist/resources/skills/builtin/frontend-design/references/ux-rules.md +256 -0
- package/dist/resources/skills/builtin/frontend-design/references/vocabulary.md +169 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/core.py +993 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/design_system.py +1643 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/reasoning_contract.py +123 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/search.py +171 -0
- package/dist/resources/skills/builtin/frontend-design/scripts/validate_data.py +1091 -0
- package/dist/resources/skills/builtin/kerja-agentik/SKILL.md +117 -0
- package/dist/resources/skills/builtin/pdf-maker/SKILL.md +51 -0
- package/dist/resources/skills/builtin/tinjau-keamanan/SKILL.md +53 -0
- package/dist/resources/skills/builtin/word-maker/SKILL.md +58 -0
- package/dist/resources/skills/builtin/write-swift/SKILL.md +388 -0
- 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).
|