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.
- package/LICENSE +21 -0
- package/README.md +46 -2
- package/THIRD-PARTY-NOTICES.txt +1427 -0
- package/dist/agen.js +138920 -0
- package/dist/bin.js +1245 -0
- package/dist/daemon.js +163701 -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,320 @@
|
|
|
1
|
+
# Migrations
|
|
2
|
+
|
|
3
|
+
This file owns changing a schema that is currently serving traffic: expand/contract sequencing, what lock each statement takes, backfills, and migration-file hygiene. **What the schema should be** — key choice, types, nullability, which index — is [data-modelling.md](data-modelling.md); every recipe there that touches a populated table hands off here. **Ordering the migration against the deploy**, the runner's own timeouts, and graceful shutdown are [deploy-and-config.md](deploy-and-config.md). Row locks taken by application code, transaction boundaries, and retrying `40001` are [concurrency-and-jobs.md](concurrency-and-jobs.md). Postgres is the assumed engine, per the hub's boring-choice rule; the MySQL/InnoDB differences that change the answer are called out inline. And where `kerja-agentik` owns how you run and verify things, this file owns what "verified" means for a migration: a timing on production-shaped data under concurrent load, never a green run on dev.
|
|
4
|
+
|
|
5
|
+
## Operating Posture
|
|
6
|
+
|
|
7
|
+
Three failure modes, ranked. The first is the one that takes the site down; the second is the one that is hardest to undo.
|
|
8
|
+
|
|
9
|
+
1. **The lock queue.** The DDL itself takes 2ms. It waits behind a 40-second analytics `SELECT`, and every query that arrives while it waits queues behind *it*. The symptom is not "the migration is slow" — it is every endpoint touching that table returning 500 within seconds of the migration starting, including endpoints that only read. Section 3 exists entirely for this.
|
|
10
|
+
2. **The contract step shipped early.** The column was dropped, renamed, or tightened while an app version that still uses it is running — a pod mid-rollout, a worker on the old image, a rolled-back deploy. Symptom: `column "email" does not exist` on a fraction of requests, and no code change caused it. Expand/contract (section 1) exists entirely for this.
|
|
11
|
+
3. **The single-statement backfill.** One `UPDATE` over 40 million rows: hours of WAL, replicas falling behind, `VACUUM` unable to reclaim anything, and a rollback that throws away all of it if it fails at 95%. Section 4 exists entirely for this.
|
|
12
|
+
|
|
13
|
+
### Gate: does this need to run now, in this deploy?
|
|
14
|
+
|
|
15
|
+
| Situation | Answer |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| Purely additive: new table, new nullable column, new index | Run it. Ship it ahead of the code that uses it. |
|
|
18
|
+
| Anything that removes, renames, narrows, or constrains | Split it. At least two migrations across at least two deploys. |
|
|
19
|
+
| Target table under ~10k rows, off the hot path | The dangerous forms are still dangerous; the lock is just short. Keep `lock_timeout`, skip the batching. |
|
|
20
|
+
| Rewrites a large table (section 2) | Not a migration. A recipe (sections 5–6), or a window you state out loud. |
|
|
21
|
+
| "It's just a rename, it's instant" | Section 5. Instant at the database is irrelevant; no app version works on both sides of it. |
|
|
22
|
+
|
|
23
|
+
**Producing nothing that runs today is a correct output.** "This is three migrations across three deploys; here is file one, which adds a nullable column and nothing else" is the right answer far more often than one file that does the whole change. Say it and write file one.
|
|
24
|
+
|
|
25
|
+
## 1. Expand / Contract
|
|
26
|
+
|
|
27
|
+
The invariant: **at every instant, the schema in the database is correct for both the app version running and the app version you could roll back to.** Every step below preserves it; skipping one breaks it.
|
|
28
|
+
|
|
29
|
+
1. **Expand.** Add the new column (nullable, no volatile default), table, or index. Backward compatible: old code neither knows nor cares.
|
|
30
|
+
2. **Dual-write.** New code writes both old and new on every write path. Reads still come from old.
|
|
31
|
+
3. **Backfill.** Fill historical rows in batches (section 4). Runs after every writer is dual-writing.
|
|
32
|
+
4. **Validate.** Prove the invariant with a query that must return 0, then add the constraint (`NOT VALID` → `VALIDATE`, section 2).
|
|
33
|
+
5. **Switch reads.** The new column becomes the source of truth. This is the only step users can perceive.
|
|
34
|
+
6. **Stop writing the old column.** Drop its `NOT NULL` first, or the first insert from the new code fails.
|
|
35
|
+
7. **Contract.** Drop the old column, constraint, or index.
|
|
36
|
+
|
|
37
|
+
| Steps | Same deploy? | Why |
|
|
38
|
+
| --- | --- | --- |
|
|
39
|
+
| 1 + 2 | **Yes** | The migration runs before the new app boots, so the column exists before anything writes it. |
|
|
40
|
+
| 2 + 3 | **No** | A row written between the backfill's last batch and the dual-write rollout has a NULL new column, and nothing will ever fix it. |
|
|
41
|
+
| 3 + 4 | Yes | Both are data-plane work; `VALIDATE` is just the statement after the last batch. |
|
|
42
|
+
| 4 + 5 | **No** | The read switch must be revertible on its own. Bundling it with the constraint means one rollback undoes both. |
|
|
43
|
+
| 5 + 6 | **No** | You need a version in production that reads new and writes both. That version *is* your rollback target. |
|
|
44
|
+
| 6 + 7 | **Never** | A rollback to the previous image resumes writing a column that no longer exists. |
|
|
45
|
+
|
|
46
|
+
**The boundary is the rollback window, not the deploy.** Step 7 waits until you would no longer roll back past step 6 — one deploy minimum, one week as the default. On a service that deploys ten times a day, "one week" is the honest number, not "the next deploy".
|
|
47
|
+
|
|
48
|
+
## 2. What Each Statement Actually Locks
|
|
49
|
+
|
|
50
|
+
Lock modes first, because the operation table below is meaningless without them. A lock request blocks only on *conflicting* modes:
|
|
51
|
+
|
|
52
|
+
| Lock mode | Blocks `SELECT`? | Blocks `INSERT`/`UPDATE`/`DELETE`? | Taken by |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| `ACCESS SHARE` / `ROW EXCLUSIVE` | no | no | every `SELECT` / every `INSERT`, `UPDATE`, `DELETE` |
|
|
55
|
+
| `SHARE UPDATE EXCLUSIVE` | no | no | `CREATE INDEX CONCURRENTLY`, `VALIDATE CONSTRAINT`, `VACUUM`, `ANALYZE` |
|
|
56
|
+
| `SHARE` | no | **yes** | `CREATE INDEX` (non-concurrent) |
|
|
57
|
+
| `SHARE ROW EXCLUSIVE` | no | **yes** | `ADD FOREIGN KEY`, `CREATE TRIGGER` |
|
|
58
|
+
| `ACCESS EXCLUSIVE` | **yes** | **yes** | most `ALTER TABLE`, `DROP`, `TRUNCATE`, `REINDEX`, `VACUUM FULL` |
|
|
59
|
+
|
|
60
|
+
`ACCESS EXCLUSIVE` conflicts with every mode including `ACCESS SHARE`. That is why a plain `SELECT` is enough to make you wait, and why waiting is enough to stop the table.
|
|
61
|
+
|
|
62
|
+
| Operation | Lock | Scan or rewrite | Verdict / safe form |
|
|
63
|
+
| --- | --- | --- | --- |
|
|
64
|
+
| `ADD COLUMN` nullable, no default | `ACCESS EXCLUSIVE` | catalog only | **Safe.** Microseconds of lock; the only risk is the queue (section 3). |
|
|
65
|
+
| `ADD COLUMN ... DEFAULT 'x'` (constant) | `ACCESS EXCLUSIVE` | catalog only, PG 11+ | **Safe on PG 11+** — the default is stored in `pg_attribute.attmissingval` and materialised on read. On PG ≤ 10 this is a full rewrite. |
|
|
66
|
+
| `ADD COLUMN ... DEFAULT gen_random_uuid()` / any volatile default, or `GENERATED ALWAYS AS (...) STORED` | `ACCESS EXCLUSIVE` | **full rewrite** | **Never.** Every row needs a distinct value. Add nullable → backfill → `ALTER COLUMN ... SET DEFAULT` as a separate statement. |
|
|
67
|
+
| `SET NOT NULL` | `ACCESS EXCLUSIVE` | **full scan** | **Never directly.** Use the `CHECK` two-step below. |
|
|
68
|
+
| `ALTER COLUMN TYPE` (int→bigint, numeric→bigint, narrowing a varchar) | `ACCESS EXCLUSIVE` | **full rewrite + every index rebuilt** | **Never on a live table.** Section 6. |
|
|
69
|
+
| `ALTER COLUMN TYPE varchar(50)→varchar(100)` or `→text` | `ACCESS EXCLUSIVE` | catalog only | Safe: binary-coercible widening, no rewrite (PG 9.2+). |
|
|
70
|
+
| `ADD FOREIGN KEY` | `SHARE ROW EXCLUSIVE` on **both** tables | full scan of the child | Blocks writes on both tables for the whole scan. Use `NOT VALID` then `VALIDATE`. |
|
|
71
|
+
| `ADD CONSTRAINT ... CHECK` | `ACCESS EXCLUSIVE` | full scan | Use `NOT VALID` then `VALIDATE`. |
|
|
72
|
+
| `... NOT VALID` (check or FK) | `ACCESS EXCLUSIVE` | none | **Safe.** Catalog-only; new and updated rows are enforced immediately, existing rows unchecked. |
|
|
73
|
+
| `VALIDATE CONSTRAINT` | `SHARE UPDATE EXCLUSIVE` (+ `ROW SHARE` on the parent, for an FK) | full scan | **Safe.** Reads and writes continue for the whole scan. Let it run for an hour. |
|
|
74
|
+
| `CREATE INDEX` | `SHARE` | full build | **Never on a live table.** Reads work, every write blocks until the build finishes. |
|
|
75
|
+
| `CREATE INDEX CONCURRENTLY` | `SHARE UPDATE EXCLUSIVE` | two full scans | **The safe form.** Cannot run inside a transaction. Can leave an INVALID index — see below. |
|
|
76
|
+
| `DROP INDEX` | `ACCESS EXCLUSIVE` | none | Use `DROP INDEX CONCURRENTLY`. |
|
|
77
|
+
| `DROP COLUMN` | `ACCESS EXCLUSIVE` | catalog only | Instant, and it does **not** reclaim disk — values stay in the heap until a rewrite. The risk is entirely app-side (posture #2). |
|
|
78
|
+
| `RENAME COLUMN` / `RENAME TABLE` | `ACCESS EXCLUSIVE` | catalog only | Instant at the database, impossible for the application. Section 5. |
|
|
79
|
+
| `SET DEFAULT` / `DROP DEFAULT` / `DROP NOT NULL` | `ACCESS EXCLUSIVE` | catalog only | Safe. |
|
|
80
|
+
| `TRUNCATE`, `VACUUM FULL`, `CLUSTER` | `ACCESS EXCLUSIVE` | full rewrite | Never on a live table. `pg_repack` rebuilds without the long lock. |
|
|
81
|
+
|
|
82
|
+
**MySQL/InnoDB is a trap here**: `ALGORITHM=INPLACE, LOCK=NONE` covers many of these, but `ADD COLUMN` on a table with a `FULLTEXT` index, or any change to the clustered key, falls back to a copy. State `ALGORITHM=INPLACE, LOCK=NONE` explicitly so the server *errors* instead of silently copying the table.
|
|
83
|
+
|
|
84
|
+
### `SET NOT NULL`, safely
|
|
85
|
+
|
|
86
|
+
```sql
|
|
87
|
+
ALTER TABLE users ADD CONSTRAINT users_email_nn CHECK (email IS NOT NULL) NOT VALID;
|
|
88
|
+
-- ^ catalog-only; enforced for every new and updated row from this instant
|
|
89
|
+
ALTER TABLE users VALIDATE CONSTRAINT users_email_nn;
|
|
90
|
+
-- ^ full scan under SHARE UPDATE EXCLUSIVE: reads and writes keep flowing
|
|
91
|
+
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
|
|
92
|
+
-- ^ PG 12+ sees the valid CHECK and skips the scan: ACCESS EXCLUSIVE for microseconds
|
|
93
|
+
ALTER TABLE users DROP CONSTRAINT users_email_nn; -- optional; now redundant
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
On PG 11 and below the third statement rescans regardless: keep the `CHECK` and never `SET NOT NULL`.
|
|
97
|
+
|
|
98
|
+
### `CREATE INDEX CONCURRENTLY` and the INVALID index
|
|
99
|
+
|
|
100
|
+
CIC does two passes and waits for every transaction that could see the table to finish. If it is cancelled, deadlocks, or hits a unique violation on pass two, **it leaves an index behind that no query will use but every write must maintain.** Nothing tells you. Detect it:
|
|
101
|
+
|
|
102
|
+
```sql
|
|
103
|
+
SELECT c.relname, i.indisvalid, i.indisready FROM pg_index i
|
|
104
|
+
JOIN pg_class c ON c.oid = i.indexrelid WHERE NOT i.indisvalid OR NOT i.indisready;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Recovery: `DROP INDEX CONCURRENTLY <name>;` then retry the build, or on PG 12+ `REINDEX INDEX CONCURRENTLY <name>;` to rebuild in place. Check `indisvalid` **as the last step of every migration that builds an index** — an unchecked CIC is how a table pays for four indexes and uses three.
|
|
108
|
+
|
|
109
|
+
## 3. The Lock Queue Trap
|
|
110
|
+
|
|
111
|
+
Postgres queues lock requests in arrival order, and a *pending* request blocks everything that arrives after it and conflicts with it — even requests that would not conflict with the lock currently held. So:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
t=0 analytics SELECT starts, holds ACCESS SHARE for 40s
|
|
115
|
+
t=1 ALTER TABLE requests ACCESS EXCLUSIVE -> waits
|
|
116
|
+
t=1.01 every new SELECT requests ACCESS SHARE -> waits behind the ALTER
|
|
117
|
+
t=1.2 pool exhausted -> 500s on endpoints that never touch this table
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
One slow `SELECT` turns a 2ms `ALTER` into a full outage. The symptom: a vertical line on the latency graph, `pg_stat_activity` full of `wait_event_type = 'Lock'`, and a migration log showing nothing at all, because the statement has not started yet.
|
|
121
|
+
|
|
122
|
+
**Every migration sets both timeouts. Postgres defaults `lock_timeout` and `statement_timeout` to `0`, meaning wait forever.**
|
|
123
|
+
|
|
124
|
+
```sql
|
|
125
|
+
BEGIN;
|
|
126
|
+
SET LOCAL lock_timeout = '2s';
|
|
127
|
+
-- 2s: at 500 req/s, 2s of queueing parks ~1,000 requests and a 20-connection pool
|
|
128
|
+
-- saturates in ~40ms, so 2s is already the outer edge of a survivable stall. Below
|
|
129
|
+
-- ~1s you lose the race even when the table is idle between two short queries.
|
|
130
|
+
SET LOCAL statement_timeout = '10s';
|
|
131
|
+
-- statement_timeout covers the lock wait too, so it must exceed lock_timeout to stay
|
|
132
|
+
-- distinguishable from it. 10s caps a catalog-only DDL that has gone wrong.
|
|
133
|
+
ALTER TABLE orders ADD COLUMN currency text;
|
|
134
|
+
COMMIT;
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`SET LOCAL`, not `SET`: it reverts at commit instead of leaking a 10-second `statement_timeout` into whatever the pooled connection does next.
|
|
138
|
+
|
|
139
|
+
Failure is a specific SQLSTATE, and the two mean opposite things:
|
|
140
|
+
|
|
141
|
+
| SQLSTATE | Cause | Retry? |
|
|
142
|
+
| --- | --- | --- |
|
|
143
|
+
| `55P03` `lock_not_available` | `lock_timeout` fired — you lost the race | **Yes.** This is the expected outcome, not an error. |
|
|
144
|
+
| `57014` `query_canceled` | `statement_timeout` fired — the work itself is too big | **No.** Retrying just repeats the outage. Rewrite the statement. |
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
for i in 1 2 3 4 5; do
|
|
148
|
+
psql -v ON_ERROR_STOP=1 -f 0042_add_currency.sql && exit 0
|
|
149
|
+
echo "attempt $i lost the lock race; retrying in 10s"
|
|
150
|
+
sleep 10 # 10s: long enough for a typical slow query to finish and release
|
|
151
|
+
done
|
|
152
|
+
echo "5 attempts failed — something is holding this table; do not force it" >&2; exit 1
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
After five failures the answer is not a sixth attempt. Find the holder:
|
|
156
|
+
|
|
157
|
+
```sql
|
|
158
|
+
SELECT pid, state, wait_event_type, now() - xact_start AS xact_age, left(query, 80)
|
|
159
|
+
FROM pg_stat_activity WHERE now() - xact_start > interval '1 minute' ORDER BY xact_start;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`idle in transaction` is the usual culprit — a connection that ran one `SELECT` and never committed, holding `ACCESS SHARE` indefinitely. `idle_in_transaction_session_timeout` (default `0`) set to `60s` on the app's role removes the class permanently. Never `pg_terminate_backend` a production connection on your own judgment. And the second-most-common holder is **your own migration runner**: most wrap the whole file in one transaction, so `ACCESS EXCLUSIVE` on table A is held until the statement on table F commits. One table per file.
|
|
163
|
+
|
|
164
|
+
## 4. Backfills
|
|
165
|
+
|
|
166
|
+
**Never one `UPDATE`.** On 40 million rows a single statement holds a row lock on every row it touches until commit, writes a new row version for each (the table can double in size), pins the `xmin` horizon so `VACUUM` reclaims nothing anywhere, floods WAL until replicas lag by minutes, and discards all of it if it fails at 95%.
|
|
167
|
+
|
|
168
|
+
The batch statement is bounded by primary key range and made idempotent by its own predicate:
|
|
169
|
+
|
|
170
|
+
```sql
|
|
171
|
+
UPDATE orders
|
|
172
|
+
SET currency = 'USD'
|
|
173
|
+
WHERE id >= $1 AND id < $2 -- keyset range: index scan, constant cost per batch
|
|
174
|
+
AND currency IS NULL; -- idempotent: re-running a batch is a no-op
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
BATCH = 5_000 # ~50-200ms per batch on an indexed table: short enough that a
|
|
179
|
+
# concurrent writer's lock wait never approaches an HTTP timeout
|
|
180
|
+
PAUSE = 0.2 # 200ms between batches gives autovacuum and replication room;
|
|
181
|
+
# ceiling ~25k rows/s, which finishes 40M rows in ~30 minutes
|
|
182
|
+
|
|
183
|
+
lo = min_id
|
|
184
|
+
while lo <= max_id:
|
|
185
|
+
with conn: # one committed transaction per batch
|
|
186
|
+
cur.execute(BATCH_SQL, (lo, lo + BATCH))
|
|
187
|
+
checkpoint(lo) # persist progress: a row in a table, or a file
|
|
188
|
+
lo += BATCH
|
|
189
|
+
time.sleep(PAUSE)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
| Requirement | How | Symptom when missing |
|
|
193
|
+
| --- | --- | --- |
|
|
194
|
+
| Resumable | Persist the last committed id | A crash at hour 3 means starting over |
|
|
195
|
+
| Idempotent | Predicate excludes done rows (`AND currency IS NULL`) | Re-running double-applies an increment or overwrites a newer value |
|
|
196
|
+
| Bounded | Fixed PK range per batch | `LIMIT`/`OFFSET` re-walks the prefix; batches get slower as it runs |
|
|
197
|
+
| Throttled | `sleep` between batches | Replica lag climbs until reads served from replicas go stale |
|
|
198
|
+
| Observable, killable | Log rows done / total every 20 batches; hold nothing across the `sleep` | Nobody can answer "how long left", and Ctrl-C costs more than one batch |
|
|
199
|
+
|
|
200
|
+
Watch replication while it runs: `SELECT client_addr, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_bytes FROM pg_stat_replication;`. If `replay_bytes` grows across three consecutive samples, the backfill is outrunning replication — raise `PAUSE` until it flattens. Do not pick a lag threshold you did not measure; the shape of the curve is the signal.
|
|
201
|
+
|
|
202
|
+
## 5. Zero-Downtime Column Rename
|
|
203
|
+
|
|
204
|
+
`ALTER TABLE users RENAME COLUMN email TO email_address` is instant, free, and unshippable: there is no app version that works both before and after it, so the rollout window is guaranteed 500s. The recipe replaces one instant statement with five deploys.
|
|
205
|
+
|
|
206
|
+
- **D1**: `ALTER TABLE users ADD COLUMN email_address text;` — catalog-only, instant — plus an app version whose every write path sets both `email` and `email_address`. Reads still use `email`.
|
|
207
|
+
- **D2 (job, not a deploy)**: backfill per section 4 — `SET email_address = email WHERE id >= $1 AND id < $2 AND email_address IS NULL`.
|
|
208
|
+
- **D3**: verify, then constrain, then switch reads.
|
|
209
|
+
|
|
210
|
+
```sql
|
|
211
|
+
-- IS DISTINCT FROM, never <>: <> is NULL when either side is NULL, so the check
|
|
212
|
+
-- would pass while every unbackfilled row is wrong ([data-modelling.md] §3).
|
|
213
|
+
SELECT count(*) FROM users WHERE email IS DISTINCT FROM email_address; -- must be 0
|
|
214
|
+
ALTER TABLE users ADD CONSTRAINT users_ea_nn CHECK (email_address IS NOT NULL) NOT VALID;
|
|
215
|
+
ALTER TABLE users VALIDATE CONSTRAINT users_ea_nn;
|
|
216
|
+
ALTER TABLE users ALTER COLUMN email_address SET NOT NULL;
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **D4**: `ALTER TABLE users ALTER COLUMN email DROP NOT NULL;` **first** — otherwise the first insert from code that no longer writes `email` fails with a not-null violation. Then deploy the version that stops writing `email`.
|
|
220
|
+
- **D5**, after the rollback window: `ALTER TABLE users DROP COLUMN email;`
|
|
221
|
+
|
|
222
|
+
**When you cannot deploy code to every writer** — an admin tool, another team's service, a legacy import — keep the columns in sync with a trigger instead of dual-writing. `CREATE TRIGGER` takes `SHARE ROW EXCLUSIVE`: reads continue, writes block for the moment it takes. Drop it at D4.
|
|
223
|
+
|
|
224
|
+
```sql
|
|
225
|
+
CREATE FUNCTION users_sync_email() RETURNS trigger AS $$
|
|
226
|
+
BEGIN NEW.email_address := coalesce(NEW.email_address, NEW.email); RETURN NEW; END $$
|
|
227
|
+
LANGUAGE plpgsql;
|
|
228
|
+
CREATE TRIGGER users_sync_email_trg BEFORE INSERT OR UPDATE ON users
|
|
229
|
+
FOR EACH ROW EXECUTE FUNCTION users_sync_email();
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The updatable-view variant — rename the table, create a view under the old name — is only for a client that genuinely cannot be changed (a BI tool, a partner's query). It leaves permanent indirection; do not reach for it because five deploys felt like a lot.
|
|
233
|
+
|
|
234
|
+
## 6. Zero-Downtime Type Change
|
|
235
|
+
|
|
236
|
+
`ALTER COLUMN amount TYPE bigint` rewrites the entire table and rebuilds every index on it, under `ACCESS EXCLUSIVE`, needing free disk for a second copy of the table plus all its indexes. On 40 million rows that is minutes to hours of total unavailability. It is the same shape as the rename, plus indexes:
|
|
237
|
+
|
|
238
|
+
1. `ALTER TABLE orders ADD COLUMN amount_minor bigint;` — catalog-only.
|
|
239
|
+
2. Dual-write via app code or the trigger from section 5, applying the conversion: `NEW.amount_minor := round(NEW.amount * 100)::bigint`.
|
|
240
|
+
3. Backfill in batches with the identical conversion, `AND amount_minor IS NULL`.
|
|
241
|
+
4. Verify: `SELECT count(*) FROM orders WHERE amount_minor IS DISTINCT FROM round(amount * 100)::bigint;` must be 0. Run it twice, minutes apart — once is a snapshot, twice proves the dual-write is holding.
|
|
242
|
+
5. `CHECK ... NOT VALID` → `VALIDATE` → `SET NOT NULL`, then `CREATE INDEX CONCURRENTLY` every index the new column needs **before** switching reads. Switching reads to an unindexed column is a self-inflicted seq scan.
|
|
243
|
+
6. Switch reads. Then stop writing `amount` and drop the trigger. Then, after the window, drop `amount`.
|
|
244
|
+
|
|
245
|
+
**`int` → `bigint` on a primary key is not this recipe.** It drags every foreign key column in every child table with it, plus the sequence, plus every index on those columns. Do the child tables first, one at a time, and schedule it as a project. Treating it as one migration is how a Saturday becomes a Sunday.
|
|
246
|
+
|
|
247
|
+
## 7. Migration Hygiene
|
|
248
|
+
|
|
249
|
+
**Never edit a migration that has already run.** The symptom is a checksum failure on the next deploy — Flyway's `Validate failed: migration checksum mismatch`, Django's `InconsistentMigrationHistory` — or, worse, no error at all and two environments with different schemas claiming the same version. Write a new migration that changes the thing. Yes, for a typo in a column name. The only editable migration is one that has run *nowhere*, and "nowhere" includes CI and a colleague's laptop.
|
|
250
|
+
|
|
251
|
+
**One logical change per file.** The failure unit is the file: a file that adds a column, backfills it, and creates an index fails at the index and leaves you reconstructing what already applied. And a multi-table file holds every one of its `ACCESS EXCLUSIVE` locks until the last statement commits.
|
|
252
|
+
|
|
253
|
+
**Some statements cannot run in a transaction**, and most runners wrap files in one by default: `CREATE INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX CONCURRENTLY`, `VACUUM`, and `ALTER TYPE ... ADD VALUE` before PG 12. The symptom is SQLSTATE `25001`, `CREATE INDEX CONCURRENTLY cannot run inside a transaction block`. The escape hatches: Django `atomic = False`, Rails `disable_ddl_transaction!`, Alembic an autocommit block. Use them, and put nothing else in that file — a non-transactional file that fails halfway leaves partial DDL applied.
|
|
254
|
+
|
|
255
|
+
**Forward-only in practice.** Write the down migration — it documents the inverse and costs five minutes — and do not plan to run it. A down migration that drops a column deletes the data the rollback was supposed to preserve, and it has never been tested. The real rollback of a schema change is a forward migration; the real rollback of a *deploy* is the previous image, which only works because expand/contract kept the schema compatible with it. That is what steps 1–7 are buying.
|
|
256
|
+
|
|
257
|
+
**Time it on production-shaped volume.** A migration is 30ms on 1,000 rows and a 40-minute outage on 40 million. Restore a snapshot, run it, record the measured duration in a comment at the top of the file. And run it **under concurrent load** — a `pgbench` script or a loop of `SELECT`s against the same table — because a DDL against an idle copy never encounters the lock queue, which is failure mode #1.
|
|
258
|
+
|
|
259
|
+
## 8. Data Migrations Are Not Schema Migrations
|
|
260
|
+
|
|
261
|
+
| Axis | Schema migration | Data migration |
|
|
262
|
+
| --- | --- | --- |
|
|
263
|
+
| Changes | The catalog | Rows |
|
|
264
|
+
| Duration | Milliseconds | Minutes to hours |
|
|
265
|
+
| Transactions | One, the whole file | Many, one per batch |
|
|
266
|
+
| Rerun safety | Guarded by the version table | Must be idempotent by its own predicate |
|
|
267
|
+
| Partial failure | Nothing applied | Half applied |
|
|
268
|
+
| Runs | At deploy, before the app boots | As a monitored job, after the deploy |
|
|
269
|
+
|
|
270
|
+
Mixing them in one file makes both unrunnable, for three compounding reasons. The deploy now blocks on the data step, so a 40-minute `UPDATE` is a 40-minute deploy that the orchestrator's own deadline (`activeDeadlineSeconds`, a CI job limit) kills mid-way. The file's single transaction makes batching impossible by construction. And when the data step fails, the version table records the migration as *not applied*, so the next run replays the DDL that already succeeded.
|
|
271
|
+
|
|
272
|
+
**The migration adds the column. A separate resumable job fills it.** The only `UPDATE` allowed in a migration file is one bounded to a handful of rows — seeding a lookup table of under ~1,000 rows, or setting a value on a single config row.
|
|
273
|
+
|
|
274
|
+
## 9. Pre-Flight Checklist
|
|
275
|
+
|
|
276
|
+
Run top to bottom against any migration touching a table serving traffic. A blocking answer stops the migration, not the checklist.
|
|
277
|
+
|
|
278
|
+
| # | Question | Blocking answer |
|
|
279
|
+
| --- | --- | --- |
|
|
280
|
+
| 1 | Measured row count of the target table? | "It's small" with no number. Run `SELECT reltuples::bigint FROM pg_class WHERE relname = 'x'`. |
|
|
281
|
+
| 2 | Does any statement take `ACCESS EXCLUSIVE` **and** scan or rewrite? | Yes → rewrite it using section 2. |
|
|
282
|
+
| 3 | Are `lock_timeout` and `statement_timeout` set with `SET LOCAL` in the file? | No. |
|
|
283
|
+
| 4 | Is there a retry loop around the lock acquisition? | No. |
|
|
284
|
+
| 5 | Long-running query or `idle in transaction` on this table right now? | Yes → clear it or migrate later. |
|
|
285
|
+
| 6 | Is the **currently running** app version still correct after this applies? | No → this is a contract step; split it. |
|
|
286
|
+
| 7 | Is the **next** app version correct before this applies? | Ordering is [deploy-and-config.md](deploy-and-config.md). |
|
|
287
|
+
| 8 | Index built with `CONCURRENTLY`, outside a transaction, with an `indisvalid` check after? | No. |
|
|
288
|
+
| 9 | Backfill batched, resumable, idempotent, throttled — and outside the migration file? | No. |
|
|
289
|
+
| 10 | Timed on a production-shaped copy, under concurrent load? | No. |
|
|
290
|
+
| 11 | Does the down migration destroy data? | Then the rollback plan is a forward migration — say so out loud. |
|
|
291
|
+
| 12 | Rewrite or index build: is free disk ≥ 2× (table + indexes)? | Unknown. |
|
|
292
|
+
|
|
293
|
+
## Never Ship
|
|
294
|
+
|
|
295
|
+
| Never | Instead |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
| `SET NOT NULL` on a live table | `CHECK ... NOT VALID` → `VALIDATE` → `SET NOT NULL` |
|
|
298
|
+
| `CREATE INDEX` without `CONCURRENTLY` | `CONCURRENTLY`, outside a transaction, then check `indisvalid` |
|
|
299
|
+
| `ADD FOREIGN KEY` / `ADD CHECK` in one statement | `NOT VALID`, then `VALIDATE CONSTRAINT` |
|
|
300
|
+
| `ADD COLUMN ... DEFAULT gen_random_uuid()` or any volatile default | Nullable column → backfill → `SET DEFAULT` separately |
|
|
301
|
+
| `ALTER COLUMN TYPE` on a large table | New column + dual-write + backfill + swap (section 6) |
|
|
302
|
+
| A migration with no `lock_timeout`, or retrying a `57014` | `SET LOCAL lock_timeout = '2s'` + a retry loop; only `55P03` is retryable |
|
|
303
|
+
| One `UPDATE` over the whole table, or a backfill inside a migration file | A batched, checkpointed loop in a separate monitored job (section 4) |
|
|
304
|
+
| Editing a migration that has run anywhere | A new migration |
|
|
305
|
+
| Dropping a column in the deploy that stops writing it | At least one deploy gap; one week by default |
|
|
306
|
+
| `RENAME COLUMN` on a table an app is using | The five-deploy recipe (section 5) |
|
|
307
|
+
| Several tables' DDL in one file | One table per migration |
|
|
308
|
+
| A down migration you intend to run in production | A forward fix |
|
|
309
|
+
| `VACUUM FULL` / `CLUSTER` / `TRUNCATE` on a live table | `pg_repack`, or accept the bloat |
|
|
310
|
+
| "It ran fine in staging" | Staging has no traffic and no lock queue; time it under load |
|
|
311
|
+
|
|
312
|
+
## No Guessing
|
|
313
|
+
|
|
314
|
+
- **Never state a duration you did not measure, and never call a lock "brief" from memory.** The timing comes from a restored production-shaped snapshot; the lock comes from the table in section 2 or from `pg_locks`.
|
|
315
|
+
- **Never invent a batch size, a pause, or a timeout.** Use the values here or the project's own config, and say which.
|
|
316
|
+
- **Never trust the ORM's generated DDL, or assume the runner's transaction behaviour.** Django `AlterField`, Rails `change_column`, and Alembic `alter_column` all emit the dangerous single-statement form by default, and a wrapped file both fails on `CREATE INDEX CONCURRENTLY` and holds six locks for six `ALTER`s. Print the SQL and read it: `manage.py sqlmigrate app 0042`, `alembic upgrade <rev> --sql`. A generated migration is a draft.
|
|
317
|
+
|
|
318
|
+
## Output
|
|
319
|
+
|
|
320
|
+
Write the migration. Then, briefly: the measured row count of the target table, the lock each statement takes and for how long, which expand/contract step this is and which deploy it may share, the backfill's batch size and where progress is checkpointed, and the timing you measured on a production-shaped copy under load. If the honest answer is "this is three files across three deploys, and today's file only adds a nullable column", say exactly that and ship the one file. If the change would be safe only in a maintenance window, name the window rather than inventing a clever rewrite that isn't actually safe.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# Observability
|
|
2
|
+
|
|
3
|
+
This file owns knowing what the service did: structured logging, request and trace ids, metrics, tracing, health and readiness endpoints, and alerts. **Measuring** latency — benchmarks, load tests, profiling, query plans — is [testing-and-perf.md](testing-and-perf.md); this file owns the instrumentation that produces the numbers, not the exercise that stresses them. The error taxonomy and the stable `code` that lands in `error_code` are [validation-and-errors.md](validation-and-errors.md). Which credential values must never be written anywhere is [auth-and-secrets.md](auth-and-secrets.md); the redaction *mechanism* is here. Queue depth, consumer lag, and DLQ alerts are here, delegated from [concurrency-and-jobs.md](concurrency-and-jobs.md). Probe timings that interact with graceful shutdown and pool config are [deploy-and-config.md](deploy-and-config.md).
|
|
4
|
+
|
|
5
|
+
`kerja-agentik` owns how you debug a failing test and how you verify a fix. This file is the layer underneath: what production writes down so that a debugging session is possible at all, six hours after the fact, with no reproduction.
|
|
6
|
+
|
|
7
|
+
## Operating Posture
|
|
8
|
+
|
|
9
|
+
Two failure modes, and the first is worse:
|
|
10
|
+
|
|
11
|
+
1. **The logs cannot answer "what happened to request X?"** A customer sends a screenshot and a timestamp. You have four million lines across six services and no key that joins them. Every log line was written correctly and the system is still opaque. This is not a gap in coverage — it is the absence of a join key, and no amount of extra logging fixes it after the fact.
|
|
12
|
+
2. **Everything is instrumented and nothing is actionable.** ERROR fires 4,000 times a day for a handled 404. A metric labelled by user id OOMs the Prometheus server at 03:00, so you lose all metrics at the exact moment you need them. Traces cost more per month than the database and nobody has opened one.
|
|
13
|
+
|
|
14
|
+
**Never invent an SLO, a probe timeout, a histogram bucket, an alert threshold, or a sampling rate.** Take them from the tables below or from the project's own config. A made-up `99.9%` reads exactly like a negotiated one, and the first time it is missed someone will treat it as a commitment.
|
|
15
|
+
|
|
16
|
+
## The One Question
|
|
17
|
+
|
|
18
|
+
Everything here serves one test:
|
|
19
|
+
|
|
20
|
+
> Take a `request_id` off a response header. Reconstruct what that request did — every log line it wrote, every downstream call it made, the status it returned, and how long it took.
|
|
21
|
+
|
|
22
|
+
If a single `grep` on that id does not produce that story, the rest of this file is decoration. Run the test before adding a dashboard. In VibeCode, `BashOutput`'s `filter` regex is that grep against a running server.
|
|
23
|
+
|
|
24
|
+
**Permission to add nothing:** a service that already emits structured lines with a request id, exports RED metrics, and has split probes needs no new instrumentation. Adding a second logger beside the framework's, or a bespoke metrics registry beside the OTel one, is a defect. Say the coverage is adequate and stop.
|
|
25
|
+
|
|
26
|
+
## Structured Logs — One Event, One Line, Fixed Fields
|
|
27
|
+
|
|
28
|
+
JSON to stdout. The process does not manage files, rotation, or shipping — the platform does ([deploy-and-config.md](deploy-and-config.md)).
|
|
29
|
+
|
|
30
|
+
| Field | Type / example | Source | Why it is mandatory |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| `timestamp` | RFC 3339 UTC, ms precision — `2026-09-02T14:03:11.482Z` | logger | Sorting across services needs one format and one zone. Local time is unsortable. |
|
|
33
|
+
| `level` | one of the six below | call site | The only routing input; see the level table. |
|
|
34
|
+
| `message` | short **constant** string — `"order rejected"` | call site | Constant so N occurrences group into one row. Interpolated ids make every line unique and unaggregatable. |
|
|
35
|
+
| `request_id` | ULID / uuidv7 | edge middleware | The join key. This is the field the One Question depends on. |
|
|
36
|
+
| `trace_id` | 32 lowercase hex (W3C) | propagated or generated at the edge | Joins the story across process boundaries. |
|
|
37
|
+
| `tenant_id` | uuid | the session — **never** the request body | Scoping every query and the blast radius of every incident. |
|
|
38
|
+
| `route` | matched template — `/orders/{id}` | the router | Bounded, aggregatable. A raw path is not. |
|
|
39
|
+
| `method` | `GET` / `POST` / … | request | Half of "which endpoint". |
|
|
40
|
+
| `status` | int — `200`, `409` | response | The other half of every RED metric. |
|
|
41
|
+
| `duration_ms` | int, from a **monotonic** clock | timer | Wall-clock subtraction yields negative durations after an NTP step. `performance.now()`, `time.monotonic()`, `time.Since`. |
|
|
42
|
+
| `error_code` | stable code from [validation-and-errors.md](validation-and-errors.md) | error path | Clients branch on it, dashboards group by it, humans never parse a sentence. |
|
|
43
|
+
|
|
44
|
+
Everything else goes under a nested `ctx` object. **The top level is closed.**
|
|
45
|
+
|
|
46
|
+
Why a fixed set beats ad-hoc keys, in symptoms rather than principles:
|
|
47
|
+
|
|
48
|
+
- Three services write `userId`, `user_id`, and `uid`. The one query that would have found the incident matches a third of the lines, and looks like it worked.
|
|
49
|
+
- Log backends index by field name. Free-form keys mean an unbounded index — the same cardinality failure as metrics, in a system with worse alerting on it.
|
|
50
|
+
- A dashboard built on `duration` silently returns nothing after someone renames it `latency_ms`. Nothing errors. The panel is just empty, and empty reads as healthy.
|
|
51
|
+
|
|
52
|
+
Two call-site rules: **log the event, not the sentence** — `log.info("order rejected", {order_id, error_code})`, never an interpolated string someone will regex badly at 03:00 — and **never log inside a per-row loop**; a 10,000-row import that logs each row costs more than the import. Log the batch: count, duration, failures.
|
|
53
|
+
|
|
54
|
+
### Log Levels — Real Criteria
|
|
55
|
+
|
|
56
|
+
Six levels, mapping onto RFC 5424 severities. The criteria are about *who acts*, not about how bad it feels.
|
|
57
|
+
|
|
58
|
+
| Level | Criterion | Example | Who acts |
|
|
59
|
+
| --- | --- | --- | --- |
|
|
60
|
+
| `FATAL` | The process cannot continue and is exiting now. | Missing required config at boot, port already bound | Deploy owner, immediately |
|
|
61
|
+
| `ERROR` | Something is broken that a **human must fix**. Unhandled exception, a `5xx` you returned, a job in the DLQ. | `500` from an unhandled `KeyError`, Postgres unreachable | On-call, this shift |
|
|
62
|
+
| `WARN` | Degraded but handled — the system recovered, and a pattern of these is a problem. | Retry succeeded on attempt 3, cache miss storm, deprecated endpoint hit | Nobody tonight; someone this sprint |
|
|
63
|
+
| `INFO` | One line per request, plus state changes that matter to the business. | Request completed, order paid, user deleted | Nobody. It is evidence, not a signal. |
|
|
64
|
+
| `DEBUG` | Developer detail, off in production. | Query text, branch taken | Nobody |
|
|
65
|
+
| `TRACE` | Firehose, on for minutes at a time under a flag. | Loop internals | Nobody |
|
|
66
|
+
|
|
67
|
+
**An expected, handled `404` is INFO, not ERROR.** So is a `400` from a client that sent bad JSON, a `401` from an expired token, and a `409` from a conflicting write. Those are the API doing its job — they are all in the taxonomy at [validation-and-errors.md](validation-and-errors.md) precisely because they are normal. The rule is mechanical:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
4xx you returned deliberately -> INFO (WARN if the rate itself is the anomaly)
|
|
71
|
+
5xx you returned -> ERROR
|
|
72
|
+
5xx you received and retried -> WARN on the attempt, ERROR only if the retries were exhausted
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**An ERROR nobody acts on trains everyone to ignore ERROR.** That is the actual cost, and it is paid once, permanently: after a month of 4,000 daily handled-404 ERRORs, the real one scrolls past. If a line fires more than a handful of times a day and no one has ever changed anything because of it, it is INFO or it is deleted. Demoting noisy ERRORs is real work, not tidying.
|
|
76
|
+
|
|
77
|
+
### Never Logged
|
|
78
|
+
|
|
79
|
+
| Never | Why |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| Passwords, in any form, including "invalid" ones | Users mistype one account's password into another's login form |
|
|
82
|
+
| `Authorization` / `Cookie` / `Proxy-Authorization` headers | A bearer token in a log is a valid credential in a log |
|
|
83
|
+
| Tokens, API keys, refresh tokens, session ids, signed URLs | Same; a session id is a login |
|
|
84
|
+
| Full card numbers, CVV, full bank account numbers | PCI DSS 3.4 — PAN unreadable wherever stored; log the last 4 only |
|
|
85
|
+
| Whole request bodies, whole config objects, whole exception locals | These are the three ways every secret above gets logged by accident |
|
|
86
|
+
| National ids, full addresses, medical or biometric data | Retention on a log pipeline is not a privacy review |
|
|
87
|
+
|
|
88
|
+
**Redact at the logger, not at each call site.** A per-call-site rule is a rule you will forget on exactly the line that mattered, and the failure is silent — the log looks fine until someone reads it.
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
// pino: one config, applies to every logger in the process
|
|
92
|
+
pino({ redact: {
|
|
93
|
+
paths: ['req.headers.authorization', 'req.headers.cookie', 'password',
|
|
94
|
+
'token', 'refresh_token', 'card.number', '*.password', 'ctx.*.token'],
|
|
95
|
+
censor: '[REDACTED]' } })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
# structlog: a processor in the chain; nothing reaches a renderer unfiltered
|
|
100
|
+
DENY = {"password", "token", "authorization", "cookie", "secret", "api_key", "card_number"}
|
|
101
|
+
def scrub(_, __, ed): return {k: ("[REDACTED]" if k.lower() in DENY else v) for k, v in ed.items()}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A secret that reached the log is leaked, full stop — the log is replicated, indexed, and retained. Deleting the line is not the fix; rotation is ([auth-and-secrets.md](auth-and-secrets.md) *When a Key Leaks*).
|
|
105
|
+
|
|
106
|
+
Keep **100% of WARN and above** within retention. Sample INFO on high-traffic routes (`1:10` above ~100 rps on one route is a normal start), but **sample by `trace_id`, not per line** — half a story is worse than none, because it looks complete.
|
|
107
|
+
|
|
108
|
+
## Request Id and Trace Id
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
|
|
112
|
+
^ver ^trace-id (32 hex) ^span-id (16 hex) ^sampled
|
|
113
|
+
X-Request-Id: 01JB6X2K8P9QW3ZR7T4V5N0MDC
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`traceparent` is W3C Trace Context (a W3C Recommendation, not an RFC) and is what OpenTelemetry propagates. `X-Request-Id` is a de-facto convention with no spec — universal anyway, and it is the one a human can read aloud over the phone.
|
|
117
|
+
|
|
118
|
+
The rules:
|
|
119
|
+
|
|
120
|
+
1. **Generated at the edge when absent.** One middleware, first in the chain, before auth, before body parsing. If a request reaches a handler with no id, the middleware is in the wrong position.
|
|
121
|
+
2. **Bound to the ambient context, not passed as an argument.** `AsyncLocalStorage` in Node, `contextvars` in Python, `context.Context` in Go. An id threaded by hand is an id someone drops in the one helper that mattered.
|
|
122
|
+
3. **On every log line**, via the logger's context binding — never by remembering to add it.
|
|
123
|
+
4. **Propagated to every downstream call**: HTTP headers, RPC metadata, and the **job payload** when work is enqueued, so an async consumer's logs join the request that created it ([concurrency-and-jobs.md](concurrency-and-jobs.md)).
|
|
124
|
+
5. **Returned in a response header** on every response, success and failure, so the user can quote it. A `500` body carries the same id ([validation-and-errors.md](validation-and-errors.md)) — that pairing is what turns a support ticket into a `grep`.
|
|
125
|
+
6. **At a public edge, do not trust an inbound id.** Validate the `traceparent` format or generate a fresh one and record the client's as `ctx.upstream_trace_id`. An attacker who can set your trace id can forge log correlation and inflate your trace bill. Between your own services, behind the gateway, trust it — that is the entire point.
|
|
126
|
+
|
|
127
|
+
## Metrics
|
|
128
|
+
|
|
129
|
+
Three frameworks, one job each. They overlap; that is fine.
|
|
130
|
+
|
|
131
|
+
| Framework | Applies to | Signals |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| **RED** | Request-driven services (HTTP, RPC, queue consumers) | **R**ate, **E**rrors, **D**uration |
|
|
134
|
+
| **USE** | Resources (CPU, pool, disk, queue) | **U**tilization, **S**aturation, **E**rrors |
|
|
135
|
+
| **Four golden signals** (Google SRE) | Anything user-facing | Latency, traffic, errors, saturation |
|
|
136
|
+
|
|
137
|
+
The minimum export for an HTTP service, in OTel semantic-convention names:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
http.server.request.duration histogram, seconds, labels: http.route, http.request.method, http.response.status_code
|
|
141
|
+
http.server.active_requests gauge (saturation: in-flight)
|
|
142
|
+
db.client.connection.count gauge, by state=used|idle (pool saturation — the first thing to go)
|
|
143
|
+
messaging.client.consumed.messages counter (queue throughput)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Plus, from [concurrency-and-jobs.md](concurrency-and-jobs.md): queue depth, oldest-message age, and DLQ depth as gauges. **Oldest-message age beats depth** — a depth of 500 that drains in 4 seconds is healthy; a depth of 3 where the oldest is 40 minutes old means a consumer is stuck.
|
|
147
|
+
|
|
148
|
+
### Histograms, Not Averages
|
|
149
|
+
|
|
150
|
+
**An average latency is a number that describes nobody.** 100 requests at 10 ms and 1 at 10,000 ms average to 108 ms. No user experienced 108 ms; 100 had a fast page and 1 gave up. Symptom: the incident channel says "it's slow", the dashboard says 42 ms, and both are correct.
|
|
151
|
+
|
|
152
|
+
Export a histogram and read p50 / p95 / p99. Buckets must straddle your actual latencies — the Prometheus Go client's `DefBuckets` is:
|
|
153
|
+
|
|
154
|
+
```go
|
|
155
|
+
DefBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10} // seconds
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
If your endpoint answers in 2 ms, every request lands in the first bucket and p50, p95 and p99 all report `≤5ms` — a flat, useless line. Set buckets around the real distribution, measured first ([testing-and-perf.md](testing-and-perf.md)).
|
|
159
|
+
|
|
160
|
+
**Never average percentiles.** The mean of four instances' p99 is not the fleet p99 and has no statistical meaning. Aggregate the raw buckets, then compute the quantile — `histogram_quantile(0.99, sum by (le, route) (rate(bucket[5m])))`. Averaging p99s is the most common wrong dashboard in production.
|
|
161
|
+
|
|
162
|
+
### Cardinality — The Label That Kills the Backend
|
|
163
|
+
|
|
164
|
+
**A time series exists for every distinct combination of label values.** The count is a product, not a sum, and it grows while you watch.
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
routes(20) × methods(3) × status(5) = 300 series fine
|
|
168
|
+
× user_id(50,000) = 15,000,000 series dead
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
At the long-standing Prometheus rule of thumb of roughly 3 kB of resident memory per active series, 15 million series is about 45 GB of RAM for one label. Symptom: the scrape times out, the server OOMs and restarts, and **you lose every metric in the fleet at the moment of the incident that caused the spike.** Cardinality does not degrade gracefully; it falls over.
|
|
172
|
+
|
|
173
|
+
| Label | Allowed? | Note |
|
|
174
|
+
| --- | --- | --- |
|
|
175
|
+
| `route` (matched **template**) | Yes | Bounded by the router: number of routes |
|
|
176
|
+
| `method`, `status_code` or `status_class` | Yes | Tiny, fixed sets |
|
|
177
|
+
| `error_code` | Yes | Bounded by the taxonomy in [validation-and-errors.md](validation-and-errors.md) |
|
|
178
|
+
| `tenant_id` | **Only** with a hard, known bound | Fine at 30 enterprise tenants; fatal at 300,000 self-serve accounts |
|
|
179
|
+
| `user_id`, `session_id`, `order_id`, `email`, `ip` | **Never** | Unbounded by construction |
|
|
180
|
+
| Raw `path` / full URL with ids or query string | **Never** | `/orders/8123` and `/orders/8124` are two series |
|
|
181
|
+
| Exception message, upstream error text | **Never** | Free text is infinite cardinality wearing a label |
|
|
182
|
+
|
|
183
|
+
**The route-template rule: take the label from the router's matched pattern, never from the request path.** `req.route.path` in Express, `request.scope["route"].path` in FastAPI/Starlette, `chi.RouteContext(ctx).RoutePattern()` in Go, `http.route` in OTel. `request.url.path` is the bug, and it looks identical in code review.
|
|
184
|
+
|
|
185
|
+
High-cardinality dimensions belong in logs and traces, which are indexed for it. Metrics answer "how many and how slow"; logs and traces answer "which one".
|
|
186
|
+
|
|
187
|
+
## Tracing
|
|
188
|
+
|
|
189
|
+
**Spans at boundaries only.** A boundary is: the inbound request, an outbound HTTP/RPC call, a database query, a cache call, a queue publish or consume. That is the list.
|
|
190
|
+
|
|
191
|
+
A span per function produces a 400-span trace whose flame graph is unreadable and whose export cost exceeds the work it measured. Symptom: a trace UI that takes eleven seconds to render and answers no question the logs did not. **Tracing everything produces a bill and no insight** — the useful signal is almost always "which downstream call took the 900 ms", and that needs a dozen spans, not four hundred.
|
|
192
|
+
|
|
193
|
+
- **Attributes carry the high-cardinality detail** — `user.id`, `order.id`, `db.statement` (parameterised, never interpolated values). This is the correct home for what metrics may not hold.
|
|
194
|
+
- **Errors are recorded on the span** (`span.recordException`, status `ERROR`), so a failing trace is filterable without a text search.
|
|
195
|
+
- **Sampling:** head-based at 1–10% for steady-state volume; if the backend supports tail-based, keep **100% of traces containing an error or exceeding your p99 target** and sample the rest. Sample the *trace*, not the span — a partially sampled trace is a broken one.
|
|
196
|
+
- **Context propagation is the whole value.** An unpropagated `traceparent` gives you N single-service traces and the illusion of coverage.
|
|
197
|
+
|
|
198
|
+
## Health vs Readiness — Never the Same Endpoint
|
|
199
|
+
|
|
200
|
+
Two endpoints, two questions, two consequences:
|
|
201
|
+
|
|
202
|
+
| | Liveness `GET /livez` | Readiness `GET /readyz` |
|
|
203
|
+
| --- | --- | --- |
|
|
204
|
+
| Question | Is this process wedged? | Can this instance serve traffic *right now*? |
|
|
205
|
+
| Checks | Process-local only. No I/O. No database. No cache. No downstream. | Dependencies it cannot serve without, with a short timeout |
|
|
206
|
+
| Failure consequence | **The orchestrator kills the container** | Removed from the load-balancer pool; the pod keeps running |
|
|
207
|
+
| Recovery | Restart | Automatic, on the next successful probe |
|
|
208
|
+
|
|
209
|
+
**A liveness probe that checks the database causes a cascading outage, and this is the single most damaging mistake in this file.** The mechanism, step by step: the database has a 20-second blip — a failover, a saturated pool, a slow `ANALYZE`. Every instance's liveness check fails simultaneously, because they all depend on the same database. Kubernetes' default `failureThreshold: 3` with `periodSeconds: 10` means each container is killed after ~30 seconds. The orchestrator kills **all of them at once**. They restart into a cold cache and an empty pool, and hammer the recovering database with reconnects; the ones that come up are killed again on the next probe. A 20-second blip became a full outage that outlives its own cause, and the crash-loop backoff makes recovery slower the longer it runs. This is in the hub's Never Ship list as **a health check that queries the database**, and it is there for this reason.
|
|
210
|
+
|
|
211
|
+
```py
|
|
212
|
+
# LIVENESS — answers from memory. If the process can run this, it should not be killed.
|
|
213
|
+
@app.get("/livez")
|
|
214
|
+
def livez():
|
|
215
|
+
return {"status": "ok"}, 200 # no DB, no Redis, no HTTP, no disk
|
|
216
|
+
|
|
217
|
+
# READINESS — dependencies, each with a timeout shorter than the probe's timeoutSeconds
|
|
218
|
+
@app.get("/readyz")
|
|
219
|
+
async def readyz():
|
|
220
|
+
checks = {"db": await ping_db(timeout=0.5), "cache": await ping_cache(timeout=0.2)}
|
|
221
|
+
ok = all(checks.values())
|
|
222
|
+
return {"status": "ok" if ok else "degraded", "checks": checks}, (200 if ok else 503)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Exact probe config, and why each number:
|
|
226
|
+
|
|
227
|
+
```yaml
|
|
228
|
+
livenessProbe: # k8s defaults: periodSeconds 10, timeoutSeconds 1, failureThreshold 3
|
|
229
|
+
httpGet: { path: /livez, port: 8080 }
|
|
230
|
+
periodSeconds: 10
|
|
231
|
+
timeoutSeconds: 1 # a process too wedged to answer in 1s from memory IS wedged
|
|
232
|
+
failureThreshold: 3 # 30s of unresponsiveness before a kill — survives one GC pause
|
|
233
|
+
readinessProbe:
|
|
234
|
+
httpGet: { path: /readyz, port: 8080 }
|
|
235
|
+
periodSeconds: 5 # faster than liveness: leaving the pool is cheap and reversible
|
|
236
|
+
timeoutSeconds: 2 # must exceed the sum of the dependency timeouts above (0.7s)
|
|
237
|
+
failureThreshold: 2
|
|
238
|
+
startupProbe: # without this, a 60s boot is killed by liveness before it ever starts
|
|
239
|
+
httpGet: { path: /livez, port: 8080 }
|
|
240
|
+
periodSeconds: 5
|
|
241
|
+
failureThreshold: 30 # 150s of boot budget, then liveness takes over
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**`/readyz` must return `503` during graceful shutdown**, before the server stops accepting connections, so the load balancer drains first ([deploy-and-config.md](deploy-and-config.md)). **Neither endpoint is authenticated, and neither leaks**: no versions, hostnames, or connection strings — `{"checks": {"db": false}}`, never `{"db": "postgres://prod-eu-1..."}`.
|
|
245
|
+
|
|
246
|
+
## Alerting
|
|
247
|
+
|
|
248
|
+
**Alert on symptoms users feel, not on causes.** CPU at 90% with every request served in 40 ms is not an incident; it is a well-utilised machine. A page for it is how on-call learns to silence pages.
|
|
249
|
+
|
|
250
|
+
| Cause-based alert (delete) | Symptom-based alert (keep) |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `cpu > 90%` | `p99 latency > SLO for 10m` |
|
|
253
|
+
| `memory > 80%` | `5xx rate > 1% of requests for 5m` |
|
|
254
|
+
| "A pod restarted" | "Ready replicas < 2 for 5m" |
|
|
255
|
+
| "Queue depth > 1000" | "Oldest message age > 10m" |
|
|
256
|
+
| "A retry happened" | "DLQ depth > 0 sustained 15m" |
|
|
257
|
+
|
|
258
|
+
**Every alert names the action.** The annotation is not a description of the metric — that is on the graph. It is: what is broken from the user's point of view, the first thing to check, and the link to the runbook. An alert with no action is a dashboard; move it to one and delete the alert.
|
|
259
|
+
|
|
260
|
+
```yaml
|
|
261
|
+
- alert: CheckoutErrorBudgetBurnFast
|
|
262
|
+
expr: |
|
|
263
|
+
(sum(rate(http_server_requests_total{route="/checkout",status=~"5.."}[1h]))
|
|
264
|
+
/ sum(rate(http_server_requests_total{route="/checkout"}[1h]))) > 14.4 * 0.001
|
|
265
|
+
for: 2m
|
|
266
|
+
annotations:
|
|
267
|
+
summary: "Checkout failing for ~1 in 70 users; 2% of the 30-day budget burns per hour"
|
|
268
|
+
action: "Check /readyz across replicas, then payment-provider status. Runbook: <link>"
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`14.4` is not a taste: it is the Google SRE Workbook's fast-burn multiplier — 14.4× the error budget rate consumes **2% of a 30-day budget in 1 hour**. The companion slow burn is **6× over 6 hours (5% of the budget)**, which is a ticket, not a page. Use those two windows; do not invent a third.
|
|
272
|
+
|
|
273
|
+
The severity rule is one line: **page only for what a human must fix within minutes.** Everything else is a ticket. If an alert has fired twenty times and no one has ever acted, it has already been answered — delete it.
|
|
274
|
+
|
|
275
|
+
## Never Ship
|
|
276
|
+
|
|
277
|
+
| Never | Instead |
|
|
278
|
+
| --- | --- |
|
|
279
|
+
| A health check that queries the database | `/livez` process-local; `/readyz` checks dependencies |
|
|
280
|
+
| One endpoint serving both probes | Two endpoints, two consequences |
|
|
281
|
+
| Unbounded metric label cardinality (`user_id`, raw path) | Route template + bounded label set |
|
|
282
|
+
| `request.url.path` as a metric label | The router's matched pattern / `http.route` |
|
|
283
|
+
| An average latency on a dashboard | A histogram, p50/p95/p99 from raw buckets |
|
|
284
|
+
| Averaging p99 across instances | `histogram_quantile` over summed buckets |
|
|
285
|
+
| Default histogram buckets on a 2 ms endpoint | Buckets measured around the real distribution |
|
|
286
|
+
| ERROR for a handled `404` / `400` / `401` / `409` | INFO, or WARN if the rate is the anomaly |
|
|
287
|
+
| An ERROR that has never caused an action | Demote to INFO or delete it |
|
|
288
|
+
| Interpolated ids in the log `message` | Constant message, ids as fields |
|
|
289
|
+
| Redaction at the call site | A logger-level redact/processor config |
|
|
290
|
+
| A request that reaches a handler with no `request_id` | Edge middleware, first in the chain |
|
|
291
|
+
| A trace id that stops at the queue boundary | Propagate in the job payload |
|
|
292
|
+
| Logging inside a per-row loop | One summary line per batch |
|
|
293
|
+
| `Date.now()` deltas for `duration_ms` | A monotonic clock |
|
|
294
|
+
| An alert on CPU, memory, or a restart | An alert on latency, errors, or availability |
|
|
295
|
+
| An alert with no named action | A runbook link, or deletion |
|
|
296
|
+
| A `/readyz` body containing a URL, version, or hostname | Booleans only |
|
|
297
|
+
| An invented SLO, threshold, burn rate, or sampling rate | The values in this file, or the project's config |
|
|
298
|
+
|
|
299
|
+
## Output
|
|
300
|
+
|
|
301
|
+
Write the instrumentation. Then, briefly: **the join key** (where `request_id` is generated, where it is returned, and confirm you traced one request end to end), **the labels you used and their cardinality bound**, and **which alert fires if this breaks, and what it tells on-call to do**. If the honest answer was "already covered — the framework emits this and the probes are already split", say that in one line and add nothing. A service that needed no new instrumentation is a good result, not a skipped task.
|