ClickStack take-home practice
Partitioning
logs is range-partitioned by observed_at, one child table per UTC day. Thirteen relations, one logical table.
- 13
- relations
- 10
- daily partitions
- 200,000
- rows
- 0
- rows in DEFAULT
INSERT INTO logs never writes to logs. PostgreSQL reads observed_at and routes the row to a child.Range on observed_at prunes to one child — 483 planning buffers, one partition scanned.
ORDER BY id DESC cannot prune — Merge Append across every child.
- 01Every partition is an independent table
- The parent has no storage of its own. Each child is a real table you can query, VACUUM, or DROP directly — which is exactly why retention is a metadata unlink instead of a bulk DELETE.
- 02Indexes are per partition, not global
- PostgreSQL has no global index across children. Every index in schema.sql is created once per partition, so ORDER BY id DESC becomes a Merge Append over one index scan per child.
- 03DEFAULT catches whatever matches nothing
- A safety net so ingest never fails on a missing daily partition. Rows landing there mean maintenance stopped running, so its count is an alert, not a metric.
SELECT EXISTS (SELECT 1 FROM logs_default); - 04Pre-creation has to be somebody's job
- This service runs it in-process at startup and hourly. The alternatives: pg_partman for the lifecycle, pg_cron to schedule inside the database, or an external scheduler — a Kubernetes CronJob or an Airflow DAG.
Partition pruning
Pruning is the planner discarding partitions it can prove hold no matching rows. It only works on the partition key — observed_at. An id predicate tells the planner nothing about which day a row lives in, so every child stays in the plan.
SELECT * FROM logs
ORDER BY id DESC LIMIT 100;Limit (actual rows=100)
-> Merge Append <- opens every child
-> Index Scan logs_2026_07_21 rows=1
-> Index Scan logs_2026_07_22 rows=1
-> ... 8 more children, one row each
-> Index Scan logs_2026_07_30 rows=100
-> Index Scan logs_default rows=0- children opened
- 12
- planning buffers
- 2,050 cold
- planning time
- 3.46 / 0.11 ms
- execution time
- 0.175 ms
SELECT * FROM logs
WHERE observed_at >= $1 AND observed_at < $2
ORDER BY id DESC LIMIT 100;Limit (actual rows=100)
-> Index Scan logs_2026_07_30 rows=100
-> Filter: observed_at >= .. AND < ..
no Merge Append at all —
11 children removed at plan time- children opened
- 1
- planning buffers
- 486 cold
- planning time
- 1.01 / 0.06 ms
- execution time
- 0.055 ms
Planning dominates — but only on the first query per connection. The 3.46 ms above is a cold catalog cache. Run the same query again on the same connection and planning drops to 0.11 ms (14 buffers, not 2,050). With a pooled connection you pay the cold cost once per backend, then it amortises away. The pruned query still wins warm — 0.06 ms versus 0.11 ms — just by far less than the cold numbers suggest.
▶How does it scale?O(partitions), flat in rows
Two sweeps, each holding the other variable fixed — otherwise the curve measures both at once. Cost is per child: the planner opens and locks each partition, and Merge Append pulls a first tuple from each. Nothing in either step depends on how many rows a child holds.
Both panels measure cold planning buffers — 8 KB pages the planner reads to build a plan. A page count is deterministic; sub-millisecond timings on this machine are not.
Hover for exact values.
Same y-scale as the left panel.
Table view — every measurement
| Configuration | Cold buffers | Per partition | Cold plan | Execution |
|---|---|---|---|---|
| 1 partitions | 168 | 168 | 0.39 ms | 0.020 ms |
| 2 partitions | 223 | 112 | 1.43 ms | 0.072 ms |
| 4 partitions | 332 | 83 | 0.64 ms | 0.043 ms |
| 8 partitions | 552 | 69 | 1.15 ms | 0.083 ms |
| 16 partitions | 993 | 62 | 1.92 ms | 0.149 ms |
| 32 partitions | 1,875 | 59 | 3.26 ms | 0.311 ms |
| 64 partitions | 3,640 | 57 | 6.23 ms | 0.437 ms |
| 128 partitions | 7,164 | 56 | 13.17 ms | 1.049 ms |
| 1k rows @ 16 partitions | 993 | — | 1.84 ms | 0.103 ms |
| 10k rows @ 16 partitions | 993 | — | 1.80 ms | 0.144 ms |
| 100k rows @ 16 partitions | 995 | — | 1.88 ms | 0.150 ms |
| 1M rows @ 16 partitions | 994 | — | 1.97 ms | 0.170 ms |
Linear in partitions. 1 → 128 partitions takes cold planning from 168 to 7,164 buffers with total rows unchanged. That is a steady ~56 buffers per partition across a 128× range. Execution tracks it: 0.020 → 1.049 ms.
Flat in rows. 1,000 → 1,000,000 rows at 16 partitions: planning buffers go 993, 993, 995, 994. A 1,000× data increase moves planning by one page. Execution creeps 0.103 → 0.170 ms — logarithmic, a deeper B-tree descent.
| Grows with… | Planning | Execution | Why |
|---|---|---|---|
| Partitions | O(P) | O(P) | Every child is opened and locked to plan, then contributes one index scan to Merge Append. |
| Rows | O(1) | O(log n) | Planning reads catalog, never data. Execution pays one B-tree descent per child and stops at the LIMIT. |
So the sizing rule is: partition count is the budget, not table size. At the 30 daily partitions this service retains, an unprunable cursor costs roughly 1,700 planning buffers — invisible. At hourly partitions over the same 30 days that is 720 children, ~40,000 buffers and 70+ ms of cold planning per connection. That is where the observed_at bound stops being optional — and it is a retention-policy decision, not a data-volume one.
▶How to read the planfield reference
- cost=3.34..16007.46
- Estimates in arbitrary page-fetch units, not milliseconds. First number is startup cost — work before the first row. Second is total cost for all rows. A LIMIT can stop early, which is why the Limit node's total is tiny while Merge Append's is huge.
- rows=200002 width=144
- Estimated row count and average row size in bytes. Compare against the actual rows to spot bad statistics — a 100× gap is why a planner picks the wrong join or scan.
- actual time=0.117..0.129
- Real milliseconds, startup..total, only present with ANALYZE. Times are per loop and inclusive of children, so a parent's time already contains its subtree's.
- loops=1
- How many times the node ran. Actual time and rows are per loop — multiply by loops for the true total. A nested loop with loops=20000 is where plans usually go wrong.
- Buffers: shared hit=36
- 8 KB pages read from cache (hit) versus disk (read). The honest unit of work — it does not vary with a warm or busy machine the way timings do.
- Planning: shared hit=2050
- Pages touched to build the plan. This is where partition count bites: the planner opens and locks every child before it can prune. Here planning costs 20× more than execution.
▶What the observed_at bound looks likecursor design
The fix is not simply a composite cursor. A row comparison alone leaves Index Cond: (ROW(observed_at, id) < ROW(...)) and prunes nothing — the planner needs a scalar predicate, and it needs a floor, since an upper bound only removes partitions newer than the cursor.
- 01
Cursor carries both columns
nextCursor: `${row.observed_at.toISOString()}_${row.id}` - 02
Upper bound prunes newer partitions, row comparison keeps paging exact
WHERE observed_at <= $1 AND (observed_at, id) < ($1, $2) ORDER BY observed_at DESC, id DESC - 03
A floor is what actually prunes — compute it in JS, never as SQL interval math
WHERE observed_at > $3 -- cursor minus one day AND observed_at <= $1 AND (observed_at, id) < ($1, $2)
| Cursor shape | Children | Plan buffers | Planning time |
|---|---|---|---|
| id-only cursor (today) | 12 | 2,109 | 4.05 ms |
| + upper bound only | 9 | 1,719 | 3.03 ms |
| + 1-day floor, interval math | 2 | 1,740 | 3.53 ms |
| + 1-day floor, plain literals | 2 | 626 | 1.29 ms |
The last two rows are the surprise: both prune to two children, but writing the floor as observed_at > $1 - interval '1 day' costs nearly three times the planning work of passing a plain timestamp. Compute the window bound in application code.
The row that never arrives
Sorting by id is the right call for paging — it is server-assigned and immune to client clock skew. But id order is allocation order, and rows become visible in commit order. Those are not the same sequence, and a live tail that advances a cursor on id can step over a row that has not committed yet.
GENERATED ALWAYS AS IDENTITY is a sequence, and sequences deliberately sit outside transaction control — otherwise every insert would serialise on them. The cost of that decision is this gap.
Reproduced deterministically — npm run demo:race
poller cursor starts at 200000
txn A INSERT -> id 200005 (uncommitted)
txn B INSERT -> id 200006 (uncommitted)
txn B COMMIT -> id 200006 is now visible
POLL #1: WHERE id > 200000 -> 200006:fast-writer
cursor advances to 200006
txn A COMMIT -> id 200005 becomes visible, but it is BEHIND the cursor
POLL #2: WHERE id > 200006 -> (nothing)
cursor advances to 200006
in the table: 200005:slow-writer, 200006:fast-writer
delivered by the tail: 200006:fast-writer
never delivered: 200005 <-- the bug
sequence gap: the rolled-back row consumed id 200007, the next committed row got 200008
ids are unique and increasing, but never contiguous.Is it a bug?
Yes — in the tail. But it is a design limitation, not a coding error: every line does what it intends. The flawed premise is that a sequence cursor tracks visibility order.
It is latent — two ingests must overlap, and a poll must land between one’s id allocation and its commit. And it fails silently: no error, no log, no metric. Sequence gaps are normal anyway (a rollback burns its id), so a missing id can never be used to detect it.
| GET /api/logs/tail | yes | Row is never delivered to any client. Permanent, and silent. |
| GET /api/logs | partly | Missed for that scroll session only; a fresh page one shows it. |
| GET /api/logs/stats | no | Aggregates read committed state at query time. |
| POST /api/logs | no | The write itself is correct and durable. |
Shipping it documented was the right call — the correct fix is a substantial build, and the limitation is written down in the decision log rather than discovered by a reviewer. One thing to tighten though: the log calls this acceptable “under the single-writer assumption,” but server/db.ts opens a pool of ten. That assumption was already false — it held because the test traffic was serial, not because the design guaranteed it.
The fix ladder
- 1
Stop the cursor at the first id gaprejected
The tempting one-liner, and it is wrong. Deletes, rollbacks and dropped partitions all leave permanent holes, so the cursor stalls forever on the first one. Measured: delivered nothing at all.
- 2
Overlap re-read — WHERE id > cursor - Kmitigation
Narrows the window to whatever K covers and needs dedupe downstream. Never closes it.
- 3
Trailing window — only deliver rows older than Lsimplest fix
One extra predicate: AND observed_at < now() - L. Correct as long as L exceeds the longest gap between a row's INSERT and its COMMIT, which idle_in_transaction_session_timeout already bounds. Costs L of tail latency and survives id gaps. Measured: delivers the skipped row.
- 4
xmin snapshot watermarkcorrect
Advance only to pg_snapshot_xmin(pg_current_snapshot()). Same guarantee with no fixed latency penalty, at the cost of real complexity.
- 5
LISTEN / NOTIFY as a wake signalcorrect
Notifications fire on commit, so allocation order stops mattering. NOTIFY is not durable, so it must trigger a catch-up query rather than carry the payload.
- 6
Logical decoding / CDCcorrect
The WAL is commit order by definition — and the same pipeline that would feed ClickHouse.
Track B against the prompt
Every row is a literal requirement from the assignment, checked against what the code does — not what the decision log says it does. Two of them do not fully hold.
| GET /api/logs/tail — streaming endpoint (e.g. SSE) | met | Valid SSE headers, heartbeat comments every 15s, retry: 3000, one shared process-wide poller on a 1s tick. |
| …honoring the same filters as the query endpoint | partial | Only service and level. The schema is a strictObject, so ?q=timed returns 400 on /tail while /api/logs accepts it. Time bounds are arguably N/A for a tail; q is not. |
| Clean up when clients disconnect | met | close handler removes the subscriber, and the poll and heartbeat timers stop once the last one leaves. |
| GET /api/logs/stats — counts by level and by service | met | One GROUPING SETS query returns totalCount, countByLevel and countByService from a single scan. |
| …for an optional time range | deviation | from/to are optional and default to the last 24h, but any span over 24h is rejected with a 400. A deliberate bound on worst-case cost, not an oversight — say so before they ask. |
| API-key auth on POST /api/logs | met | x-api-key compared with timingSafeEqual after a length check. 503 when unconfigured, 401 on mismatch. Read routes stay public. |
| …plus per-key rate limiting | partial | Keyed on the raw x-api-key header before that header is validated, so rotating it mints a fresh 1,000/min bucket every request. Per-key in shape, bypassable in practice. |
Stats — why the range is bounded
Postgres is serving ingest and recent operational reads, not an unbounded OLAP workload. Thirty-day retention helps, but an unbounded from/to would still let one request scan the whole retained month. So the range is optional, defaults to the last 24 hours, and is capped at 24 hours — any wider span is a 400.
The contract, verified
no params -> last 24h, totalCount 20000
25h span -> 400 "stats range cannot exceed 24 hours"
exactly 24h -> 200
to before from -> 400 "to must be at or after from"
historical day -> 200, any 24h window in retentionThe window is on observed_at, not event_time — so it prunes to one partition and its semantics match retention. It is half-open (>= from AND < to) so adjacent windows tile without double-counting.
One GROUPING SETS query vs two GROUP BYs
| Query | Buffers | Consistency |
|---|---|---|
| GROUPING SETS (one scan) | 438 | one snapshot |
| GROUP BY service | 438 | separate snapshot |
| GROUP BY level | 79 | separate snapshot |
| two queries, combined | 517 | two snapshots |
Worth correcting yourself on: the decision log says two queries read the pages twice, for half the I/O. Measured here it is 438 vs 517 — about 15%, not 50% — because logs_observed_at_level_idx covers the level aggregate with an index-only scan. The argument that needs no benchmark is the third column: one query, one snapshot, so the counts cannot disagree with each other.
Protecting ingestion — and the hole in it
Started with: hand-rolled key validation, an in-memory limiter, and an api_keys table.
Moved to: express-rate-limit plus one API_KEY environment variable, no table.
Why: removes a database round trip per request and custom limiter maintenance, and gets standard IETF draft-8 headers for free. Deleting your own working code is part of the story, not a detour.
Known limits, stated up front: in-memory counters do not coordinate across instances (needs Redis), they reset on restart, and with one configured key “per-key” is a single shared budget rather than real per-tenant quotas.
The limiter is bypassable. Its key is the raw x-api-key header, read before requireApiKey validates it. Rotate the header and every request lands in a fresh bucket.
rotating key: r=999 r=999 r=999 <- never decrements
fixed key: r=999 r=998 r=997 <- correctThe per-IP fallback was reasoned carefully — it stops an anonymous flood from draining a legitimate key’s quota — but it guards the wrong direction. Fix: key on ip + key, or throttle by IP until the key validates.
The live tail — built vs deferred
Straight from the decision log’s own ledger. Only the tracer bullet was marked complete; claiming more than this in an interview is the one unforced error available here.
- doneBasic SSE tracer bullet — Headers, heartbeats, disconnect cleanup, validated filters.
- to doShared poller — Listed as to-do: move from one DB poller per client to one shared poller so queries do not scale with client count.
- to doLast-Event-ID resume — Events are framed with id:, but reconnect rejoins live and ready advertises replaySupported: false. Gaps, not duplicates.
- to doLISTEN/NOTIFY instead of polling — Replace the fixed interval with an event-driven wake signal.
- to doBackpressure — Listed as to-do: define a drop, queue or disconnect policy for slow clients.
- to doFilter-aware fan-out — Subscriber filters are matched linearly per row; an indexed subscriber structure pays off at high client counts.
Follow-up questions
What a ClickHouse engineer is likely to ask about this design, ordered by how probable it is. Answer aloud before opening the spine — reading one is not rehearsing it.
They will pull the ClickHouse thread
near-certain4 questions01You partition by day in Postgres. What is the ClickHouse equivalent, and is it the same thing?▶
It is not. PARTITION BY toDate(observed_at) looks identical but does a different job: in ClickHouse the ORDER BY key is the primary index, and partitions are mostly a data-management and TTL unit. Over-partitioning there is a known foot-gun — each partition multiplies parts and merge pressure, where in Postgres it multiplies planner work. Daily is right in both, for different reasons.
02Would you still need the DEFAULT partition?▶
No. ClickHouse creates partitions implicitly on insert, so there is nothing to pre-create and nothing to fall through to. The DEFAULT partition, the pre-creation job, and the alert on its row count all exist purely because Postgres requires the target partition to exist before the insert.
03What replaces your setInterval retention job?▶
A declarative TTL observed_at + INTERVAL 30 DAY DELETE on the table. The entire partitions.ts file disappears, along with the advisory lock, the DETACH/ATTACH dance, and the ACCESS EXCLUSIVE stall that comes with it.
04Your id cursor — does it survive the move?▶
No, and this is the one to volunteer before being asked. A monotonic global sequence is a single-writer construct; there is no IDENTITY across ClickHouse shards. Paging would move to (observed_at, tiebreaker), which changes the public API contract. It is the single design decision in the submission that does not port.
Track B — did you actually meet the spec?
near-certain6 questions01Show me curl -N 'localhost:5173/api/logs/tail?q=error'.▶
It returns 400, Unrecognized key q. The tail schema is a strictObject with only service and level, so it rejects a filter the search endpoint accepts. The prompt says the tail should honour the same filters, so this is a genuine gap — matchesFilters is a two-line predicate and adding a substring test on message is small. Time bounds are a fairer omission: from/to on a live tail are close to meaningless, since every row is by definition new.
02What stops me from ignoring your rate limit?▶
Nothing, today. The limiter keys on the raw x-api-key header and runs before requireApiKey validates it, so rotating the header mints a fresh 1,000-per-minute bucket on every request. I verified it: RateLimit stays at r=999 with a rotating key and counts down correctly with a fixed one. The per-IP fallback was deliberate — it stops an anonymous flood from draining a real key's quota — but it protects the wrong direction. The fix is a composite ip+key, or throttling by IP until the key validates.
03Why cap the stats range at 24 hours when the prompt says the range is optional?▶
The range is optional — omit both and you get the last 24 hours. The cap is on the span, and it is a deliberate bound on worst-case cost: Postgres is serving ingest and recent reads here, and without a cap one request could scan the entire 30-day retention. Any historical window still works as long as it is 24 hours or narrower. If a product needed month-wide aggregates, the answer is rollup tables or a column store, not lifting the cap.
04Why GROUPING SETS instead of two GROUP BY queries?▶
One scan and one snapshot, so the level counts and service counts cannot disagree with each other — that argument needs no benchmark. On I/O I would correct my own decision log: it claims two queries read the pages twice for half the I/O, but measured on this schema it is 438 buffers versus 517, roughly 15%, because logs_observed_at_level_idx covers the level aggregate with an index-only scan. The 2× claim holds only without that covering index.
05Your stats window is on observed_at but search filters on event_time. Why the mismatch?▶
Deliberate, and it is the one place the difference pays off. observed_at is the partition key, so a stats range prunes to a single child — one partition instead of twelve. It also makes the endpoint's time semantics match retention. Search filters on event_time because operators care when the producer says something happened; the cost is that a search time filter can never prune.
06You disconnect slow SSE clients at a 1 MB buffer. Why disconnect rather than drop?▶
Disconnect is a policy choice: a client that cannot drain 1 MB is not going to catch up on a firehose, and dropping silently gives it a false view of the stream. Reconnecting gets it back to live immediately. The honest caveat is that the decision log lists backpressure as a to-do, so treat this as the shape of the answer rather than a claim that it was fully designed — and any real version needs a metric on how often it fires.
On the skipped row — they will quote your own log line
near-certain7 questions01You wrote that the live tail may skip a row. Explain it to me.▶
id comes from a sequence, and sequences allocate outside transaction control so inserts do not serialise on them. That makes id allocation order, while visibility is commit order. Txn A takes id 5, txn B takes 6, B commits first, the poller runs WHERE id > cursor, sees only 6, and advances past 5. When A commits, 5 is behind the cursor and no client ever receives it. It is reproducible on demand — npm run demo:race forces the interleaving.
02So is that a bug you shipped?▶
In the tail, yes. It is a design limitation rather than a coding error — every line does what it intends; the flawed premise is that a sequence cursor tracks visibility. It is latent (needs concurrent ingest) and silent (no error, no metric). I shipped it documented because the correct fix is a substantial build and the endpoint is explicitly best-effort. What I would tighten is the wording: I called it acceptable under a single-writer assumption, but the pool is ten connections, so that assumption was already false. It held because my test traffic was serial.
03How would you detect this in production if you did not already know about it?▶
You largely cannot from the data, which is the uncomfortable part. Sequence gaps are normal — a rollback burns its id permanently — so a missing id proves nothing. Detection has to come from the delivery side: count rows the poller emitted versus rows committed in the same window and alert on divergence, or run a reconciliation query that replays a trailing window and compares against what was sent.
04Pick one fix and defend it.▶
A trailing window, because it is one predicate: AND observed_at < now() - L. It is correct provided L exceeds the longest gap between a row's INSERT and its COMMIT, which idle_in_transaction_session_timeout already bounds, and it costs L of tail latency. I tested it against the forced interleaving and the skipped row is delivered. The tempting alternative — stop the cursor at the first id gap — I also tested, and it is wrong: deletes, rollbacks and dropped partitions all leave permanent holes, so it stalls forever. If the latency mattered I would move to LISTEN/NOTIFY as a wake signal for a catch-up query, since notifications fire on commit.
05Why not just use a timestamp cursor instead of id?▶
It has the same defect and adds new ones. observed_at is clock_timestamp(), assigned before commit, so a late-committing row can carry an earlier timestamp than one already delivered — identical race. On top of that, timestamps collide, so you need a tiebreaker anyway, and any clock adjustment reorders history. Swapping id for a timestamp changes which column is wrong, not whether the cursor is correct.
06Does the same race affect your keyset pagination?▶
Yes, but far less severely. A page walks toward lower ids, so a row that commits late above the cursor is skipped for that scroll session and shows up on a fresh page one. That is a consistency artifact rather than data loss. If it mattered, the fix is a repeatable-read snapshot or paging on a snapshot id captured at page one.
07Would this bug exist in ClickHouse?▶
Not this one — there is no cross-shard sequence to allocate out of order. But the underlying problem does not vanish: inserted parts become queryable after they are committed and merges reorder data, so a tail still needs a watermark rather than an assumption that a monotonic key equals visibility order. Kafka offsets or a CDC stream give commit order directly, which is why real log pipelines put a log in front of the store.
They are an observability company — scale questions
very likely4 questions01200,000 rows is a toy. What breaks first at 200 billion?▶
The stats endpoint and free-text search, in that order — both scan rather than seek. At roughly 1M logs/day over 30 days retention you are at 30B rows, well past where a row store on one box is the right call. Ingest and keyset paging would still be fine; aggregation is what forces the move.
02Your q filter is ILIKE '%…%'. What does that cost?▶
A leading wildcard cannot use a btree, so it is a sequential scan across every partition in range. The Postgres fix is a pg_trgm GIN index per partition. In ClickHouse it is a token bloom-filter skip index or the full-text index. This query is the strongest single argument for moving the workload.
03Why not use ClickHouse from the start?▶
At the prompt's scale Postgres was correct and every query is bounded. The honest version names the two triggers that would change the answer: p95 latency on the stats endpoint, and search latency once messages stop fitting in cache. Without traffic data, a second datastore is speculative complexity.
04What ORDER BY key would you choose in ClickHouse?▶
Likely (service, observed_at) — low-cardinality prefix first so the sparse primary index prunes granules effectively, then time within a service. service and level would both be LowCardinality(String), which cuts storage and speeds up the group-bys the stats endpoint runs.
On the measurement work itself
likely3 questions01Your planning cost was a cold cache. Does that matter in production?▶
Much less than the raw number suggests. It is paid once per backend connection, so a pool of ten pays it ten times and then it amortises to about 0.11 ms. The pruned query still wins warm, just by 2× rather than 30×. Measuring it is what turned a scary number into a bounded one.
02Why report buffers instead of milliseconds?▶
A buffer count is a deterministic page count; a sub-millisecond timing on a laptop has an error bar wider than its own value — the warm planning spread at 128 partitions was ±1.5 ms on a 0.76 ms median. Buffers do not move with machine load, so they are the honest unit for a claim about complexity.
03You claim O(partitions). Did you hold rows constant?▶
Not at first — the initial sweep held rows-per-partition constant, so total rows grew with partition count and the curve measured both variables at once. Rerun with total rows pinned at 64,000, the per-partition cost converges on ~56 buffers and stays flat across a 128× range. The separate row sweep moved planning by one page across 1,000× more data.
The uncomfortable ones
be ready4 questions01clock_timestamp() splits one batch across two partitions at midnight. Intentional?▶
Yes. now() freezes at transaction start; clock_timestamp() advances per row, so rows in one batch get distinct observed_at values and a batch landing at UTC midnight can straddle two children. Harmless because reads order by id, not observed_at. now() would also be defensible — one timestamp per batch, cleaner routing — and naming that trade is the actual test.
02Your maintenance job takes ACCESS EXCLUSIVE on the parent to detach DEFAULT. What happens to ingest?▶
It stalls for the whole detach → create → move → re-attach transaction. This is the sharpest self-identified weakness in the design. The window is short and only fires when a new daily partition is created, and an advisory lock serialises replicas. Production answer: pre-create several days of runway so the pause never coincides with load. DETACH CONCURRENTLY is restricted when a DEFAULT partition exists — which is exactly why pg_partman exists.
03Why does every index on the table exist?▶
Each one has to name a query. logs_id_idx serves the keyset cursor; logs_service_id_idx and logs_level_id_idx serve filtered paging; the observed_at composites serve stats and the SSE tail. Unused indexes are pure write-amplification, which matters more to a logs product than almost anything else — an index nobody queries is a tax on every insert.
04Two clients connect at the same instant to an idle tail endpoint. Walk me through it.▶
There is a real race: startStreamTimers guards on pollTimer !== undefined before an await, so two concurrent first-subscribers both pass the guard, both query the latest id, and both call setInterval — the second assignment leaks the first interval. The fix is a promise latch storing the in-flight startup promise rather than the timer. Same check-then-act family as the id-versus-commit-order race already in the decision log.