Skip to content

Darling: the daily retention purge runs inline in the collection launch loop, so each SQL store stops collecting for 7–10 minutes a day #4130

Description

@erikdarlingdata

Problem

On the SQL Server stores, the scheduled daily retention purge runs inline in the collection launch loop. For as long as the purge runs, no new per-server sweep is launched, so every monitored server goes stale.

Measured on two production SQL stores on 2026-09-24 (MCP reads):

  • Store A: no new collections for about 9 minutes. The data_retention run took 400,781 ms and deleted about 839k rows. Staleness peaked at 13.4 minutes, and 31 of 43 servers read Warning "collection stale". The same hole appeared the day before.
  • Store B: the same, for about 7 minutes (a 346 s purge).
  • A PostgreSQL-target store is unaffected; its purge takes about 311 ms.
  • Every run reported SUCCESS, with 0 collection failures. No staleness self-alert fired during the stall.

Where

Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs, about lines 2556–2565, inside the sweep-launch loop:

if (DateTime.UtcNow >= _nextPurgeUtc) { _nextPurgeUtc = now + 24h; await DarlingRetention.PurgeAsync(...); await ...CleanupOldFindingsAsync(...) }

While those awaits run, the loop launches nothing. In-flight sweeps finish, then every server goes stale until the purge returns.

_nextPurgeUtc starts at DateTime.MinValue, so the first purge runs at service start and then every 24 hours. The daily hole is anchored to the last restart.

The purge_now command's own comment says a purge "takes NO collection gate" and "may safely overlap the daily sweep". So by the code's own reasoning, running the scheduled purge off the launch loop is safe. That is inferred, not tested.

Fix

  • Run the scheduled purge (and the findings cleanup that follows it) off the launch loop, as its own background task with the service's cancellation token.
  • Never run two purges at once: skip the tick if the previous one is still running, and log that.
  • Keep _nextPurgeUtc's schedule and the purge's own logging and self-metrics as they are.
  • A test pins that the launch loop keeps launching sweeps while a purge is in flight, using a purge that blocks until released.

Also check

Why no staleness self-alert fired during a 13-minute stall. The stale threshold may be above 13 minutes, or the evaluator may run on the stalled loop. If the evaluator shares the loop, the fix above covers it too. Otherwise, report the threshold.

Activity

  1. added
    client-siteOwned by the client-site agents (other laptop). Local sessions never pick these up.
    on Sep 24, 2026
  2. added a commit that references this issue on Sep 24, 2026
    dcf040a
  3. erikdarlingdata commented on Sep 24, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by the watcher: delivered in PR #4133, merged to dev.

  4. erikdarlingdata commented on Sep 24, 2026

    @erikdarlingdata
    OwnerAuthor

    Reopening on evidence from the first nightly that carries #4133 (3.8.0-nightly.20260924.467). #4133 moved the daily purge off the collection launch loop, so it now runs alongside collector writes. On the largest SQL Server store (43 servers), the first purge after the install failed, and collector writes failed during it. The pre-registered watch bar for #4133 was one purge-window retention failure, and this is one.

    What the service log shows (all times UTC, the day of the install):

    • 22:13:34: the collection loop starts, and the purge launches with it (Fix #4130: fire-and-track the daily retention purge off the launch loop #4133's first-start run).
    • 22:14:23: the deadlock re-mask stage fails with 57014: canceling statement due to statement timeout.
    • 22:15:58: a collector's query_store store write fails: store-write COPY data phase (rows in flight under the collection-sweep deadline): Exception while reading from stream.
    • 22:29:32: Could not read config_version … Exception while reading from stream.
    • 22:32:09: Retention purge failed for query_plan_dim after removing 250000 row(s): Exception while reading from stream.
    • 22:32:10: Retention purge: 85 table(s) purged, 293188 row(s) deleted, 0 chunk(s) dropped, 1 failed, 1116220ms.
    • 22:35:04: a second collector query_store COPY fails (Exception while writing to stream).
    • 22:37:13: module_map refresh failed … Exception while reading from stream. Nothing failed after this.

    Measured, for comparison:

    • The purge took 1,116 s. The same store's inline purge took 346–400 s before Fix #4130: fire-and-track the daily retention purge off the launch loop #4133.
    • Its previous run, inline at 03:31 the same day, was SUCCESS (838,989 rows, 26 chunks). This is the first non-success data_retention row on that store in 8 days.
    • Store-write COPY failures of this kind also occur without a purge running: 1–2 on each of 4 of the previous 7 days. So those two alone are not new; the new part is that they fell inside the purge window.
    • The other three stores on the same build ran their first-start purge with no failure.

    Not yet shown (inferred, not measured): the stream errors are Npgsql command timeouts on the store, caused by the purge's query_plan_dim delete contending with collector COPYs and the post-start refresh/backfill work. Other load was also present right after the restart: a collection_health_hourly refresh of 95 s, and autovacuum workers active afterwards. So this run may be a first-start worst case rather than the steady state.

    Next:

  5. erikdarlingdata commented on Sep 25, 2026

    @erikdarlingdata
    OwnerAuthor

    Mechanism evidence for the first-start query_plan_dim failure (the store that failed, build .467, read 2026-09-25 ~02:15–02:20Z; read-only).

    The failing statement was the sixth 50,000-row batch of the row-capped plan-dimension delete, cancelled by the client:

    • The store's PostgreSQL log at 2026-09-24 22:32:09.163 UTC:
      • ERROR: canceling statement due to user request;
      • STATEMENT: DELETE FROM query_plan_dim WHERE ctid IN (SELECT ctid FROM query_plan_dim WHERE last_seen < $1 ORDER BY last_seen LIMIT 50000).
    • The service log 4 ms later: Retention purge failed for query_plan_dim after removing 250000 row(s): Exception while reading from stream, then 85 table(s) purged, 293188 row(s) deleted, … 1 failed, 1116220ms.
    • "Exception while reading from stream" plus a server-side "user request" cancel is what an Npgsql CommandTimeout expiry looks like. That fits DarlingRetention.DeleteTimeoutSeconds = 300. I haven't seen the inner TimeoutException, because the service logs only the outer message.

    Why a batch can reach 300 s on this store (get_store_query_stats, owner role, since the restart):

    • The five batches that succeeded averaged 152.7 s (max 195.8 s), about 330 rows/s.
    • PlanDimDeleteRowCap's doc sizes 50 k at ~50 s (~1,000 rows/s measured on the other SQL store), for "about 5x margin" against the 300 s timeout. On this store the steady margin is ~2x, not 5x. The first-start burst (catch-up COPYs, the store-timeout window 22:14–22:37Z) was enough to push one batch past it.
    • For comparison, the other SQL store's batches since its restart: 11 calls, mean 30.7 s, max 43.2 s.
    • query_plan_dim here is 67 GB, 63 GB of it TOAST (pg_toast_17428). An autovacuum of that TOAST relation was cancelled at 23:13:50Z, at block 4,075,763. So the table is also carrying vacuum debt, which is consistent with the slower per-row delete cost. That's an inference, not measured.

    What this suggests for tonight's read and after:

    • Tonight's run is not a first start, so on this evidence it most likely finishes inside 300 s per batch. A success would mean "first-start only", as the bar says. But the margin is ~2x, not the 5x the cap was sized for.
    • A durable fix sizes the batch from measured throughput instead of a constant. For example, adapt the cap per statement toward a target of ~60 s (halve after a slow batch, grow after a fast one). Alternatively, catch the timeout and retry that slice once at half the cap, instead of failing the table for the day. Either keeps the purge progressing under load.
  6. added
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 25, 2026
  7. erikdarlingdata commented on Sep 25, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by #4210, merged to dev.

  8. removed
    in-progressActively being worked by a local session or its agents (PR open or in flight)
    on Sep 25, 2026
  9. erikdarlingdata commented on Sep 25, 2026

    @erikdarlingdata
    OwnerAuthor

    Field evidence, 2026-09-25. This replaces tonight's scheduled purge read, which the host resize cancelled.

    The largest field store moved to a host with twice the RAM (32 GiB to 63 GiB, the same 8 vCPU). It restarted at 03:20:35Z. The service ran its first-start purge during the catch-up burst, on a cold cache.

    • The purge succeeded. It took 701,498 ms across 86 tables, deleted 262,687 rows, and dropped 31 chunks. Last night's first-start purge failed at 1,116 s, after a 300 s cancel on batch 6.
    • The plan-dimension DELETE batches (LIMIT 50000) did not get faster. In pg_stat_statements the statement went from 5 calls to 9. The 4 new batches averaged about 163 s. One took 210 s, which is 70% of the 300 s command timeout.
    • The batches still run about 3 times longer than the ~50 s the cap was sized for. The worst batch left a margin of about 1.4x.

    So last night's failure and today's success differ in margin, not in mechanism. More RAM did not change the batch cost. The adaptive batch cap in #4210 is still needed.

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

    client-siteOwned by the client-site agents (other laptop). Local sessions never pick these up.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions