Skip to content

Commit 7a1ba85

Browse files
cboettigDefault User
andauthored
fix(logs): unwedge the OOM-failing consolidation job, and mirror tiers to rustfs (#116) (#117)
* feat(logs): mirror consolidated + sessions tiers to rustfs (#116) geo-agent-ops minted the scoped credential pair, but rustfs logs-open-llm-proxy is empty, so logs-open-llm-proxy-reader can't answer anything yet. This is the step that fills it. Both consolidation CronJobs now copy consolidated/** and sessions/** to rustfs after the tiers are written and verified. Credentials bind under RUSTFS_* rather than AWS_*, per the collision called out in #116: the jobs already bind AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY to the `aws` secret for the NRP source, and reusing those names would overwrite the source credential and break the job before it mirrored anything. Endpoint and bucket come from the Secret too, so moving rustfs doesn't mean editing this repo. All four bindings are optional:true — a cluster without the Secret still consolidates and prints the mirror as skipped. Copy-only, never delete: an accidental source deletion must not propagate. Re-copies when source LastModified or size differs, which is what catches the reflatten pass rewriting files in place — size alone is not a witness, since a re-flatten can land on an identical byte count. Ordered last so a mirror failure cannot cost the consolidation work, but it does fail the Job: a mirror that quietly stops is a stale mirror nobody notices, and the reader would keep serving stale answers. Verified: both manifests parse as YAML and their embedded Python passes ast.parse (heredoc indentation intact). Mirror logic exercised against stub clients over six cases — absent credential skips; empty dest copies; matching mtime+size skips; same-size-newer-mtime re-copies (the reflatten case); a 403 on HEAD raises instead of being swallowed as "missing"; and no path deletes at the destination. Not run against live rustfs — needs the Secret, which exists only in-cluster. First scheduled run is the real test. * fix(logs): raise CronJob memory + isolate backfill — daily job OOM-wedged 9 days Discovered while checking whether the rustfs mirror was live: it can't be, because the daily job never reaches the mirror step. It has failed every night since 2026-08-07. OutOfMemoryException: failed to allocate 256.0 KiB (819.0 MiB/819.1 MiB used) in build_session_view, backfilling 2026-08-07 Self-perpetuating: the day fails, keeps its slot on the backfill list, and re-breaks the job the next night. threads=2 and preserve_insertion_order =false were already set, so the cheap mitigations were spent. New days were still consolidating (that loop runs first), but session views for 2026-08-07/08/12 are missing and the reflatten pass has not run in 9 days. These are CronJobs, not persistent pods, so the 2Gi rule for long-lived workloads doesn't bind. Daily 1Gi -> 4Gi with DuckDB capped at 3GB; monthly 2Gi -> 6Gi capped at 4GB; both get temp_directory plus an ephemeral-storage request to back the spill. Headroom is the primary fix and the comment says so. Measured on a session-view-shaped build (window function + self-join over a wide payload): spill off failed at 500MB and 1GB, passed at 2GB; spill on passed at 500MB; at 200MB both failed. DuckDB does not spill every operator, so temp_directory is a margin, not a cure. An earlier draft of this change claimed spilling made the failure impossible -- the test above refuted that and the comment was corrected before commit. The monthly job is raised although it has not failed: same code over a whole month is strictly more exposed, and 2026-09-02 rolls up an August holding the benchmark sweeps. Backfill now isolates each day. One oversized day is recorded and skipped so reflatten and the rustfs mirror still run; the job then raises with the failed-day list, so it stays loud rather than quietly tolerating a gap. Both manifests parse as YAML and their embedded Python passes ast.parse. --------- Co-authored-by: Default User <email@example.com>
1 parent 69f8b0d commit 7a1ba85

4 files changed

Lines changed: 257 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,47 @@ See [Releases](README.md#releases) for how a release is cut.
9696
`deepseek/…` models) route instead of silently falling back to NRP. Enables the
9797
fleet-wide "DeepSeek V4 Flash (OpenRouter)" picker option.
9898

99+
### Fixed
100+
- **Daily consolidation had been failing for 9 days (OOM), wedged on one day.** Last
101+
success 2026-08-07; every run since died with
102+
`OutOfMemoryException: failed to allocate 256.0 KiB (819.0 MiB/819.1 MiB used)` inside
103+
`build_session_view`, backfilling `2026-08-07`. Self-perpetuating: the day failed, kept
104+
its place on the backfill list, and re-broke the job nightly. `threads=2` and
105+
`preserve_insertion_order=false` were already set, so the easy mitigations were spent.
106+
These are CronJobs, not persistent pods, so the 2Gi ceiling for long-lived workloads
107+
doesn't apply: daily 1Gi → 4Gi (DuckDB capped at 3GB), monthly 2Gi → 6Gi (capped at
108+
4GB), both with `temp_directory` set so DuckDB can spill and an `ephemeral-storage`
109+
request to back it. Headroom is the primary fix — on a session-view-shaped build,
110+
spilling turned failure into success at 500MB–1GB but not at 200MB, since DuckDB does
111+
not spill every operator. The monthly job is raised too although it has not failed yet:
112+
it runs the same code over a whole month, so it is strictly more exposed, and its next
113+
run (2026-09-02) rolls up an August containing the heavy benchmark sweeps.
114+
The backfill loop now isolates each day — one oversized day is recorded and skipped so
115+
the reflatten pass and the rustfs mirror still run — and the job raises at the end with
116+
the failed days, so it stays loud instead of silently tolerating the gap.
117+
Missing session views for `2026-08-07`, `08` and `12` should rebuild on the next run.
118+
119+
### Added
120+
- **Mirror `consolidated/**` and `sessions/**` to rustfs from the consolidation CronJobs
121+
(#116).** Log analysis has required the single NRP credential, which carries
122+
read/write/delete on *every* NRP bucket, for a read-only task against one bucket of a
123+
few MiB (#113). `geo-agent-ops` has minted a scoped pair — `logs-open-llm-proxy-reader`
124+
(Get/List only) and `…-writer` (plus object Put/Delete, no bucket create/delete) — but
125+
the rustfs bucket was empty, so the reader was useless. Both CronJobs now copy the
126+
query-ready tiers there after the tiers are written and verified. Credentials come from
127+
the `rustfs-logs-write` Secret under **`RUSTFS_*`** names, deliberately not `AWS_*`:
128+
those are already bound to the `aws` secret for the NRP source, and reusing them would
129+
clobber the source credential and break the job before it mirrored anything. All four
130+
bindings are `optional: true`, so a cluster without the Secret still consolidates and
131+
reports the mirror skipped. Copy-only, never delete — an accidental source deletion
132+
must not propagate; re-copies when the source `LastModified` or size changes, which is
133+
what catches the in-place rewrites the reflatten pass performs (size alone is not a
134+
witness). Runs last so a mirror failure cannot cost the consolidation work, but it does
135+
fail the Job, because a mirror that quietly stops is a stale mirror nobody notices.
136+
NRP Ceph stays the system of record: rustfs shares the same rook Ceph, so this is a
137+
convenience copy, not a second failure domain. Consumer-side retarget of `sync-logs.sh`
138+
stays in #113 and deliberately does **not** land until the mirror is confirmed non-empty.
139+
99140
### Fixed
100141
- **`geo-agent-training` skill: log collection was broken and over-privileged.** Its
101142
Step 1 selector was `app=llm-proxy`, which matches **no pods** — the label is

LOGGING.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ request/response interleaving:
117117
118118
## Access pattern
119119

120+
> 🔜 **A read-only rustfs mirror is being stood up (#116).** The consolidation CronJobs now
121+
> copy `consolidated/**` and `sessions/**` to `logs-open-llm-proxy` on rustfs after each run,
122+
> so those tiers can be read with a credential scoped to that one bucket — Get/List only —
123+
> instead of the single NRP key that carries read/write/delete on *every* NRP bucket (#113).
124+
> The credential lives in the `rustfs-logs-read` Secret in `biodiversity`; reference it by
125+
> **name**, never by value, so rotation touches nothing here.
126+
>
127+
> **Not yet the recommended path.** The sections below still describe the NRP-key workflow,
128+
> and stay that way until the mirror has run and been confirmed non-empty — retargeting
129+
> sooner would make the recommended path return *nothing* rather than too much. NRP Ceph
130+
> remains the system of record either way; rustfs shares the same rook Ceph, so the mirror
131+
> is a convenience copy, not a second failure domain.
132+
120133
### Local sync (recommended for interactive analysis)
121134

122135
The bucket is **private**, but `rclone` already has credentials configured under the `nrp` remote. Sync the bucket to a local scratch dir once per session, then query the local files — no S3 secret, no shell-expanded credentials, and orders of magnitude faster iteration:

consolidate-daily-cronjob.yaml

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,24 @@ spec:
3737
valueFrom: { secretKeyRef: { name: aws, key: AWS_ACCESS_KEY_ID } }
3838
- name: AWS_SECRET_ACCESS_KEY
3939
valueFrom: { secretKeyRef: { name: aws, key: AWS_SECRET_ACCESS_KEY } }
40+
# rustfs mirror target (#116). Deliberately NOT named AWS_* — the
41+
# consolidation work above binds those to the `aws` secret for the NRP
42+
# source, and reusing the names would overwrite the source credential
43+
# and break the job before it ever mirrored anything.
44+
# `optional: true` throughout: a cluster without the secret still
45+
# consolidates, and the mirror step reports itself skipped.
46+
- name: RUSTFS_KEY
47+
valueFrom:
48+
secretKeyRef: { name: rustfs-logs-write, key: AWS_ACCESS_KEY_ID, optional: true }
49+
- name: RUSTFS_SECRET
50+
valueFrom:
51+
secretKeyRef: { name: rustfs-logs-write, key: AWS_SECRET_ACCESS_KEY, optional: true }
52+
- name: RUSTFS_ENDPOINT
53+
valueFrom:
54+
secretKeyRef: { name: rustfs-logs-write, key: AWS_S3_ENDPOINT, optional: true }
55+
- name: RUSTFS_BUCKET
56+
valueFrom:
57+
secretKeyRef: { name: rustfs-logs-write, key: BUCKET, optional: true }
4058
command:
4159
- /bin/bash
4260
- -c
@@ -46,6 +64,7 @@ spec:
4664
python - <<'PY'
4765
import os, datetime, boto3, duckdb
4866
from botocore.client import Config
67+
from botocore.exceptions import ClientError
4968
5069
BUCKET = 'logs-open-llm-proxy'
5170
ENDPOINT = 'http://rook-ceph-rgw-nautiluss3.rook'
@@ -103,6 +122,18 @@ spec:
103122
# memory bounded (the data is re-sorted by ts on read anyway).
104123
con.execute("SET threads=2")
105124
con.execute("SET preserve_insertion_order=false")
125+
# Cap DuckDB below the pod limit, and give it somewhere to spill.
126+
#
127+
# The daily job wedged for 9 days OOMing at 819/819 MiB building one
128+
# session view, which blocked the reflatten pass and everything after
129+
# it. Headroom is the primary fix; `temp_directory` is a margin, not a
130+
# cure. Measured on a session-view-shaped build (window function +
131+
# self-join over a wide payload): with spill off it failed at 500MB
132+
# and 1GB and passed at 2GB; with spill on it passed at 500MB. At a
133+
# hard-tight budget (200MB) both still failed — DuckDB does not spill
134+
# every operator. So raise the ceiling *and* allow spilling.
135+
con.execute("SET memory_limit='3GB'")
136+
con.execute("SET temp_directory='/tmp/duckdb-spill'")
106137
con.execute(f"""
107138
CREATE SECRET s3_logs (
108139
TYPE S3,
@@ -266,10 +297,21 @@ spec:
266297
s3.delete_objects(Bucket=BUCKET, Delete={'Objects': to_delete[i:i+1000]})
267298
print(f" ✓ {len(to_delete)} JSONL chunks removed; session view written")
268299
300+
# Isolate per day. A single oversized day used to abort the whole
301+
# run before the reflatten pass and the rustfs mirror, and it stayed
302+
# on the backfill list, so it re-broke the job every night. Now one
303+
# bad day is recorded and skipped; the rest of the pipeline completes
304+
# and the job still exits non-zero at the end (see `failed` below),
305+
# so the failure is loud rather than silently tolerated.
306+
failed_days = []
269307
for day in backfill_sessions:
270308
print(f"↺ backfill session view: {day}")
271-
build_session_view(f's3://{BUCKET}/consolidated/daily/{day}.parquet',
272-
f's3://{BUCKET}/sessions/daily/{day}.parquet')
309+
try:
310+
build_session_view(f's3://{BUCKET}/consolidated/daily/{day}.parquet',
311+
f's3://{BUCKET}/sessions/daily/{day}.parquet')
312+
except Exception as e:
313+
print(f" ✗ session view failed for {day}: {type(e).__name__}: {e}")
314+
failed_days.append(day)
273315
274316
# Schema-upgrade pass: bring any legacy-schema consolidated daily file
275317
# (current month — older months live in monthly files, upgraded by the
@@ -280,12 +322,77 @@ spec:
280322
upgraded += 1
281323
print(f" ⬆ re-flattened {day} to wide schema")
282324
print(f"Legacy daily files upgraded to wide schema: {upgraded}")
325+
# -----------------------------------------------------------------
326+
# Mirror the query-ready tiers to rustfs (#116).
327+
#
328+
# Exists so log analysis can run on the read-only, single-bucket
329+
# `logs-open-llm-proxy-reader` credential instead of the one NRP key,
330+
# which carries read/write/delete on every NRP bucket (#113).
331+
#
332+
# NRP Ceph stays the system of record. rustfs sits on the *same* rook
333+
# Ceph, so this is a convenience mirror, not a second failure domain,
334+
# and must never become the only copy.
335+
#
336+
# Copy-only, never delete: an accidental source deletion must not
337+
# propagate. True mirror semantics would need a --max-delete-style
338+
# guard first (the writer identity can delete objects; this does not).
339+
#
340+
# Runs last, after every tier is written and verified, so a mirror
341+
# failure cannot cost the consolidation work. It does fail the Job
342+
# though — a mirror that quietly stops is a stale mirror nobody
343+
# notices, and the reader would keep serving yesterday's answers.
344+
# -----------------------------------------------------------------
345+
def mirror_to_rustfs(prefixes=('consolidated/', 'sessions/')):
346+
key = os.environ.get('RUSTFS_KEY')
347+
if not key:
348+
print("ℹ️ RUSTFS_KEY not set — skipping rustfs mirror (#116)")
349+
return
350+
dest_bucket = os.environ.get('RUSTFS_BUCKET') or BUCKET
351+
rustfs = boto3.client(
352+
's3',
353+
endpoint_url=os.environ['RUSTFS_ENDPOINT'],
354+
aws_access_key_id=key,
355+
aws_secret_access_key=os.environ['RUSTFS_SECRET'],
356+
config=Config(s3={'addressing_style': 'path'}),
357+
)
358+
copied = skipped = 0
359+
for prefix in prefixes:
360+
for page in paginator.paginate(Bucket=BUCKET, Prefix=prefix):
361+
for obj in page.get('Contents', []):
362+
k, stamp = obj['Key'], obj['LastModified'].isoformat()
363+
# Re-upload whenever the source changed. Size alone is
364+
# not a witness: the reflatten pass rewrites files in
365+
# place and can land on an identical byte count.
366+
try:
367+
head = rustfs.head_object(Bucket=dest_bucket, Key=k)
368+
if (head['Metadata'].get('src-mtime') == stamp
369+
and head['ContentLength'] == obj['Size']):
370+
skipped += 1
371+
continue
372+
except ClientError as e:
373+
if e.response['Error']['Code'] not in ('404', 'NoSuchKey', 'NotFound'):
374+
raise # 403 etc. is a real problem — be loud
375+
body = s3.get_object(Bucket=BUCKET, Key=k)['Body'].read()
376+
rustfs.put_object(Bucket=dest_bucket, Key=k, Body=body,
377+
Metadata={'src-mtime': stamp})
378+
copied += 1
379+
print(f"rustfs mirror → {dest_bucket}: {copied} copied, {skipped} already current")
380+
381+
mirror_to_rustfs()
382+
383+
if failed_days:
384+
raise SystemExit(
385+
f"session view build failed for {len(failed_days)} day(s): "
386+
f"{failed_days} — consolidation, reflatten and the rustfs "
387+
f"mirror completed for everything else")
283388
print("Done.")
284389
PY
285390
resources:
286391
requests:
287392
cpu: "500m"
288-
memory: "1Gi"
393+
memory: "4Gi"
394+
ephemeral-storage: "8Gi"
289395
limits:
290396
cpu: "500m"
291-
memory: "1Gi"
397+
memory: "4Gi"
398+
ephemeral-storage: "8Gi"

0 commit comments

Comments
 (0)