Skip to content

Store disk-pressure check's pg_database_size times out in bursts: 5s deadline sized on a 4.05 GB store, beating against the 300s checkpoint cycle #3199

Description

@erikdarlingdata

The user_request_cancel clusters on the use2 store are the store disk-pressure check's pg_database_size call hitting its 5-second deadline. The deadline was sized against a 6.2 ms measurement on a 4.05 GB store; use2's store is an order of magnitude larger, and pg_database_size walks every file in it.

What the cancels are

DarlingWorker.cs:4371 — new NpgsqlCommand("SELECT pg_database_size(current_database())", connection) { CommandTimeout = ServiceCommandDeadlines.SerialLoopSeconds }, and SerialLoopSeconds is 5.

Read from the use2 store's own PostgreSQL log, every cancel carries the statement:

2026-09-09 03:45:27.336 UTC [4304] ERROR:  canceling statement due to user request
2026-09-09 03:45:27.336 UTC [4304] STATEMENT:  SELECT pg_database_size(current_database())

Eight occurrences on 2026-09-09, all between 03:45:27 and 04:21:03, each on a different PID. None anywhere else in the day.

The mechanism, and why it clusters

The intervals between consecutive cancels are 305.181, 305.125, 305.123, 305.103, 305.118, 305.131, 305.241 seconds — mean 305.146 s, spread 0.138 s.

The check is finish-to-start on a 5-minute sleep, so its period is 300 s plus however long the call takes. 305.1 s means the call is consuming its full 5 s deadline every iteration — during a cluster, every check times out, not some.

Checkpoints on this store run on a 300 s timer with write phases spanning ~270 s of that (checkpoint complete: ... write=269.673 s, total=271.800 s), so the store is writing for roughly 90% of every checkpoint cycle.

Two periods 5.146 s apart beat with a period of 300 × 305.146 / 5.146 = 17,789 s = 4.94 hours. So:

  • while the check's phase overlaps the checkpoint's write phase, pg_database_size's file-stat walk is starved and blows the 5 s deadline;
  • each timeout stretches that iteration's period from 300.0 s to 305.1 s, which walks it out of phase;
  • after ~30 minutes it clears the write phase, succeeds in milliseconds again, and the period returns to 300.0 s until the next beat brings it back.

It is a self-limiting oscillation, which is exactly why it appears as a bounded burst on a long cycle rather than as a permanent fault.

Corroboration: two beat periods is 9.88 hours. The monitor loop independently observed clusters at 03:41–04:07 and 14:04–14:14 UTC — 10.4 hours apart, and the observed start time drifts between cycles, as a beat does.

Why the 5 s deadline does not transfer

ServiceCommandDeadlines.cs:341 states the provenance plainly:

Measured against a 4.05 GB store built by the product's own MigrateAsync and seeded through SeedIfEmptyAsync … pg_database_size 6.2 ms — so the worst SINGLE command on this thread is a single-digit-millisecond read and 5 s is roughly three orders of magnitude above it.

The reasoning is sound and the measurement was real. What moved is the population. pg_database_size stats every file in the database directory, and a TimescaleDB store's file count scales with chunk count rather than with byte size — so a store an order of magnitude larger has many times the files, and the walk competes with checkpoint I/O for the same disk. A margin of "three orders of magnitude" over a measurement taken on a 4.05 GB fixture is not a margin over this store.

This is the same shape as #3188: a constant carrying real evidence from a population that has since moved, with nothing that notices.

Impact: log noise and a missing number, NOT a monitoring gap

I expected this to blind the disk-pressure alert and checked. It does not. The catch at DarlingWorker.cs:4376 says so, and it is right:

NOT counted by #3013's swallowed-read counter: this read is CONTEXT for the alert text, not the evidence the alert is judged on — that is freeBytes/totalBytes above.

The alert judges on DriveInfo free/total, which is a syscall and unaffected. Losing this costs the alert message a number.

What it does cost:

  1. ERROR-level lines in the store log on a recurring cycle, which get_store_log's classifier then has to absorb as user_request_cancel — part of the "expected floor" whose text is deliberately not retained. So the product generates noise that another part of the product is configured to discard, and the discarding is what made this hard to attribute.
  2. A real diagnostic dead end. Attribution needed raw store-log access; the classifier's summary could not answer it by design.

Fix options

  1. Give this call its own deadline rather than the shared SerialLoopSeconds. It is the only command on that chain whose cost scales with store size, and the chain-length argument (10 × 5 s = 50 s inside the 60 s watchdog) is what pins the shared value — so a separate constant for this one call does not disturb it.
  2. Stop calling pg_database_size on a 5-minute cadence. It is context for an alert message. pg_total_relation_size summed from store_metrics' hourly snapshot, or simply the last recorded value, would serve the message without a filesystem walk.
  3. Do nothing, and record why. Defensible — the effect is a missing number and some log noise — but then the classifier's "expected floor" note should name this call, so the next person who asks "what is cancelling?" gets an answer instead of a dead end.

I would take (2): the call is doing filesystem work every five minutes on every store to decorate a message, and it is the only reason this deadline is load-bearing at all.

Found while attributing the cancel clusters the monitor loop flagged; the offset defect #3198 was found in the same investigation.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions