Skip to content

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 of LIMIT/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) and TestListLogEntriesAfterPlan pins the plan. TestListLogEntriesAfterWalksEveryRowOnce covers rows that share a timestamp and the filter.
  • ExportLogsCSV flushes the CSV writer and the HTTP response after every page (the client streams, the encoder holds one page) and pauses exportPagePause (20 ms) after each page of exportPageRows (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 stats read 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.