v1.3.41.1 -- CSV export by keyset, streamed, paced¶
argosVersion 1.3.41.0 -> 1.3.41.1. The v1.3.41.0 gate 3 (three back-to-back 24 h CSV exports with /api/hosts p99 <= 50 ms) failed on prod for a CPU reason: the export held the panel at 99-102 % of its 1-CPU cgroup and every request that landed in that window stalled. This release changes how the export walks and paces the match set; ARGOS_CPU_LIMIT stays at 1.
What ships¶
db.ListLogEntriesAfter: the export walks the match set by a(timestamp, id)keyset instead ofLIMIT/OFFSET. Each OFFSET page re-read every earlier row, so a 66k-row export visited about 1.1 M index entries; the keyset cursor uses both bounds as range terms (strike 14 rule) andTestListLogEntriesAfterPlanpins the plan.TestListLogEntriesAfterWalksEveryRowOncecovers rows that share a timestamp and the filter.ExportLogsCSVflushes the CSV writer and the HTTP response after every page (the client streams, the encoder holds one page) and pausesexportPagePause(20 ms) after each page ofexportPageRows(500).- The pause is the yield that works.
runtime.Gosched()was tried first: with the keyset alone the export dropped from 5.5 s to 1.5 s and the kernel throttled the container in 14 of 16 scheduler periods (155 ms) during that export; gate 3 still failed (p99 88 ms with the pool on, 240 ms off). A sleep between pages keeps the duty cycle low: 0 of 36 periods throttled during a 3.5 s export at 1,000 rows per page, and the 500-row page halves the burst a request can land in.
Measured on the dense demo (500k rows, 24 h export of 66k access rows, no IO pressure)¶
/api/hosts sampled every 100 ms across three exports; panel CPU from docker stats.
| build | pool | export wall time | p50 | p95 | p99 | max | > 50 ms |
|---|---|---|---|---|---|---|---|
| v1.3.41.0 (OFFSET, no pause) | on | 5.5 / 7.2 / 5.7 s | 2.0 ms | 7.2 ms | 506 ms | 825 ms | 5 / 186 |
| v1.3.41.0 | off | 5.9 / 5.5 / 5.4 s | 44.4 ms | 249 ms | 395 ms | 483 ms | 56 / 121 |
| keyset + Gosched (not shipped) | on | 2.0 / 1.6 / 1.5 s | 1.7 ms | 20.5 ms | 88 ms | 187 ms | 3 / 87 |
| keyset + Gosched (not shipped) | off | 1.8 / 1.5 / 1.5 s | 1.9 ms | 61 ms | 240 ms | 314 ms | 6 / 76 |
| keyset, 1,000 rows + 20 ms pause (not shipped) | on | 3.8 / 3.2 / 3.2 s | 1.5 ms | 17.0 ms | 63.7 ms | 70 ms | 2 / 134 |
| keyset, 1,000 rows + 20 ms pause (not shipped) | off | 3.3 / 3.2 / 3.3 s | 2.1 ms | 27.2 ms | 257 ms | 395 ms | 2 / 121 |
| v1.3.41.1 (keyset, 500 rows + 20 ms pause), run a | on | 5.3 / 5.4 / 5.2 s | 1.6 ms | 5.7 ms | 9.8 ms | 85 ms | 1 / 186 |
| v1.3.41.1, run b | on | 5.2 / 6.4 / 5.2 s | 1.5 ms | 16.0 ms | 67.7 ms | 88 ms | 3 / 192 |
| v1.3.41.1 | off | 5.6 / 5.5 / 5.4 s | 1.9 ms | 24.2 ms | 38.2 ms | 340 ms | 2 / 180 |
Reading the table:
- The export's own share is gone: with the pool off, where every read still queues on the writer, p95 fell from 249 ms to 24 ms and p99 from 395 ms to 38 ms. The two 300 ms samples left in the off column sit together at one instant and are the pinned dashboard refresh holding the writer, which is the pool's job, not the export's.
- With the pool on, run a passes with p99 9.8 ms. Run b fails on a cluster of three samples (68-88 ms) inside its slow export 2 (6.4 s) while
docker statsread 101 %: the pinned 24 h refresh (a 1-2 s query at full CPU on the dense demo, 0.8 s on prod) landed on top of the export and the sum crossed the 1-CPU quota. The export alone no longer throttles the container. - Wall time: 5.3 s, the same as v1.3.41.0's, because the pause gives back what the keyset saved; the difference is that the encoder now uses about a third of the quota instead of all of it.
Gate on prod¶
Deployed 2026-09-26 15:58 UTC (make deploy-prod 67 s, healthy in 10 s, read pool enabled). After-smokes with the threshold unchanged at 50 ms:
| smoke | result |
|---|---|
read-pool.sh gate 0 (mode) | PASS |
| gate 1, idle 120 s | PASS: n=1,099, p50 1.4 ms, p99 4.8 ms, max 14.5 ms |
| gate 2, strip (2,119 rows) | PASS: p99 3.1 ms |
| gate 3, export x9 aggregated (65,600 rows each, 5.0-5.9 s) | FAIL: n=457, p50 1.5 ms, p95 7.1 ms, p99 63.0 ms, max 75.4 ms, 8 samples over 50 ms |
dashboard-latency.sh strict | PASS: health 1.0 ms cache hit, overview 61 ms |
panel-boot.sh | PASS: boot-to-listen 114 ms, first purge 122 s after listen |
What is left in gate 3, with the numbers that separate it from the export: three more exports right after, with every sample timed, gave p99 7.4 ms, max 23.5 ms, 0 of 180 over 50 ms, and 8 throttled scheduler periods in 15 s (the export alone stays under the quota). The 8 slow samples of the 9-export run (63-75 ms, 1.75 % of the samples) are the moments when the pinned 24 h refresh (about 0.8 s at full CPU on prod, every 24 s: about two ticks in the 46 s the nine exports took) landed on top of the export's third of the quota and the container was throttled (nr_throttled +50 during the run, in steps that match the ticks). Known issue, cause identified, closed in v1.3.42.0 when the dashboard reads the hourly rollup and the refresh takes milliseconds. Compared with v1.3.41.0 on prod: gate 3 p99 884 ms -> 63 ms, p95 unchanged at about 7 ms.
Known issues carried¶
The pinned 24 h refresh is the remaining CPU burst that can coincide with an export on a 1-CPU cgroup; it goes away when the dashboard reads the hourly rollup (v1.3.42, migration 034). Carried from v1.3.41.0: Until shown as "expired" on imported decisions; blocked_requests_24h mixing; dashboard/cache_test timing flake; the 48 h DB-size reading.