Repository navigation
Expand file tree
/
Copy pathpublish.py
More file actions
2673 lines (2399 loc) · 121 KB
/
Copy pathpublish.py
File metadata and controls
2673 lines (2399 loc) · 121 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
publish.py — Daily publishing pipeline for Swiss Case Law
==========================================================
Orchestration script for VPS cron job. Runs the full pipeline:
1. Ingest new entscheidsuche.ch downloads (if entscheidsuche_ingest.py exists)
2. Build/update FTS5 database
2d. Quality enrichment (titles, regeste, dates, hashes, dedup)
2b. Quality report (optional)
2c. Build reference graph (citations + statutes, ~78 min)
3. Export database/JSONL → Parquet
4. Upload Parquet + dataset card to HuggingFace
5. Generate stats.json
6. Git commit + push docs/stats.json
Most steps are wrapped in try/except — failures are logged. Critical steps
(FTS5, Parquet) will skip subsequent guarded steps (HF upload, git push) to
avoid publishing an incomplete dataset.
Cron:
15 3 * * * cd /opt/caselaw/repo && python3 publish.py >> logs/publish.log 2>&1
Usage:
python3 publish.py # run full pipeline
python3 publish.py --step 3 # run only step 3 (export)
python3 publish.py --dry-run # log what would happen
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import shutil
import signal
import urllib.request
import logging
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone
from pathlib import Path
LOCK_FILE_PATH = "/tmp/opencaselaw-publish.lock"
CHECKPOINT_PATH = Path("/tmp/opencaselaw-publish-checkpoint.json")
# The operator topic is not in the code. An ntfy.sh topic is open to anyone who knows
# its name, and a name in this public repo reached every old checkout: test loops in
# pre-guard worktrees pushed hundreds of fake "Publish OK" on 2026-10-26..29. On the VPS the
# topic comes from /opt/caselaw/ops.env (systemd drop-in ntfy.conf); unset, no push.
NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "")
def _notify(title: str, message: str, *, priority: str = "default"):
"""Send push notification via ntfy.sh (best-effort, never fails the pipeline).
Never posts under pytest. A per-file fixture (tests/test_publish_dispatch.py,
2026-09-16) only protects branches that carry it; on 2026-10-26/27 a loop
over a pre-fix checkout pushed ~230 fake "Publish OK / ? decisions, 0 min"
to the operator topic. PYTEST_CURRENT_TEST is inherited by subprocesses,
so a test that shells out to publish.py is covered too. Without NTFY_TOPIC
(any run outside the systemd units) nothing is sent either.
"""
if not NTFY_TOPIC or os.environ.get("PYTEST_CURRENT_TEST"):
return
try:
req = urllib.request.Request(
f"https://ntfy.sh/{NTFY_TOPIC}",
data=message.encode(),
headers={"Title": title, "Priority": priority},
)
urllib.request.urlopen(req, timeout=5)
except Exception:
pass # notification failure must never break the pipeline
_STEP2_REASON_MARKERS = ("refusing to swap", "PRE-FLIGHT FAILED", "RuntimeError", "timed out",
"Traceback", "disk is full", "database disk image")
def _step2_failure_message(log_path: Path | None = None, data_volume: str | None = None) -> str:
"""What an operator needs the moment step 2 fails: why, how old the data
users are getting is, and whether a complete new build is waiting.
On 2026-10-09 the per-court gate refused the swap at 12:21; the only page
came at 20:58, when the whole run ended, too late to swap the kept build in
by hand that day, and the 03:30 run then deleted it. Best-effort: any part
it cannot read is left out."""
parts = []
log_path = log_path or (REPO_DIR / "logs" / "publish.log")
try:
with open(log_path, "rb") as fh:
fh.seek(0, 2)
fh.seek(max(0, fh.tell() - 200_000))
tail = fh.read().decode("utf-8", "replace").splitlines()
reason = next((ln for ln in reversed(tail)
if any(m in ln for m in _STEP2_REASON_MARKERS)), "")
if reason:
parts.append("Reason: " + reason.split("| ", 1)[-1].strip()[:400])
except OSError:
pass
out = Path(data_volume or DATA_VOLUME) / "output"
try:
import sqlite3
conn = sqlite3.connect(f"file:{out / 'decisions.db'}?mode=ro&immutable=1", uri=True)
try:
gen = conn.execute("PRAGMA user_version").fetchone()[0]
finally:
conn.close()
if gen:
parts.append(f"Users keep the previous database, {(time.time() - gen) / 3600:.0f} h old.")
except Exception: # noqa: BLE001 — a message part, never a failure
pass
kept = out / "decisions.db.tmp"
try:
st = kept.stat()
parts.append(
f"A new build is kept at {kept} ({st.st_size / 1e9:.1f} GB, "
f"{(time.time() - st.st_mtime) / 3600:.1f} h old). If the refusal is an intended "
"fold or re-key, swap it in by hand before the next 03:30 run, which deletes it "
"(memory: swap-gate-rekey-trap).")
except OSError:
parts.append("No new build was kept: the next run starts from scratch.")
return " ".join(parts) or "Step 2 failed; see logs/publish.log."
def _append_run_record(record: dict) -> None:
"""Append one JSON line to state/publish_runs.jsonl.
The pipeline's only durable structured record. Until 2026-08-19 a run
left behind 109 overwritten bytes on success and NOTHING on failure
(the failure branch exited before any marker) — per-step timings were
computed, logged as text and lost, so a 13h41m → 17h07m build creep
and a gate timeout were invisible until they hurt. Append-only, one
line per step and one summary per run, written on success AND failure.
Telemetry must never break the pipeline: any error here is swallowed.
"""
try:
state_dir = REPO_DIR / "state"
state_dir.mkdir(exist_ok=True)
with open(state_dir / "publish_runs.jsonl", "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception:
pass
def _save_checkpoint(step_num, results: dict):
"""Save completed step so pipeline can resume after crash."""
CHECKPOINT_PATH.write_text(json.dumps({
"last_completed_step": str(step_num),
"results": {str(k): v for k, v in results.items()},
"timestamp": datetime.now(timezone.utc).isoformat(),
}))
def _load_checkpoint(resume_requested: bool = False) -> dict | None:
"""Load checkpoint from prior crashed run (if any).
A checkpoint whose database rebuild (step 2) completed is honoured only
when the run asks for it (``--resume``). 2026-10-07: Tuesday's build ended
at 23:41 UTC with only the early stats step red; the 03:30 timer run found
the checkpoint ten minutes before it expired, "resumed", reran that one
step and skipped the rebuild, so a day of scraped decisions, a
migration and two de-listings did not reach the site. A scheduled run
starts fresh unless the rebuild itself is what failed.
TTL is 4h (was 12h). The daily timer fires every ~24h, so a 12h TTL
leaves a wide window where yesterday's checkpoint can be re-used by
today's timer-triggered run if even one step failed (defeating the
refresh purpose of the daily publish). 4h is short enough to never
bleed into the next daily run, yet long enough for a manual resume
after a crash within the same publish window.
"""
if CHECKPOINT_PATH.exists():
try:
data = json.loads(CHECKPOINT_PATH.read_text())
age_hours = (datetime.now(timezone.utc) - datetime.fromisoformat(data["timestamp"])).total_seconds() / 3600
if age_hours < 4:
if not resume_requested and data.get("results", {}).get("2") is True:
logging.getLogger("publish").info(
" Ignoring checkpoint from %s: its database rebuild (step 2) "
"completed, so this run starts fresh (--resume continues it)",
data.get("timestamp"))
return None
return data
except Exception:
pass
return None
def _clear_checkpoint():
"""Remove checkpoint after successful completion."""
CHECKPOINT_PATH.unlink(missing_ok=True)
logger = logging.getLogger("publish")
REPO_DIR = Path(__file__).parent.resolve()
OUTPUT_DIR = REPO_DIR / "output"
DATASET_DIR = OUTPUT_DIR / "dataset"
DOCS_DIR = REPO_DIR / "docs"
DB_PATH = OUTPUT_DIR / "decisions.db"
HF_REPO_ID = "voilaj/swiss-caselaw"
# Build_fts5 writes decisions.db.tmp (~63 GB) plus a .tmp-wal that peaks
# around 10 GB, then atomically swaps. We keep a 7 GB safety margin so the
# concurrent decision_structure sidecar build (step 2g) doesn't squeeze the
# volume during the brief window before the .tmp is replaced.
DATA_VOLUME = "/mnt/HC_Volume_104655575"
BUILD_DISK_REQUIRED_GB = 80
# How long a free-space check waits for space a just-deleted file still holds.
DISK_FREE_SETTLE_S = 120
# Step 2g wall clock (see step_2g_build_decision_structure).
STRUCTURE_TIMEOUT_S = 18000
def _free_bytes_settled(path, need: int, wait_s: float | None = None, poll_s: float = 5.0) -> int:
"""Free bytes on ``path``'s volume, re-read for up to ``wait_s`` while short of ``need``.
Deleting a file does not free its blocks at once on the data volume. On
2026-10-10 the step-2 cleanup removed the kept 73 GB decisions.db.tmp and
the pre-flight in the same second saw 50.5 GB free (about 124 GB moments
later), so the night's build failed at once. Only the path that would fail
waits; a volume that is really full fails ``wait_s`` later than before."""
wait_s = DISK_FREE_SETTLE_S if wait_s is None else wait_s
free = shutil.disk_usage(path).free
deadline = time.monotonic() + wait_s
while free < need and time.monotonic() < deadline:
time.sleep(poll_s)
free = shutil.disk_usage(path).free
return free
def run_cmd(
cmd: list[str],
description: str,
dry_run: bool = False,
timeout: int = 3600,
stall_timeout: int | None = 5400,
on_line=None,
outcome_sink: dict | None = None,
) -> bool:
"""Run a command, return True on success.
outcome_sink, when given, receives {"timed_out": bool, "stalled": bool,
"returncode": int|None} so a caller can distinguish "the command was
killed at its wall-clock cap" from "the command ran and said no". The
QC gate needs that distinction: a timeout says nothing about corpus
quality, but until 2026-08-22 it was indistinguishable from a CRITICAL
verdict and cascade-skipped the HF upload and both git pushes (08-18,
08-21 — both timeouts, zero regressions).
Streams stdout/stderr line-by-line to the logger instead of buffering
the full output in memory (avoids OOM on long-running steps like
build_fts5 or graph build that can produce hundreds of MB of output).
on_line: optional callback invoked once per stdout line BEFORE it
reaches the logger. Used by Step 2 to release the publish lock the
instant build_fts5 prints its OCL_SWAP_DONE sentinel, without
waiting for the rest of the integrity-check tail. Callback
exceptions are caught and logged so a faulty hook can't crash the
publish.
Two independent kill-switches:
- ``timeout``: hard wall-clock cap (default 3600 s). The wall-clock
bound has to accommodate the longest legitimate step (Step 2c
reference graph at 10800 s).
- ``stall_timeout``: kill the process if no output line is received
for this many seconds (default 5400 s = 90 min). Catches the
"process is alive but wedged" class — silent OOM, deadlocked DB,
infinite loop. Bumped from the 30 min initial value on
2026-05-02 after that watchdog killed a healthy build mid-dedup
(build_fts5 dedup is silent for ~45 min by design; optimize is
silent for ~45 min; both are legitimate). 90 min covers the
longest legitimate silent phase with 2× margin. Set to None to
disable.
"""
logger.info(f" $ {' '.join(cmd)}")
if dry_run:
logger.info(" [dry-run] skipped")
return True
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, # merge stderr into stdout to avoid pipe deadlock
text=True,
cwd=str(REPO_DIR),
# start_new_session=True makes the child a process-group leader
# (its PID == its PGID) so we can kill the WHOLE group via
# os.killpg, which catches grandchildren build_fts5 may spawn.
# 2026-05-07 incident: Step 2 wall-clock fired at 03:20:05 but
# build_fts5 kept running, swap happened 3h 20m later — the old
# proc.kill() only signalled the immediate child, missed the
# ionice→nice→python chain or a child SQLite worker still in D.
start_new_session=True,
)
# Watchdog timers: kill the process either on wall-clock timeout
# OR on output-stall timeout. We can't rely on proc.wait(timeout=)
# because the for-loop over proc.stdout blocks until EOF
# (i.e. process exit).
timed_out = threading.Event()
stalled = threading.Event()
last_output_at = [time.time()]
def _kill_pg(reason: str) -> None:
"""Kill the entire process group: SIGTERM, 5s grace, then SIGKILL.
Belt-and-braces against children that survive a single SIGKILL
to the leader (the 2026-05-07 incident).
"""
try:
pgid = os.getpgid(proc.pid)
except (ProcessLookupError, OSError):
# Process already dead.
return
try:
os.killpg(pgid, signal.SIGTERM)
except (ProcessLookupError, PermissionError, OSError):
pass
# Give it 5 seconds for atomic-swap / file-handle close to finish.
for _ in range(50):
if proc.poll() is not None:
return
time.sleep(0.1)
try:
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
pass
# SIGKILL queued — give kernel up to 30s to deliver
# (D-state syscalls may delay kill arrival).
for _ in range(300):
if proc.poll() is not None:
return
time.sleep(0.1)
logger.warning(
f" process group {pgid} still alive 30s after SIGKILL "
f"({reason}); subprocess may be wedged in D state"
)
def _kill_on_timeout():
timed_out.set()
_kill_pg("wall-clock timeout")
def _kill_on_stall():
while proc.poll() is None and not timed_out.is_set():
idle = time.time() - last_output_at[0]
if stall_timeout is not None and idle > stall_timeout:
stalled.set()
_kill_pg("stall watchdog")
return
time.sleep(min(60, max(5, (stall_timeout or 60) // 4)))
wall_timer = threading.Timer(timeout, _kill_on_timeout)
wall_timer.start()
stall_thread = None
if stall_timeout is not None:
stall_thread = threading.Thread(target=_kill_on_stall, daemon=True)
stall_thread.start()
try:
assert proc.stdout is not None
for line in proc.stdout:
last_output_at[0] = time.time()
line = line.rstrip("\n")
if line:
if on_line is not None:
try:
on_line(line)
except Exception as _hook_err:
logger.warning(
f" on_line callback raised "
f"{type(_hook_err).__name__}: {_hook_err}"
)
logger.info(f" | {line}")
proc.wait()
finally:
wall_timer.cancel()
if outcome_sink is not None:
outcome_sink["timed_out"] = timed_out.is_set()
outcome_sink["stalled"] = stalled.is_set()
outcome_sink["returncode"] = proc.returncode
if timed_out.is_set():
logger.error(f" timed out after {timeout}s (wall-clock)")
return False
if stalled.is_set():
logger.error(
f" stalled — no output for {stall_timeout}s; killed by watchdog"
)
return False
if proc.returncode != 0:
logger.error(f" exit code {proc.returncode}")
return False
return True
except Exception as e:
logger.error(f" failed: {e}")
if outcome_sink is not None:
outcome_sink.setdefault("timed_out", False)
outcome_sink.setdefault("stalled", False)
outcome_sink.setdefault("returncode", None)
return False
def step_1_ingest(dry_run: bool = False) -> bool:
"""Step 1: Ingest new entscheidsuche.ch downloads."""
logger.info("Step 1: Ingest entscheidsuche downloads")
ingest_script = REPO_DIR / "entscheidsuche_ingest.py"
if not ingest_script.exists():
# Try scrapers directory
ingest_script = REPO_DIR / "scrapers" / "entscheidsuche_ingest.py"
if not ingest_script.exists():
logger.info(" No ingest script found, skipping")
return True
return run_cmd(
[sys.executable, str(ingest_script)],
"Ingest entscheidsuche downloads",
dry_run,
)
def _cleanup_stale_build_artifacts() -> None:
"""Remove .tmp/.tmp-wal/.tmp-shm leftover from a crashed prior build.
Only removes files older than 6 h to avoid clobbering an in-flight build
invoked outside publish.py (the lockfile already prevents concurrent
publish runs, so this is defence-in-depth for manual build_fts5 invocations).
"""
candidates = [
f"{DATA_VOLUME}/output/decisions.db.tmp",
f"{DATA_VOLUME}/output/decisions.db.tmp-wal",
f"{DATA_VOLUME}/output/decisions.db.tmp-shm",
# `.quick` is a SQLite quickcheck snapshot left behind by a
# crashed build_fts5 PRAGMA quick_check pass. It can reach 60 GB
# and burned the 2026-05-02 nightly when the disk filled up.
f"{DATA_VOLUME}/output/decisions.db.quick",
f"{DATA_VOLUME}/output/.reference_graph.db.tmp-journal",
f"{DATA_VOLUME}/output/.decisions.db.tmp-journal",
# decision_structure rebuild artefacts. The full rebuild writes
# to a sibling .tmp file that can grow to ~45 GB. A crash mid-
# build leaves the .tmp orphaned and burns the next nightly
# at pre-flight. Once decision_structure.db lives on /mnt
# (post-2026-05-02 symlink) the .tmp lands there too; covering
# both legacy /opt and current /mnt paths is defence-in-depth.
f"{DATA_VOLUME}/output/decision_structure.db.tmp",
f"{DATA_VOLUME}/output/decision_structure.db.tmp-journal",
"/opt/caselaw/repo/output/decision_structure.db.tmp",
"/opt/caselaw/repo/output/decision_structure.db.tmp-journal",
"/opt/caselaw/repo/output/decision_structure.db.partial-2026-04-29",
]
now = time.time()
for path in candidates:
try:
st = os.stat(path)
except FileNotFoundError:
continue
age_h = (now - st.st_mtime) / 3600
if age_h < 6:
logger.warning(
f" Stale-build cleanup: skipping {path} "
f"(age {age_h:.1f}h < 6h, possibly active)"
)
continue
try:
os.unlink(path)
logger.warning(
f" Stale-build cleanup: removed {path} "
f"({st.st_size / 1e9:.1f} GB freed, age {age_h:.1f}h)"
)
except OSError as e:
logger.error(f" Stale-build cleanup failed for {path}: {e}")
def _preflight_disk_check() -> bool:
"""Verify /mnt has enough room for build_fts5's transient .tmp + .tmp-wal."""
import shutil
if not Path(DATA_VOLUME).exists():
logger.warning(f" Pre-flight: {DATA_VOLUME} not present, skipping check")
return True
free_gb = _free_bytes_settled(DATA_VOLUME, int(BUILD_DISK_REQUIRED_GB * 1e9)) / 1e9
if free_gb < BUILD_DISK_REQUIRED_GB:
logger.error(
f"PRE-FLIGHT FAILED: {DATA_VOLUME} has {free_gb:.1f} GB free, "
f"build needs ~{BUILD_DISK_REQUIRED_GB} GB transient. "
f"Top consumers in {DATA_VOLUME}/output:"
)
out = Path(f"{DATA_VOLUME}/output")
if out.exists():
top = sorted(
((p, p.stat().st_size) for p in out.iterdir() if p.is_file()),
key=lambda x: -x[1],
)[:10]
for p, sz in top:
logger.error(f" {sz / 1e9:>6.1f} GB {p.name}")
return False
logger.info(
f" Pre-flight: {DATA_VOLUME} has {free_gb:.1f} GB free "
f"(need >= {BUILD_DISK_REQUIRED_GB})"
)
return True
def _parse_worker_ports(systemctl_list_units_output: str) -> list:
"""Extract worker ports from `systemctl list-units mcp-server@*.service`
(--plain --no-legend) output. Pure + unit-tested."""
ports = []
for line in systemctl_list_units_output.splitlines():
parts = line.split()
if parts and parts[0].startswith("mcp-server@") and parts[0].endswith(".service"):
ports.append(parts[0][len("mcp-server@"):-len(".service")])
return sorted(set(ports))
def _recycle_mcp_workers(dry_run: bool = False) -> None:
"""Roll-restart the mcp-server@ SSE workers to release handles to the
just-swapped (now-deleted) decisions.db inode.
After the Step 2 atomic swap, each serving worker keeps pooled SQLite
connections open on the OLD decisions.db inode, pinning that ~70 GB file
(unlinked, not yet freed) until the process recycles. With the data volume
near capacity this starved the post-build aux tier: on 2026-07-08,
reference_graph + decision_structure both hit 'database or disk is full'
because ~130 GB of orphaned inodes were pinned by 106 worker handles. A
rolling restart (one worker at a time, gated on /health) releases them with
zero serving downtime.
Non-fatal: logs and continues on any error so a recycle hiccup never fails
the build. No-op under dry-run or when the units are absent (dev box).
"""
if dry_run:
logger.info(" [dry-run] would roll-restart mcp-server@ workers post-swap")
return
try:
out = subprocess.run(
["systemctl", "list-units", "mcp-server@*.service",
"--state=active", "--no-legend", "--plain", "--no-pager"],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.SubprocessError):
return # no systemctl (dev box) — nothing to recycle
ports = _parse_worker_ports(out.stdout)
if not ports:
logger.info(" post-swap recycle: no active mcp-server@ workers; skipping")
return
logger.info(
f" post-swap recycle: rolling restart of {len(ports)} workers "
f"({', '.join(ports)}) to free the old decisions.db inode"
)
for port in ports:
try:
subprocess.run(
["systemctl", "restart", f"mcp-server@{port}.service"],
check=False, timeout=60,
)
except subprocess.SubprocessError as e:
logger.warning(f" worker {port}: restart error {e}; continuing")
continue
healthy = False
for _ in range(15):
try:
with urllib.request.urlopen(
f"http://127.0.0.1:{port}/health", timeout=3,
) as r:
if r.status == 200:
healthy = True
break
except Exception: # noqa: BLE001 - keep polling until the deadline
pass
time.sleep(1)
logger.info(f" worker {port}: {'ok' if healthy else 'TIMEOUT (continuing)'}")
time.sleep(2)
def step_2_build_fts5(
dry_run: bool = False,
full_rebuild: bool = False,
on_line=None,
) -> bool:
"""Step 2: Build/update FTS5 search database.
Always uses full rebuild: builds to .db.tmp then atomic os.replace().
This avoids DB locks with live MCP workers (immutable=1 connections).
on_line: optional per-stdout-line callback. The publish driver
passes a hook that releases the publish lock when build_fts5
prints its ``OCL_SWAP_DONE`` sentinel — so the lock isn't held
through the post-swap integrity_check tail (1–3 h on the 60 GB
DB) and quick_publish can fold fresh BGer poller scrapes into
the live DB during that window.
"""
script = REPO_DIR / "build_fts5.py"
if not script.exists():
logger.error(" build_fts5.py not found")
return False
logger.info("Step 2: Full FTS5 rebuild (low I/O priority, zero-downtime swap)")
# Pre-flight: clean stale .tmp from a prior crashed build, then check we
# have room for the new build. Fail fast (skip the 90-min crash cycle).
if not dry_run:
_cleanup_stale_build_artifacts()
if not _preflight_disk_check():
return False
# Use ionice/nice to prevent I/O starvation of live MCP workers.
# Best-effort class (-c2), not idle (-c3): under serving load the idle class
# got fully starved, stalling build_fts5 optimize past the nightly cap.
# Proven 2026-06-23: optimize 35min (best-effort) vs >4h (idle). See memory
# incident_2026_06_23_build_starvation_salvage.
cmd = ["ionice", "-c2", "nice", "-n", "10",
sys.executable, str(script), "--output", str(OUTPUT_DIR),
"--full-rebuild"]
# Wall-clock cap. History:
# 18000s (5h) — too tight; hit 2026-05-04 03:30 mid-optimize.
# 25200s (7h) — too tight; hit 2026-05-07 03:20 after 1.46M
# wayback_queue + heavy König cleanup pushed total to 10h 20m.
# 43200s (12h) — current. Today's worst case (10h 20m) + 1h 40m
# cushion. The 16h unit-level TimeoutStartSec is the outer cap.
# Note: with the 2026-05-07 process-group kill fix, even if this
# cap fires, the entire build_fts5 tree dies within ~6s — no more
# silent overrun + cascade-skip + post-mortem-class incidents.
timeout = 43200 # 12h hard cap; legitimate completion ranges 4–10h
# Stall watchdog: the FTS5 'optimize' phase + post-swap
# PRAGMA integrity_check both emit NO stdout for ~1–3 h each.
# History:
# 5400s (1.5h) — too tight; killed mid-optimize 2026-05-04.
# 10800s (3h) — bumped 2026-05-04; tripped today (2026-05-11)
# because integrity_check on the post-swap 60 GB DB
# ran exactly 3h under disk contention. The 13:41
# watchdog kill cascade-skipped Steps 4/6 even
# though the swap had succeeded at 10:40.
# 14400s (4h) — current. Empirical post-swap integrity_check
# ranges 2h 55m – 3h 20m on this hardware. 4h
# gives a ~40-min cushion. The new
# OCL_SWAP_DONE handshake (commit b4ba734) means
# quick_publish can run DURING this window, which
# adds disk contention and is the reason the
# previous cap got hit.
# build_fts5 refreshes the public stats.json right after the swap only
# when asked to; this is the production build, so ask. setdefault keeps
# an operator's explicit OCL_EARLY_STATS_PUSH=0 in force.
os.environ.setdefault("OCL_EARLY_STATS_PUSH", "1")
return run_cmd(cmd, "Build FTS5 database", dry_run,
timeout=timeout, stall_timeout=14400,
on_line=on_line)
def step_2b_quality_report(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2b: Generate quality report and check gates."""
logger.info("Step 2b: Quality report")
script = REPO_DIR / "quality_report.py"
if not script.exists():
logger.info(" quality_report.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" Database not found, skipping quality report")
return True
return run_cmd(
[sys.executable, str(script),
"--db", str(DB_PATH),
"--output", str(OUTPUT_DIR / "quality_report.json"),
"--gate"],
"Quality report",
dry_run,
timeout=7200,
)
def step_2c_build_reference_graph(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2c: Build reference graph (citations + statutes)."""
logger.info("Step 2c: Build reference graph")
# Use the INCREMENTAL builder in forced-full mode rather than
# build_reference_graph.py directly. Same inputs (both read
# decisions.db), same full rebuild — but this one also writes the
# `meta` and `processed_decisions` state tables.
#
# Without that state a subsequent --in-place incremental run finds no
# diff base (_select_diff_base -> "no_state") and bootstraps the whole
# graph from scratch, ~3h22m measured on production, instead of
# applying a delta in ~50min. That is what blocks the weekday-
# incremental cutover. The step comment below has said "Real fix:
# build_reference_graph_incremental.py" since 2026-06-03; this is it.
script = REPO_DIR / "search_stack" / "build_reference_graph_incremental.py"
if not script.exists():
logger.info(" build_reference_graph_incremental.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" FTS5 database not found, skipping reference graph")
return True
graph_db = OUTPUT_DIR / "reference_graph.db"
return run_cmd(
[sys.executable, str(script),
"--decisions-db", str(DB_PATH),
"--graph-db", str(graph_db),
"--force-full",
"--in-place"],
"Build reference graph",
dry_run,
# Bumped 7200→10800 (2026-05-01), then 10800→18000 (2026-06-03 STOPGAP):
# the full builder is ~78min solo but ran >3h and hit the 10800s cap on
# the 06-02 nightly under 4-way post-build I/O contention (see
# PARALLEL_MAX_WORKERS, now 2). 5h leaves margin until 2c moves to the
# incremental builder. Real fix: build_reference_graph_incremental.py.
timeout=18000,
)
def step_2d_enrich_quality(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2d: Enrich data quality (titles, regeste, dates, hashes, dedup)."""
logger.info("Step 2d: Quality enrichment")
script = REPO_DIR / "scripts" / "enrich_quality.py"
if not script.exists():
logger.info(" enrich_quality.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" FTS5 database not found, skipping enrichment")
return True
cmd = [
sys.executable, str(script),
"--db", str(DB_PATH),
"--output", str(OUTPUT_DIR),
]
if dry_run:
cmd.append("--dry-run")
return run_cmd(cmd, "Quality enrichment", dry_run, timeout=7200)
def step_2e_build_anwaltsrecht_tags(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2e: Build Anwaltsrecht tags DB from SAV PDFs."""
logger.info("Step 2e: Build Anwaltsrecht tags")
script = REPO_DIR / "search_stack" / "build_anwaltsrecht_tags.py"
if not script.exists():
logger.info(" build_anwaltsrecht_tags.py not found, skipping")
return True
if not DB_PATH.exists():
logger.info(" FTS5 database not found, skipping Anwaltsrecht tags")
return True
tags_db = OUTPUT_DIR / "anwaltsrecht_tags.db"
return run_cmd(
[sys.executable, str(script),
"--fts5-db", str(DB_PATH),
"--output", str(tags_db)],
"Build Anwaltsrecht tags",
dry_run,
timeout=600,
)
# Coverage gate for the structure sidecar swap: the new sidecar must index at
# least this share of the current decisions the old one indexed. A broken
# extractor then keeps yesterday's sidecar instead of shipping an empty index.
STRUCTURE_COVERAGE_FLOOR = 0.98
def _structure_coverage(sidecar: Path, decisions_db: Path) -> int | None:
"""Current decisions with at least one indexed Erwägung. Reads only the two
covering indexes (decision ids), never the 50 GB of paragraph text."""
import sqlite3
try:
dec = sqlite3.connect(f"file:{decisions_db}?mode=ro&immutable=1", uri=True)
try:
ids = {row[0] for row in dec.execute("SELECT decision_id FROM decisions")}
finally:
dec.close()
side = sqlite3.connect(f"file:{sidecar}?mode=ro&immutable=1", uri=True)
try:
structured = {row[0] for row in side.execute("SELECT DISTINCT decision_id FROM erwaegungen_paragraph")}
finally:
side.close()
except Exception as exc: # noqa: BLE001 — the gate must never crash the pipeline
logger.warning(f" structure coverage of {sidecar.name} could not be measured: {exc}")
return None
return len(ids & structured)
def step_2g_build_decision_structure(dry_run: bool = False, full_rebuild: bool = False) -> bool:
"""Step 2g: Rebuild decision_structure.db sidecar from the SERVED text.
Since 2026-09-06 the sidecar is extracted from decisions.db full_text — the
text get_decision serves and a reader can check a pinpoint against — by
search_stack/extract_decision_structure_incremental.py (state-diffed, so a
normal night re-extracts only new or changed decisions; a bumped
EXTRACTOR_VERSION re-extracts everything once). The old shard-based build
read raw JSONL text and, for the historical BGE volumes, indexed nothing;
the shadow runs of the incremental extractor showed 89% of BGE and 76% of
the corpus with an indexed Erwägung against 66% / 72.5%.
The new sidecar is written to decision_structure.db.tmp, passes a coverage
gate (STRUCTURE_COVERAGE_FLOOR of the old sidecar's indexed current
decisions) and is then swapped in atomically. If the extractor fails, the
shard-based build runs as before, so a bad night never leaves the sidecar
missing.
"""
logger.info("Step 2g: Build decision_structure sidecar (from served text)")
# Kill switch (2026-09-08): the served-text extractor loaded every
# decision's full_text into RAM (35 GB), pinned the publish cgroup at
# MemoryHigh and starved serving for hours, twice. OCL_SERVED_TEXT_STRUCTURE=0
# in .env.publish keeps the shard-built sidecar until the extractor streams.
if os.environ.get("OCL_SERVED_TEXT_STRUCTURE", "1").strip().lower() in {
"0", "false", "off", "no"}:
logger.warning(" OCL_SERVED_TEXT_STRUCTURE=0: served-text extractor disabled; "
"building the sidecar from shards")
return _step_2g_from_shards(dry_run)
script = REPO_DIR / "search_stack" / "extract_decision_structure_incremental.py"
decisions_db = OUTPUT_DIR / "decisions.db"
live = OUTPUT_DIR / "decision_structure.db"
if not script.exists() or not decisions_db.exists():
logger.warning(" incremental extractor or decisions.db not found; building from shards")
return _step_2g_from_shards(dry_run)
# The live sidecar is a symlink to the data volume in production, while
# OUTPUT_DIR itself is on the 150 GB root disk. The extractor writes its
# working copy next to --output and this step swaps onto it, so both must
# be derived from the RESOLVED path: a tmp beside the symlink would fill
# the root disk (~55 GB sidecar vs ~32 GB free) and the swap would replace
# the symlink with a file on /. Review finding 2026-09-08.
live_real = live.resolve() if live.exists() else live
tmp = live_real.with_name(live_real.name + ".tmp")
for stale in (tmp, Path(str(tmp) + "-journal")):
if stale.exists():
stale.unlink()
if live_real.exists():
need = int(live_real.stat().st_size * 1.2)
# A bootstrap killed by the step timeout leaves its working copy beside
# `tmp` for the next run to resume; the extractor reuses those bytes
# (or deletes them first), so they count as available. Without this
# credit a resumable bootstrap never resumes on a volume with less
# than 1.2 x the sidecar to spare (review finding 2026-09-08).
try:
from search_stack.extract_decision_structure_incremental import reclaimable_bytes
reusable = reclaimable_bytes(tmp)
except Exception as e: # noqa: BLE001 — the credit is an optimisation, never a failure
logger.warning(f" could not measure the extractor's working copy ({e}); not crediting it")
reusable = 0
# The stale tmp and journal were deleted just above: let their space settle.
free = _free_bytes_settled(live_real.parent, need - reusable)
if free + reusable < need:
logger.error(f" {live_real.parent} has {free / 1e9:.1f} GB free"
+ (f" (+{reusable / 1e9:.1f} GB in the extractor's working copy)" if reusable else "")
+ f", the sidecar rebuild needs ~{need / 1e9:.1f} GB; keeping the current sidecar")
return False
if os.environ.get("OCL_STRUCTURE_FORCE_FULL") == "1":
logger.warning(" OCL_STRUCTURE_FORCE_FULL=1 is set: full re-extraction of every decision "
"(~3 h). Unset it after the one-off, or every night pays this.")
cmd = [sys.executable, str(script), "--decisions-db", str(decisions_db),
"--structure-db", str(live_real), "--output", str(tmp)]
# A full re-extraction is the extractor's own decision: it bootstraps
# when the sidecar carries no state or a different extractor version.
# Forcing it on every full build (and on every `--step 2g`, which counts
# as one) re-extracted all ~1.07M decisions daily; the 2026-09-07 run
# did that into memory and was killed by the stall watchdog. A normal
# night now diffs and writes only new or changed decisions (minutes).
# OCL_STRUCTURE_FORCE_FULL=1 keeps a manual override for a one-off.
if os.environ.get("OCL_STRUCTURE_FORCE_FULL") == "1":
cmd.append("--force-full")
# 5 h (was 4): a full re-extraction (a new extractor version) took 13,487 s on
# 2026-10-08 and its finishing pass (indexes, FTS) was still running when the
# 14,400 s wall clock stopped it; the resume then had to wait a day for disk.
# A normal night diffs in under an hour, so the cap only bounds the rare full run.
ok = run_cmd(cmd, "Build decision_structure sidecar (served text, incremental)", dry_run,
timeout=STRUCTURE_TIMEOUT_S, stall_timeout=9000)
if dry_run:
return True
if not ok or not tmp.exists():
if tmp.exists():
tmp.unlink()
# A first bootstrap can outlast the wall clock; the extractor keeps
# its partial working copy and resumes next run. The shard build is
# only the safety net for a MISSING sidecar: rebuilding an existing,
# readable one costs ~4.5 h and gains nothing.
if live_real.exists() and _structure_coverage(live_real, decisions_db):
logger.error(" served-text sidecar build failed; keeping the current sidecar "
"(a bootstrap resumes on the next run)")
return False
logger.error(" served-text sidecar build failed and no sidecar is served; "
"falling back to the shard-based build")
return _step_2g_from_shards(dry_run)
old = _structure_coverage(live_real, decisions_db) if live_real.exists() else None
new = _structure_coverage(tmp, decisions_db)
if new is None:
logger.error(" new sidecar unreadable; keeping the current one")
tmp.unlink()
return False
if old and new < old * STRUCTURE_COVERAGE_FLOOR:
logger.error(f" coverage gate: new sidecar indexes {new:,} current decisions, "
f"old one {old:,} (floor {STRUCTURE_COVERAGE_FLOOR:.0%}); keeping the current sidecar")
tmp.unlink()
return False
logger.info(f" structure coverage: {old if old is not None else '?'} -> {new:,} current decisions; swapping")
os.replace(tmp, live_real)
_warm_structure_sidecar(live_real, dry_run)
return True
def _warm_structure_sidecar(sidecar: Path, dry_run: bool = False) -> None:
"""After a sidecar swap the new 52 GB file has no page cache; the pinpoint
attach in search_decisions then does random reads per top result and
fresh searches took 80-96 s (2026-09-10 00:20 UTC). Reading the covering
indexes once (index-only queries, ~100 s) brings that back to seconds.
Never fails the step: the swap already happened and is correct."""
script = REPO_DIR / "scripts" / "warm_structure_sidecar.py"
if not script.exists():
logger.warning(" warm_structure_sidecar.py not found; sidecar stays cold until first use")
return
cmd = ["ionice", "-c", "2", "-n", "7", sys.executable, str(script),
"--structure-db", str(sidecar), "--budget-s", "900"]
try:
ok = run_cmd(cmd, "Warm decision_structure sidecar indexes", dry_run, timeout=1200)
except Exception as e: # noqa: BLE001 — warm-up is best effort
ok = False
logger.warning(f" sidecar warm-up raised {type(e).__name__}: {e}")
if not ok:
logger.warning(" sidecar warm-up did not complete; searches stay slow until the cache fills")
def _step_2g_from_shards(dry_run: bool = False) -> bool:
"""The pre-2026-09-06 build, kept as the fallback: extract from the raw JSONL shards
(Sachverhalt / Erwägungen-Paragraphs / Dispositiv / Regeste).
Federal + cantonal + regulatory courts. Reads every JSONL shard
in OUTPUT_DIR/decisions/ that has the canonical
`<court>.jsonl` / `es_<court>.jsonl` shape, skipping backups
(.bak*, .broken) and tmp files (tmp*). Writes sidecar SQLite
with atomic swap. Used by get_decision_structure / get_erwaegung /
get_regeste MCP tools and to enrich get_case_brief responses.
Auto-glob means new shards (e.g. when a canton's first scraper
lands) are picked up automatically without a publish.py edit.
"""
logger.info("Step 2g: Build decision_structure sidecar")
script = REPO_DIR / "search_stack" / "extract_decision_structure.py"
if not script.exists():
logger.info(" extract_decision_structure.py not found, skipping")
return True
decisions_dir = OUTPUT_DIR / "decisions"
if not decisions_dir.exists():
logger.warning(f" {decisions_dir} not found, skipping")
return True
# Glob every shard, exclude backups / tmp / broken files.
candidates = sorted(decisions_dir.glob("*.jsonl"))
skip_patterns = (".bak", ".broken", ".tmp", ".old")
shard_names = []
for path in candidates:
name = path.name
if any(p in name for p in skip_patterns):
continue
# tmp* files (no extension match) — handled by name prefix check
stem = path.stem # filename without trailing .jsonl
if stem.startswith("tmp") and stem[3:4].isalnum():
continue
shard_names.append(stem)
if not shard_names:
logger.warning(" no shards found, skipping")
return True
logger.info(f" building from {len(shard_names)} shards")
shards_arg = ",".join(shard_names)
return run_cmd(
[sys.executable, str(script), "--build",
"--shards", shards_arg,
"--decisions-dir", str(decisions_dir),
"--output", str(OUTPUT_DIR / "decision_structure.db")],
f"Build decision_structure sidecar ({len(shard_names)} shards)",
dry_run,
timeout=14400, # 2026-06-03 STOPGAP: full build now runs >2h (outgrew
# the "~1h" estimate) and hit the old 7200s cap on the 06-02 nightly
# under 4-way I/O contention. Real fix: extract_decision_structure_incremental.
# The new FTS5 'rebuild' + 'optimize' phase added in commit
# b8e4cf3 (find_relevant_erwaegung infra) emits no stdout while
# SQLite rewrites the index over ~970K paragraph rows. On the
# 2026-05-04 publish that silent phase ran past the default
# 5400s stall watchdog and got killed mid-rebuild — leaving the
# decision_structure.db sidecar (and therefore the FTS5 index
# find_relevant_erwaegung depends on) un-built. Bumped to 9000s
# (2.5h) — wide enough for the silent finalisation window
# observed in production, narrow enough to still catch a
# genuinely-wedged process within a few hours.
stall_timeout=9000,