switchroom 0.19.22 → 0.19.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +68 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +552 -320
- package/dist/host-control/main.js +69 -2
- package/dist/vault/approvals/kernel-server.js +71 -4
- package/dist/vault/broker/server.js +71 -4
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +13 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +90 -107
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +108 -16
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +9 -7
- package/telegram-plugin/gateway/outbound-send-path.ts +8 -1
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -17,6 +17,7 @@ otherwise undo:
|
|
|
17
17
|
* the stall guard does not overshoot under concurrency.
|
|
18
18
|
"""
|
|
19
19
|
|
|
20
|
+
import errno
|
|
20
21
|
import io
|
|
21
22
|
import json
|
|
22
23
|
import os
|
|
@@ -95,6 +96,9 @@ class _QueueTempDirMixin:
|
|
|
95
96
|
"HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
|
|
96
97
|
# The retain content bound, which decides whether `enqueue` splits.
|
|
97
98
|
"HINDSIGHT_RETAIN_MAX_CONTENT_CHARS",
|
|
99
|
+
"HINDSIGHT_PENDING_DUPLICATE_DIR",
|
|
100
|
+
"HINDSIGHT_PENDING_DEAD_DIR",
|
|
101
|
+
"HINDSIGHT_PENDING_RESPLIT_DIR",
|
|
98
102
|
)
|
|
99
103
|
|
|
100
104
|
def setUp(self):
|
|
@@ -253,9 +257,9 @@ class EvictionTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
253
257
|
def test_eviction_is_logged_to_the_ledger(self):
|
|
254
258
|
"""Eviction is not silent loss, but it IS loss -- doctor reads this."""
|
|
255
259
|
pending.MAX_ENTRIES = 1
|
|
256
|
-
pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
|
|
260
|
+
pending.enqueue(_payload(content="m0", doc="d0"), RuntimeError("x"))
|
|
257
261
|
with redirect_stderr(io.StringIO()):
|
|
258
|
-
pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
|
|
262
|
+
pending.enqueue(_payload(content="m1", doc="d1"), RuntimeError("x"))
|
|
259
263
|
with open(pending.evictions_log_path(), encoding="utf-8") as f:
|
|
260
264
|
line = f.read().strip()
|
|
261
265
|
self.assertIn("evicted=", line)
|
|
@@ -290,7 +294,9 @@ class EvictionTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
290
294
|
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
291
295
|
with redirect_stderr(io.StringIO()):
|
|
292
296
|
paths = [
|
|
293
|
-
pending.enqueue(
|
|
297
|
+
pending.enqueue(
|
|
298
|
+
_payload(content=f"m{i}", doc=f"d{i}"), RuntimeError("x")
|
|
299
|
+
)
|
|
294
300
|
for i in range(5)
|
|
295
301
|
]
|
|
296
302
|
evicted_names = [os.path.basename(p) for p in paths[:-1]]
|
|
@@ -320,9 +326,9 @@ class EvictionTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
320
326
|
prev = (pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES)
|
|
321
327
|
pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES = 1, 3
|
|
322
328
|
try:
|
|
323
|
-
pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
|
|
329
|
+
pending.enqueue(_payload(content="m0", doc="d0"), RuntimeError("x"))
|
|
324
330
|
with redirect_stderr(io.StringIO()):
|
|
325
|
-
pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
|
|
331
|
+
pending.enqueue(_payload(content="m1", doc="d1"), RuntimeError("x"))
|
|
326
332
|
finally:
|
|
327
333
|
pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES = prev
|
|
328
334
|
|
|
@@ -476,14 +482,341 @@ class DedupeTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
476
482
|
pending.enqueue(_payload(bank="bank-b", content="same"), RuntimeError("x"))
|
|
477
483
|
self.assertEqual(pending.count(), 2)
|
|
478
484
|
|
|
479
|
-
def
|
|
480
|
-
"""No
|
|
485
|
+
def test_entry_without_content_is_always_kept(self):
|
|
486
|
+
"""No content => identity cannot be established => never merge.
|
|
487
|
+
|
|
488
|
+
This used to key off ``document_id``. It does not any more
|
|
489
|
+
(switchroom #3688): the id varies per enqueue for the producer that
|
|
490
|
+
dominates this queue, so it was never a usable identity. Content is.
|
|
491
|
+
"""
|
|
481
492
|
p = _payload()
|
|
482
|
-
p.pop("
|
|
493
|
+
p.pop("content")
|
|
483
494
|
pending.enqueue(dict(p), RuntimeError("x"))
|
|
484
495
|
pending.enqueue(dict(p), RuntimeError("x"))
|
|
485
496
|
self.assertEqual(pending.count(), 2)
|
|
486
497
|
|
|
498
|
+
def test_entry_without_document_id_still_dedupes_on_content(self):
|
|
499
|
+
"""The id is not required for identity — the content carries it."""
|
|
500
|
+
p = _payload(content="same")
|
|
501
|
+
p.pop("document_id")
|
|
502
|
+
a = pending.enqueue(dict(p), RuntimeError("x"))
|
|
503
|
+
b = pending.enqueue(dict(p), RuntimeError("x"))
|
|
504
|
+
self.assertEqual(a, b)
|
|
505
|
+
self.assertEqual(pending.count(), 1)
|
|
506
|
+
|
|
507
|
+
def test_same_content_under_a_fresh_document_id_is_ONE_entry(self):
|
|
508
|
+
"""THE bug (switchroom #3688), pinned as an outcome.
|
|
509
|
+
|
|
510
|
+
Measured on the live fleet 2026-07-26: 1,060 queued files, ~368
|
|
511
|
+
distinct ``(bank_id, part_position, sha256(content))`` groups. The top
|
|
512
|
+
group held
|
|
513
|
+
32 files carrying ONE byte-identical 45,000-char part under 32
|
|
514
|
+
DIFFERENT document ids, because ``subagent_retain.py`` stamps the
|
|
515
|
+
sub-agent's own session id into the id
|
|
516
|
+
(``{parent}-sub-{agent_id}-r{start}-{end}``) and every SubagentStop
|
|
517
|
+
mints a new one. With ``document_id`` in the dedupe key not one of
|
|
518
|
+
those 32 matched, so the queue refilled itself faster than it
|
|
519
|
+
drained and the same LLM extraction was paid for 32 times.
|
|
520
|
+
|
|
521
|
+
Restore ``document_id`` to ``_dupe_key`` and this goes red.
|
|
522
|
+
"""
|
|
523
|
+
parent = "34c4bbeb-f805-4dd3-b085-46ac2eb57ae9"
|
|
524
|
+
slice_ids = f"r{_uuid(7)}-{_uuid(8)}"
|
|
525
|
+
content = "the same sub-agent work log, byte for byte" * 50
|
|
526
|
+
for agent_id in ("aa2170a03a351e0", "a8857997182e1e9", "a836dfc39b5eb94"):
|
|
527
|
+
pending.enqueue(
|
|
528
|
+
_payload(content=content, doc=f"{parent}-sub-{agent_id}-{slice_ids}"),
|
|
529
|
+
RuntimeError("timed out"),
|
|
530
|
+
)
|
|
531
|
+
self.assertEqual(
|
|
532
|
+
pending.count(),
|
|
533
|
+
1,
|
|
534
|
+
"re-enqueuing identical content under a fresh document_id must be "
|
|
535
|
+
"a no-op, not a new queue entry",
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
def test_the_surviving_entry_keeps_a_usable_document_id(self):
|
|
539
|
+
"""Dedupe must not leave an entry the drain cannot act on."""
|
|
540
|
+
doc = _cd_doc(3)
|
|
541
|
+
pending.enqueue(_payload(content="c", doc=doc), RuntimeError("x"))
|
|
542
|
+
pending.enqueue(_payload(content="c", doc=_cd_doc(4)), RuntimeError("x"))
|
|
543
|
+
(_, entry), = pending.iter_entries()
|
|
544
|
+
self.assertEqual(entry["document_id"], doc, "the FIRST entry survives")
|
|
545
|
+
self.assertTrue(pending.is_content_derived_document_id(entry["document_id"]))
|
|
546
|
+
|
|
547
|
+
def test_identical_parts_of_ONE_split_are_never_merged(self):
|
|
548
|
+
"""The parts of a split are positions, not copies.
|
|
549
|
+
|
|
550
|
+
``split_retain_content`` cuts on a character bound, so repetitive
|
|
551
|
+
content yields byte-identical parts. Merging them would leave the
|
|
552
|
+
document missing every position but one — content loss, not a
|
|
553
|
+
redundant copy. Drop ``_part_position`` from the key and this goes
|
|
554
|
+
red: 10 parts collapse to 1.
|
|
555
|
+
"""
|
|
556
|
+
pending.MAX_ENTRIES = 20
|
|
557
|
+
pending.MAX_BYTES = 10**9
|
|
558
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
559
|
+
try:
|
|
560
|
+
pending.enqueue(
|
|
561
|
+
_payload(content="z" * 30_000, doc=_cd_doc(5)), RuntimeError("x")
|
|
562
|
+
)
|
|
563
|
+
finally:
|
|
564
|
+
os.environ.pop("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS", None)
|
|
565
|
+
|
|
566
|
+
entries = [e for _p, e in pending.iter_entries()]
|
|
567
|
+
self.assertEqual(len(entries), 10, "one queued entry per position")
|
|
568
|
+
self.assertEqual(
|
|
569
|
+
len({e["content"] for e in entries}),
|
|
570
|
+
1,
|
|
571
|
+
"fixture check: the parts really are byte-identical",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def test_the_same_position_under_a_GROWING_total_still_dedupes(self):
|
|
575
|
+
"""The measured 32x group carried -p7of15, -p7of16 and -p7of18.
|
|
576
|
+
|
|
577
|
+
The enclosing transcript kept growing between SubagentStops while
|
|
578
|
+
part 7 itself did not change. Key on the total as well as the index
|
|
579
|
+
and that group splits three ways and collapses nothing.
|
|
580
|
+
"""
|
|
581
|
+
base = _cd_doc(6)
|
|
582
|
+
for total in (15, 16, 18):
|
|
583
|
+
pending.enqueue(
|
|
584
|
+
_payload(content="part-seven", doc=f"{base}-p7of{total}"),
|
|
585
|
+
RuntimeError("x"),
|
|
586
|
+
)
|
|
587
|
+
self.assertEqual(pending.count(), 1, "one memory, one queue entry")
|
|
588
|
+
|
|
589
|
+
def test_a_part_position_is_read_off_the_id_not_the_metadata(self):
|
|
590
|
+
"""Entries queued by older builds may carry no part metadata."""
|
|
591
|
+
self.assertEqual(pending._part_position({"document_id": "abc"}), "")
|
|
592
|
+
self.assertEqual(pending._part_position({"document_id": "abc-p7of16"}), "7")
|
|
593
|
+
self.assertEqual(
|
|
594
|
+
pending._part_position({"document_id": "abc-p2of5-p1of2"}),
|
|
595
|
+
"2.1",
|
|
596
|
+
"innermost split reads last",
|
|
597
|
+
)
|
|
598
|
+
self.assertEqual(pending._part_position({"document_id": None}), "")
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class CollapseDuplicatesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
602
|
+
"""``collapse_duplicates`` — the other half of content-keyed identity.
|
|
603
|
+
|
|
604
|
+
``enqueue``'s filename key stops NEW duplicates. It cannot touch the
|
|
605
|
+
ones already on disk: those were written with the old id-bearing key, so
|
|
606
|
+
a fresh enqueue of their content computes a different key and never
|
|
607
|
+
matches them. This pass recomputes identity from CONTENT, which makes it
|
|
608
|
+
generation-agnostic, and the backlog drain runs it before spending a
|
|
609
|
+
single LLM call.
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
def _duplicate_names(self):
|
|
613
|
+
try:
|
|
614
|
+
return sorted(os.listdir(pending.duplicate_dir()))
|
|
615
|
+
except OSError:
|
|
616
|
+
return []
|
|
617
|
+
|
|
618
|
+
def _write_legacy(self, name, content, bank="bank-a", doc=None, attempts=1):
|
|
619
|
+
"""Write an entry the way an OLDER build would have named it.
|
|
620
|
+
|
|
621
|
+
``<unix-ms>-<uuid>.json`` with no key segment at all — the shape
|
|
622
|
+
that predates #3596 — so nothing about the filename can be doing
|
|
623
|
+
the identity work in these cases.
|
|
624
|
+
"""
|
|
625
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
626
|
+
entry = dict(_payload(bank=bank, content=content, doc=doc or _cd_doc(1)))
|
|
627
|
+
entry["attempt_count"] = attempts
|
|
628
|
+
p = os.path.join(self._dir, name)
|
|
629
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
630
|
+
json.dump(entry, f)
|
|
631
|
+
return p
|
|
632
|
+
|
|
633
|
+
def test_duplicates_collapse_to_one_live_entry(self):
|
|
634
|
+
for i in range(5):
|
|
635
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i}.json", "same-memory")
|
|
636
|
+
self.assertEqual(pending.count(), 5)
|
|
637
|
+
|
|
638
|
+
with redirect_stderr(io.StringIO()):
|
|
639
|
+
collapsed = pending.collapse_duplicates()
|
|
640
|
+
|
|
641
|
+
self.assertEqual(collapsed, 4)
|
|
642
|
+
self.assertEqual(pending.count(), 1)
|
|
643
|
+
|
|
644
|
+
def test_collapse_archives_the_losers_and_never_deletes_them(self):
|
|
645
|
+
for i in range(3):
|
|
646
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i}.json", "same-memory")
|
|
647
|
+
|
|
648
|
+
with redirect_stderr(io.StringIO()):
|
|
649
|
+
pending.collapse_duplicates()
|
|
650
|
+
|
|
651
|
+
archived = self._duplicate_names()
|
|
652
|
+
self.assertEqual(len(archived), 2, "losers are MOVED, not removed")
|
|
653
|
+
for name in archived:
|
|
654
|
+
with open(os.path.join(pending.duplicate_dir(), name)) as f:
|
|
655
|
+
self.assertEqual(json.load(f)["content"], "same-memory")
|
|
656
|
+
|
|
657
|
+
def test_the_copy_with_the_fewest_attempts_survives(self):
|
|
658
|
+
"""Durability-first survivor selection.
|
|
659
|
+
|
|
660
|
+
Every copy carries identical content, so any of them delivers the
|
|
661
|
+
same memory — but only the one with attempts left can still get
|
|
662
|
+
there before MAX_ATTEMPTS. Keeping the OLDEST outright would
|
|
663
|
+
systematically keep the most-attempted copy, which is backwards.
|
|
664
|
+
"""
|
|
665
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=4)
|
|
666
|
+
keep = self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts=1)
|
|
667
|
+
self._write_legacy("0000000003000-aaaaaaaaaaa2.json", "same", attempts=3)
|
|
668
|
+
|
|
669
|
+
with redirect_stderr(io.StringIO()):
|
|
670
|
+
pending.collapse_duplicates()
|
|
671
|
+
|
|
672
|
+
survivors = [p for p, _ in pending.iter_entries()]
|
|
673
|
+
self.assertEqual(survivors, [keep])
|
|
674
|
+
|
|
675
|
+
def test_distinct_memories_are_never_collapsed(self):
|
|
676
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "memory-one")
|
|
677
|
+
self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "memory-two")
|
|
678
|
+
self._write_legacy("0000000003000-aaaaaaaaaaa2.json", "memory-three")
|
|
679
|
+
|
|
680
|
+
with redirect_stderr(io.StringIO()):
|
|
681
|
+
self.assertEqual(pending.collapse_duplicates(), 0)
|
|
682
|
+
self.assertEqual(pending.count(), 3)
|
|
683
|
+
|
|
684
|
+
def test_identical_parts_of_ONE_split_are_never_collapsed(self):
|
|
685
|
+
"""The backlog is full of split parts; do not eat a document's tail.
|
|
686
|
+
|
|
687
|
+
A legacy 3-part entry whose parts happen to be byte-identical must
|
|
688
|
+
survive the sweep intact. Only the position keeps them apart.
|
|
689
|
+
"""
|
|
690
|
+
base = _cd_doc(2)
|
|
691
|
+
for i in range(1, 4):
|
|
692
|
+
self._write_legacy(
|
|
693
|
+
f"000000000{i}000-aaaaaaaaaaa{i}.json",
|
|
694
|
+
"zzz",
|
|
695
|
+
doc=f"{base}-p{i}of3",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
with redirect_stderr(io.StringIO()):
|
|
699
|
+
self.assertEqual(pending.collapse_duplicates(), 0)
|
|
700
|
+
self.assertEqual(pending.count(), 3, "every position kept")
|
|
701
|
+
self.assertEqual(self._duplicate_names(), [], "nothing archived")
|
|
702
|
+
|
|
703
|
+
def test_the_same_position_from_different_runs_IS_collapsed(self):
|
|
704
|
+
"""The complement: same index, different totals and ids -> one entry."""
|
|
705
|
+
for i, total in enumerate((15, 16, 18), start=1):
|
|
706
|
+
self._write_legacy(
|
|
707
|
+
f"000000000{i}000-aaaaaaaaaaa{i}.json",
|
|
708
|
+
"zzz",
|
|
709
|
+
doc=f"{_cd_doc(i)}-p7of{total}",
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
with redirect_stderr(io.StringIO()):
|
|
713
|
+
self.assertEqual(pending.collapse_duplicates(), 2)
|
|
714
|
+
self.assertEqual(pending.count(), 1)
|
|
715
|
+
|
|
716
|
+
def test_same_content_in_different_banks_is_never_collapsed(self):
|
|
717
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", bank="bank-a")
|
|
718
|
+
self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", bank="bank-b")
|
|
719
|
+
|
|
720
|
+
with redirect_stderr(io.StringIO()):
|
|
721
|
+
self.assertEqual(pending.collapse_duplicates(), 0)
|
|
722
|
+
self.assertEqual(pending.count(), 2)
|
|
723
|
+
|
|
724
|
+
def test_entries_without_content_are_never_grouped(self):
|
|
725
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
726
|
+
for i in range(2):
|
|
727
|
+
p = os.path.join(self._dir, f"{1000 + i:013d}-aaaaaaaaaaa{i}.json")
|
|
728
|
+
e = dict(_payload())
|
|
729
|
+
e.pop("content")
|
|
730
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
731
|
+
json.dump(e, f)
|
|
732
|
+
|
|
733
|
+
with redirect_stderr(io.StringIO()):
|
|
734
|
+
self.assertEqual(pending.collapse_duplicates(), 0)
|
|
735
|
+
self.assertEqual(pending.count(), 2)
|
|
736
|
+
|
|
737
|
+
def test_an_unarchivable_duplicate_stays_queued_and_is_not_counted(self):
|
|
738
|
+
"""Archiving never falls back to a delete, and never over-reports."""
|
|
739
|
+
for i in range(3):
|
|
740
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i}.json", "same")
|
|
741
|
+
|
|
742
|
+
with unittest.mock.patch.object(
|
|
743
|
+
pending.shutil, "move", side_effect=OSError("ENOSPC")
|
|
744
|
+
):
|
|
745
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
746
|
+
collapsed = pending.collapse_duplicates()
|
|
747
|
+
|
|
748
|
+
self.assertEqual(collapsed, 0, "a retire that did not happen is not counted")
|
|
749
|
+
self.assertEqual(pending.count(), 3, "the entries are STILL QUEUED")
|
|
750
|
+
self.assertIn("STAYS QUEUED", err.getvalue())
|
|
751
|
+
|
|
752
|
+
def test_the_duplicate_archive_is_itself_bounded(self):
|
|
753
|
+
prev = pending.DUPLICATE_MAX_ENTRIES
|
|
754
|
+
pending.DUPLICATE_MAX_ENTRIES = 2
|
|
755
|
+
try:
|
|
756
|
+
for i in range(6):
|
|
757
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i}.json", "same")
|
|
758
|
+
with redirect_stderr(io.StringIO()):
|
|
759
|
+
pending.collapse_duplicates()
|
|
760
|
+
self.assertLessEqual(len(self._duplicate_names()), 2)
|
|
761
|
+
finally:
|
|
762
|
+
pending.DUPLICATE_MAX_ENTRIES = prev
|
|
763
|
+
|
|
764
|
+
def test_the_backlog_drain_collapses_before_paying_for_extraction(self):
|
|
765
|
+
"""The user-visible outcome: one memory costs ONE extraction.
|
|
766
|
+
|
|
767
|
+
Thirty-two byte-identical copies is what the live queue actually
|
|
768
|
+
held. At the measured ~168 s per phase-2 extraction, draining them
|
|
769
|
+
all is ~90 minutes of shared LLM lane time to persist one memory.
|
|
770
|
+
"""
|
|
771
|
+
for i in range(32):
|
|
772
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i:02d}.json", "one-memory")
|
|
773
|
+
|
|
774
|
+
posted = []
|
|
775
|
+
with unittest.mock.patch.object(
|
|
776
|
+
drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
|
|
777
|
+
):
|
|
778
|
+
with unittest.mock.patch.object(
|
|
779
|
+
drain_pending,
|
|
780
|
+
"_retry_one",
|
|
781
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
782
|
+
):
|
|
783
|
+
with redirect_stderr(io.StringIO()):
|
|
784
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
785
|
+
|
|
786
|
+
self.assertEqual(summary["collapsed"], 31)
|
|
787
|
+
self.assertEqual(len(posted), 1, "one memory must cost exactly one retain")
|
|
788
|
+
self.assertEqual(pending.count(), 0)
|
|
789
|
+
|
|
790
|
+
def test_the_in_hook_drain_does_not_collapse(self):
|
|
791
|
+
"""The SessionStart drain's contract is a hard latency ceiling.
|
|
792
|
+
|
|
793
|
+
Collapsing reads every queued entry to recompute identity from
|
|
794
|
+
content; that belongs out of hook. New duplicates cannot accumulate
|
|
795
|
+
in-hook anyway — ``enqueue``'s filename key stops those.
|
|
796
|
+
"""
|
|
797
|
+
for i in range(4):
|
|
798
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i}.json", "same")
|
|
799
|
+
|
|
800
|
+
with unittest.mock.patch.object(
|
|
801
|
+
drain_pending, "_document_state", lambda e, timeout=30: None
|
|
802
|
+
):
|
|
803
|
+
with unittest.mock.patch.object(
|
|
804
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
805
|
+
):
|
|
806
|
+
with redirect_stderr(io.StringIO()):
|
|
807
|
+
summary = drain_pending.drain(CONFIG)
|
|
808
|
+
|
|
809
|
+
self.assertEqual(summary["collapsed"], 0)
|
|
810
|
+
self.assertEqual(len(self._duplicate_names()), 0)
|
|
811
|
+
|
|
812
|
+
def test_a_dry_run_collapses_nothing(self):
|
|
813
|
+
for i in range(3):
|
|
814
|
+
self._write_legacy(f"{1000 + i:013d}-aaaaaaaaaaa{i}.json", "same")
|
|
815
|
+
with redirect_stderr(io.StringIO()):
|
|
816
|
+
summary = drain_pending.drain_backlog(CONFIG, dry_run=True)
|
|
817
|
+
self.assertEqual(summary["collapsed"], 0)
|
|
818
|
+
self.assertEqual(pending.count(), 3)
|
|
819
|
+
|
|
487
820
|
|
|
488
821
|
class DropLedgerTest(_QueueTempDirMixin, unittest.TestCase):
|
|
489
822
|
"""Residual drops -- the entry could not be written even after eviction."""
|
|
@@ -1091,8 +1424,12 @@ class BacklogDrainTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
1091
1424
|
)
|
|
1092
1425
|
|
|
1093
1426
|
self.assertEqual(summary["dead"], 1)
|
|
1094
|
-
|
|
1427
|
+
# The marker lands in the pending-dead/ archive, NOT beside the live
|
|
1428
|
+
# entries -- see `pending.dead_dir`.
|
|
1429
|
+
self.assertTrue(os.path.exists(
|
|
1430
|
+
os.path.join(pending.dead_dir(), os.path.basename(path) + ".dead")))
|
|
1095
1431
|
self.assertFalse(os.path.exists(path))
|
|
1432
|
+
self.assertFalse(os.path.exists(path + ".dead"))
|
|
1096
1433
|
|
|
1097
1434
|
def test_extraction_500_past_max_attempts_never_kills_the_memory(self):
|
|
1098
1435
|
"""The regression this gate exists for.
|
|
@@ -1237,8 +1574,10 @@ class TransportFailureEndToEndTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
1237
1574
|
path = self._exhaust_attempts()
|
|
1238
1575
|
summary = self._drain_with_urlopen(self._http_error(400))
|
|
1239
1576
|
self.assertEqual(summary["dead"], 1, "a 4xx rejection must still retire")
|
|
1240
|
-
self.assertTrue(os.path.exists(
|
|
1577
|
+
self.assertTrue(os.path.exists(
|
|
1578
|
+
os.path.join(pending.dead_dir(), os.path.basename(path) + ".dead")))
|
|
1241
1579
|
self.assertFalse(os.path.exists(path))
|
|
1580
|
+
self.assertFalse(os.path.exists(path + ".dead"))
|
|
1242
1581
|
|
|
1243
1582
|
|
|
1244
1583
|
class ArchiveFailureNeverDeletesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
@@ -1861,7 +2200,10 @@ class ClampAndEnvKnobBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
1861
2200
|
)
|
|
1862
2201
|
self.assertEqual(
|
|
1863
2202
|
retain_split.retain_content_limit(),
|
|
1864
|
-
|
|
2203
|
+
# Same input, less the #3693 safety fraction: the bound is a
|
|
2204
|
+
# FRACTION of the deadline, not all of it, so a maximally-sized
|
|
2205
|
+
# part still has headroom when the drain waits exactly 600s.
|
|
2206
|
+
3000 * int((600 * retain_split.retain_deadline_safety()) // 18.4),
|
|
1865
2207
|
"and the content bound, from the same input",
|
|
1866
2208
|
)
|
|
1867
2209
|
finally:
|
|
@@ -2271,5 +2613,1097 @@ class EvictionsLogRotationBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
2271
2613
|
self.assertEqual(len(self._lines()), 1, "over the cap, rotation runs")
|
|
2272
2614
|
|
|
2273
2615
|
|
|
2616
|
+
class DeadMarkersLeaveTheLiveQueueTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2617
|
+
"""A `.dead` marker is the ONLY copy of its memory, so it must not sit
|
|
2618
|
+
in the directory external janitors sweep.
|
|
2619
|
+
|
|
2620
|
+
Live evidence: a host cron on this fleet ran
|
|
2621
|
+
``find <queue> -name '*.dead' -mtime +14 -delete`` (6 markers were live
|
|
2622
|
+
in the queue when this was measured on 2026-07-26, each on a countdown
|
|
2623
|
+
to permanent deletion). Fixing that one cron is
|
|
2624
|
+
not a fix -- the next janitor has the same shape. These tests pin the
|
|
2625
|
+
product-level invariant instead: THE LIVE QUEUE DIRECTORY CONTAINS ONLY
|
|
2626
|
+
LIVE ENTRIES.
|
|
2627
|
+
"""
|
|
2628
|
+
|
|
2629
|
+
def _dead_names(self):
|
|
2630
|
+
try:
|
|
2631
|
+
return sorted(os.listdir(pending.dead_dir()))
|
|
2632
|
+
except OSError:
|
|
2633
|
+
return []
|
|
2634
|
+
|
|
2635
|
+
def test_a_janitor_globbing_the_live_queue_cannot_match_a_memory(self):
|
|
2636
|
+
path = pending.enqueue(_payload(content="doomed"), RuntimeError("x"))
|
|
2637
|
+
pending.mark_dead(path, dict(_payload(content="doomed")))
|
|
2638
|
+
|
|
2639
|
+
# This is literally what the host cron matched on.
|
|
2640
|
+
leftovers = [n for n in os.listdir(self._dir) if n.endswith(".dead")]
|
|
2641
|
+
self.assertEqual(leftovers, [], "no .dead file may remain in the queue dir")
|
|
2642
|
+
self.assertEqual(len(self._dead_names()), 1, "and the memory still exists")
|
|
2643
|
+
|
|
2644
|
+
def test_the_marker_still_carries_the_whole_memory(self):
|
|
2645
|
+
path = pending.enqueue(_payload(content="precious"), RuntimeError("x"))
|
|
2646
|
+
marker = pending.mark_dead(path, dict(_payload(content="precious")))
|
|
2647
|
+
with open(marker, encoding="utf-8") as f:
|
|
2648
|
+
self.assertEqual(json.load(f)["content"], "precious")
|
|
2649
|
+
self.assertTrue(marker.startswith(pending.dead_dir()))
|
|
2650
|
+
|
|
2651
|
+
def test_an_unwritable_dead_archive_leaves_the_entry_QUEUED(self):
|
|
2652
|
+
"""Failure to archive is never a licence to delete."""
|
|
2653
|
+
path = pending.enqueue(_payload(content="stays"), RuntimeError("x"))
|
|
2654
|
+
blocker = os.path.join(self._tmp, "blocked")
|
|
2655
|
+
with open(blocker, "w", encoding="utf-8") as f:
|
|
2656
|
+
f.write("not a directory")
|
|
2657
|
+
os.environ["HINDSIGHT_PENDING_DEAD_DIR"] = os.path.join(blocker, "dead")
|
|
2658
|
+
|
|
2659
|
+
self.assertIsNone(pending.mark_dead(path, dict(_payload(content="stays"))))
|
|
2660
|
+
self.assertTrue(os.path.exists(path), "the live entry must survive")
|
|
2661
|
+
self.assertEqual(pending.count(), 1)
|
|
2662
|
+
|
|
2663
|
+
def test_a_failed_REPLACE_also_leaves_the_entry_QUEUED(self):
|
|
2664
|
+
"""The other half of "FAILURE IS NOT DELETION".
|
|
2665
|
+
|
|
2666
|
+
`test_an_unwritable_dead_archive_leaves_the_entry_QUEUED` fails at
|
|
2667
|
+
`os.makedirs`, so the tmp never exists and the cleanup block's
|
|
2668
|
+
`os.unlink(tmp)` raises before anything else in it can run -- which
|
|
2669
|
+
shields a stray `os.unlink(path)` placed AFTER it from ever being
|
|
2670
|
+
observed. Here the tmp write SUCCEEDS and only `os.replace` fails, so
|
|
2671
|
+
every statement in the cleanup block executes.
|
|
2672
|
+
"""
|
|
2673
|
+
path = pending.enqueue(_payload(content="stays"), RuntimeError("x"))
|
|
2674
|
+
real_replace = os.replace
|
|
2675
|
+
|
|
2676
|
+
def _fail_replace(src, dst, *a, **kw):
|
|
2677
|
+
if str(dst).endswith(".dead"):
|
|
2678
|
+
raise OSError("replace refused")
|
|
2679
|
+
return real_replace(src, dst, *a, **kw)
|
|
2680
|
+
|
|
2681
|
+
with unittest.mock.patch("os.replace", side_effect=_fail_replace):
|
|
2682
|
+
self.assertIsNone(
|
|
2683
|
+
pending.mark_dead(path, dict(_payload(content="stays")))
|
|
2684
|
+
)
|
|
2685
|
+
|
|
2686
|
+
self.assertTrue(os.path.exists(path), "the live entry must survive")
|
|
2687
|
+
self.assertEqual(pending.count(), 1)
|
|
2688
|
+
self.assertEqual(self._dead_names(), [], "no marker, so no copy exists")
|
|
2689
|
+
# And the orphaned tmp really was cleaned up -- that is what the
|
|
2690
|
+
# `os.unlink(tmp)` in the cleanup block is FOR.
|
|
2691
|
+
self.assertEqual(
|
|
2692
|
+
[n for n in os.listdir(pending.dead_dir()) if n.endswith(".tmp")], []
|
|
2693
|
+
)
|
|
2694
|
+
|
|
2695
|
+
def test_legacy_markers_in_the_queue_dir_are_relocated_not_deleted(self):
|
|
2696
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
2697
|
+
legacy = []
|
|
2698
|
+
for i in range(3):
|
|
2699
|
+
name = os.path.join(self._dir, f"100{i}000-abc-{i}.json.dead")
|
|
2700
|
+
with open(name, "w", encoding="utf-8") as f:
|
|
2701
|
+
json.dump(_payload(content=f"old-{i}"), f)
|
|
2702
|
+
legacy.append(name)
|
|
2703
|
+
|
|
2704
|
+
self.assertEqual(pending.sweep_legacy_dead_markers(), 3)
|
|
2705
|
+
self.assertEqual(
|
|
2706
|
+
[n for n in os.listdir(self._dir) if n.endswith(".dead")], []
|
|
2707
|
+
)
|
|
2708
|
+
self.assertEqual(len(self._dead_names()), 3)
|
|
2709
|
+
for name in legacy:
|
|
2710
|
+
self.assertFalse(os.path.exists(name))
|
|
2711
|
+
# Content survived the move -- this is the whole point.
|
|
2712
|
+
moved = os.path.join(pending.dead_dir(), self._dead_names()[0])
|
|
2713
|
+
with open(moved, encoding="utf-8") as f:
|
|
2714
|
+
self.assertEqual(json.load(f)["content"], "old-0")
|
|
2715
|
+
|
|
2716
|
+
def test_the_sweep_is_idempotent_and_never_overwrites(self):
|
|
2717
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
2718
|
+
name = os.path.join(self._dir, "1000000-abc-0.json.dead")
|
|
2719
|
+
with open(name, "w", encoding="utf-8") as f:
|
|
2720
|
+
json.dump(_payload(content="first"), f)
|
|
2721
|
+
self.assertEqual(pending.sweep_legacy_dead_markers(), 1)
|
|
2722
|
+
self.assertEqual(pending.sweep_legacy_dead_markers(), 0)
|
|
2723
|
+
|
|
2724
|
+
# A same-named marker reappearing must not clobber the archived one.
|
|
2725
|
+
with open(name, "w", encoding="utf-8") as f:
|
|
2726
|
+
json.dump(_payload(content="second"), f)
|
|
2727
|
+
self.assertEqual(pending.sweep_legacy_dead_markers(), 0)
|
|
2728
|
+
with open(os.path.join(pending.dead_dir(), os.path.basename(name)),
|
|
2729
|
+
encoding="utf-8") as f:
|
|
2730
|
+
self.assertEqual(json.load(f)["content"], "first")
|
|
2731
|
+
|
|
2732
|
+
def test_the_sweep_leaves_live_entries_alone(self):
|
|
2733
|
+
pending.enqueue(_payload(content="alive"), RuntimeError("x"))
|
|
2734
|
+
self.assertEqual(pending.sweep_legacy_dead_markers(), 0)
|
|
2735
|
+
self.assertEqual(pending.count(), 1)
|
|
2736
|
+
|
|
2737
|
+
def test_a_dead_marker_is_STRUCTURALLY_UNTRIMMABLE(self):
|
|
2738
|
+
"""The deliberate exception: a dead marker is the only copy left.
|
|
2739
|
+
|
|
2740
|
+
Every other archive holds a redundant copy and is capped. This one
|
|
2741
|
+
holds unrecovered memories, so a cap would be a delete by another
|
|
2742
|
+
name -- exactly the loss channel this change closes. The guarantee
|
|
2743
|
+
is not "we remember not to call the trim": `_trim_dir` selects
|
|
2744
|
+
`*.json` and a marker is `<entry>.json.dead`, so pointing a trim
|
|
2745
|
+
straight at the dead archive with a zero cap still removes nothing.
|
|
2746
|
+
"""
|
|
2747
|
+
for i in range(40):
|
|
2748
|
+
path = pending.enqueue(_payload(content=f"dead-{i}"), RuntimeError("x"))
|
|
2749
|
+
pending.mark_dead(path, dict(_payload(content=f"dead-{i}")))
|
|
2750
|
+
self.assertEqual(len(self._dead_names()), 40)
|
|
2751
|
+
|
|
2752
|
+
with redirect_stderr(io.StringIO()):
|
|
2753
|
+
dropped = pending._trim_dir(pending.dead_dir(), 0, 0)
|
|
2754
|
+
self.assertEqual(dropped, 0)
|
|
2755
|
+
self.assertEqual(len(self._dead_names()), 40, "a memory was trimmed away")
|
|
2756
|
+
|
|
2757
|
+
def _write_legacy_markers(self, n: int) -> list:
|
|
2758
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
2759
|
+
names = []
|
|
2760
|
+
for i in range(n):
|
|
2761
|
+
name = f"100{i}000-abc-{i}.json.dead"
|
|
2762
|
+
with open(os.path.join(self._dir, name), "w", encoding="utf-8") as f:
|
|
2763
|
+
json.dump(_payload(content=f"old-{i}"), f)
|
|
2764
|
+
names.append(name)
|
|
2765
|
+
return names
|
|
2766
|
+
|
|
2767
|
+
def _legacy_leftovers(self):
|
|
2768
|
+
return sorted(n for n in os.listdir(self._dir) if n.endswith(".dead"))
|
|
2769
|
+
|
|
2770
|
+
def test_an_uncreatable_dead_archive_reports_ZERO_relocated(self):
|
|
2771
|
+
"""The count is a claim about the LIVE QUEUE, not an intention.
|
|
2772
|
+
|
|
2773
|
+
Phase 0b turns it straight into `summary["dead_relocated"]` and the
|
|
2774
|
+
line "the queue now holds only live entries, so no janitor glob over
|
|
2775
|
+
it can match a memory". When `dead_dir()` cannot be created not one
|
|
2776
|
+
marker moved, so returning `len(names)` would print that sentence
|
|
2777
|
+
over a queue directory still holding every marker -- the exact
|
|
2778
|
+
condition this change exists to end, now with an operator told it was
|
|
2779
|
+
fixed.
|
|
2780
|
+
"""
|
|
2781
|
+
self._write_legacy_markers(3)
|
|
2782
|
+
|
|
2783
|
+
with unittest.mock.patch.object(
|
|
2784
|
+
pending.os, "makedirs",
|
|
2785
|
+
side_effect=OSError(errno.EACCES, "Permission denied"),
|
|
2786
|
+
):
|
|
2787
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2788
|
+
moved = pending.sweep_legacy_dead_markers()
|
|
2789
|
+
|
|
2790
|
+
self.assertEqual(moved, 0, "nothing moved, so nothing may be claimed")
|
|
2791
|
+
self.assertEqual(
|
|
2792
|
+
len(self._legacy_leftovers()), 3,
|
|
2793
|
+
"and every marker is still in the janitor's path",
|
|
2794
|
+
)
|
|
2795
|
+
self.assertEqual(self._dead_names(), [])
|
|
2796
|
+
self.assertIn("STAY in the live queue", err.getvalue())
|
|
2797
|
+
|
|
2798
|
+
def test_a_marker_that_could_not_be_MOVED_is_not_counted(self):
|
|
2799
|
+
"""One marker per `except OSError`, and a `pass` there counts a lie.
|
|
2800
|
+
|
|
2801
|
+
The loop swallows a per-marker failure deliberately -- one unwritable
|
|
2802
|
+
marker must not abandon the other nine -- but swallowing it is not
|
|
2803
|
+
the same as having moved it. Dropping the `continue` leaves the
|
|
2804
|
+
marker in the live queue directory AND reports it relocated.
|
|
2805
|
+
"""
|
|
2806
|
+
names = self._write_legacy_markers(2)
|
|
2807
|
+
real_move = shutil.move
|
|
2808
|
+
|
|
2809
|
+
def refuse_the_second(src, dst):
|
|
2810
|
+
if str(src).endswith(names[1]):
|
|
2811
|
+
raise OSError(errno.EACCES, "Permission denied")
|
|
2812
|
+
return real_move(src, dst)
|
|
2813
|
+
|
|
2814
|
+
with unittest.mock.patch.object(pending.shutil, "move", refuse_the_second):
|
|
2815
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2816
|
+
moved = pending.sweep_legacy_dead_markers()
|
|
2817
|
+
|
|
2818
|
+
self.assertEqual(moved, 1, "only the marker that actually moved counts")
|
|
2819
|
+
self.assertEqual(
|
|
2820
|
+
self._legacy_leftovers(), [names[1]],
|
|
2821
|
+
"the refused one is still in the live queue directory",
|
|
2822
|
+
)
|
|
2823
|
+
self.assertEqual(self._dead_names(), [names[0]])
|
|
2824
|
+
self.assertIn("relocated 1 legacy .dead marker(s)", err.getvalue())
|
|
2825
|
+
|
|
2826
|
+
def test_the_backlog_drain_relocates_legacy_markers(self):
|
|
2827
|
+
"""The migration only happens if the drain actually calls it.
|
|
2828
|
+
|
|
2829
|
+
Nothing else on the fleet runs `sweep_legacy_dead_markers`, so an
|
|
2830
|
+
unwired phase 0b leaves every existing marker sitting in the janitor's
|
|
2831
|
+
path -- the exact condition this change exists to end. Asserting the
|
|
2832
|
+
function works in isolation does not catch that; this does.
|
|
2833
|
+
"""
|
|
2834
|
+
path = pending.enqueue(_payload(content="legacy"), RuntimeError("x"))
|
|
2835
|
+
legacy = path + ".dead"
|
|
2836
|
+
os.replace(path, legacy)
|
|
2837
|
+
|
|
2838
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
|
|
2839
|
+
|
|
2840
|
+
self.assertEqual(summary["dead_relocated"], 1)
|
|
2841
|
+
self.assertFalse(os.path.exists(legacy), "marker left in the live queue dir")
|
|
2842
|
+
self.assertEqual(self._dead_names(), [os.path.basename(legacy)])
|
|
2843
|
+
|
|
2844
|
+
def test_a_dry_run_relocates_nothing(self):
|
|
2845
|
+
path = pending.enqueue(_payload(content="legacy"), RuntimeError("x"))
|
|
2846
|
+
legacy = path + ".dead"
|
|
2847
|
+
os.replace(path, legacy)
|
|
2848
|
+
|
|
2849
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile", dry_run=True)
|
|
2850
|
+
|
|
2851
|
+
self.assertEqual(summary["dead_relocated"], 0)
|
|
2852
|
+
self.assertTrue(os.path.exists(legacy))
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
class ResplitOverBoundEntriesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2856
|
+
"""An entry over the retain bound is RECOVERABLE, so it must never die.
|
|
2857
|
+
|
|
2858
|
+
Such an entry needs more sequential extraction calls than fit the client
|
|
2859
|
+
deadline, so every POST is guaranteed waste; if the server rejects the
|
|
2860
|
+
body as a 4xx it is (correctly) classified permanent and the memory goes
|
|
2861
|
+
`.dead`. Splitting it is the difference between a lost memory and a slow
|
|
2862
|
+
one. Two ways one gets into a queue and both are live on this fleet: an
|
|
2863
|
+
older build enqueued it unsplit, or the BOUND MOVED under it.
|
|
2864
|
+
"""
|
|
2865
|
+
|
|
2866
|
+
def _resplit_names(self):
|
|
2867
|
+
try:
|
|
2868
|
+
return sorted(os.listdir(pending.resplit_dir()))
|
|
2869
|
+
except OSError:
|
|
2870
|
+
return []
|
|
2871
|
+
|
|
2872
|
+
def test_an_over_bound_entry_becomes_drainable_parts(self):
|
|
2873
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2874
|
+
path = pending.enqueue(_payload(content="z" * 90_000), RuntimeError("x"))
|
|
2875
|
+
self.assertEqual(pending.count(), 1, "queued unsplit under the old bound")
|
|
2876
|
+
|
|
2877
|
+
# The bound MOVES -- exactly what a deadline or safety-factor change
|
|
2878
|
+
# does. The entry is now unretainable as-is.
|
|
2879
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
2880
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
2881
|
+
|
|
2882
|
+
self.assertEqual(entries, 1)
|
|
2883
|
+
self.assertGreaterEqual(parts, 30)
|
|
2884
|
+
self.assertFalse(os.path.exists(path), "the original left the queue")
|
|
2885
|
+
self.assertEqual(len(self._resplit_names()), 1, "archived, not deleted")
|
|
2886
|
+
for _p, entry in pending.iter_entries():
|
|
2887
|
+
self.assertLessEqual(len(entry["content"]), 3000)
|
|
2888
|
+
|
|
2889
|
+
def test_no_line_of_the_memory_is_lost_across_the_resplit(self):
|
|
2890
|
+
"""Every line survives -- that is the guarantee that matters.
|
|
2891
|
+
|
|
2892
|
+
`split_retain_content` promises to lose no MESSAGE, not to be
|
|
2893
|
+
byte-exact: it splits on line boundaries and the boundary newline
|
|
2894
|
+
itself is not carried into either part (measured: 29 newlines lost
|
|
2895
|
+
over a 30-way split). That is a pre-existing property of the splitter
|
|
2896
|
+
that the LIVE enqueue path already has; this test pins the property a
|
|
2897
|
+
re-split must not go beyond -- no line of the memory may vanish.
|
|
2898
|
+
|
|
2899
|
+
Order is deliberately not asserted: the parts are independent queue
|
|
2900
|
+
entries whose filenames carry a random suffix, so `iter_entries()`
|
|
2901
|
+
returns them in filename order, not part order. Reassembly is by the
|
|
2902
|
+
part metadata, not by queue position.
|
|
2903
|
+
"""
|
|
2904
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2905
|
+
body = "".join(f"line {i}\n" for i in range(9000))
|
|
2906
|
+
pending.enqueue(_payload(content=body), RuntimeError("x"))
|
|
2907
|
+
|
|
2908
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
2909
|
+
pending.resplit_over_bound_entries()
|
|
2910
|
+
rejoined = "\n".join(e["content"] for _p, e in pending.iter_entries())
|
|
2911
|
+
surviving = [ln for ln in rejoined.split("\n") if ln]
|
|
2912
|
+
expected = [f"line {i}" for i in range(9000)]
|
|
2913
|
+
# Cheap, readable failure first: name the lines that went missing
|
|
2914
|
+
# rather than dumping a 9000-element list diff.
|
|
2915
|
+
missing = sorted(set(expected) - set(surviving))
|
|
2916
|
+
self.assertEqual(missing[:10], [], f"{len(missing)} line(s) lost in the re-split")
|
|
2917
|
+
self.assertEqual(len(surviving), len(expected), "a line was duplicated or dropped")
|
|
2918
|
+
|
|
2919
|
+
def test_the_resplit_archive_is_itself_bounded(self):
|
|
2920
|
+
"""The archived original is redundant, so it must not eat the disk.
|
|
2921
|
+
|
|
2922
|
+
It is only moved AFTER its parts are queued, so unlike
|
|
2923
|
+
`pending-dead/` (which holds the only copy of an unrecovered memory
|
|
2924
|
+
and is deliberately uncapped) this archive can be trimmed on the
|
|
2925
|
+
same terms as `pending-duplicate/`.
|
|
2926
|
+
"""
|
|
2927
|
+
prev = pending.RESPLIT_MAX_ENTRIES
|
|
2928
|
+
pending.RESPLIT_MAX_ENTRIES = 2
|
|
2929
|
+
try:
|
|
2930
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2931
|
+
for i in range(5):
|
|
2932
|
+
pending.enqueue(_payload(content=f"{i}" * 40_000), RuntimeError("x"))
|
|
2933
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
2934
|
+
with redirect_stderr(io.StringIO()):
|
|
2935
|
+
resplit, _parts = pending.resplit_over_bound_entries()
|
|
2936
|
+
self.assertEqual(resplit, 5)
|
|
2937
|
+
self.assertLessEqual(len(self._resplit_names()), 2)
|
|
2938
|
+
finally:
|
|
2939
|
+
pending.RESPLIT_MAX_ENTRIES = prev
|
|
2940
|
+
|
|
2941
|
+
def test_the_archived_original_is_still_readable(self):
|
|
2942
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2943
|
+
pending.enqueue(_payload(content="q" * 50_000), RuntimeError("x"))
|
|
2944
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
2945
|
+
pending.resplit_over_bound_entries()
|
|
2946
|
+
|
|
2947
|
+
archived = os.path.join(pending.resplit_dir(), self._resplit_names()[0])
|
|
2948
|
+
with open(archived, encoding="utf-8") as f:
|
|
2949
|
+
self.assertEqual(len(json.load(f)["content"]), 50_000)
|
|
2950
|
+
|
|
2951
|
+
def test_an_entry_within_the_bound_is_untouched(self):
|
|
2952
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2953
|
+
path = pending.enqueue(_payload(content="small"), RuntimeError("x"))
|
|
2954
|
+
self.assertEqual(pending.resplit_over_bound_entries(), (0, 0))
|
|
2955
|
+
self.assertTrue(os.path.exists(path))
|
|
2956
|
+
self.assertEqual(self._resplit_names(), [])
|
|
2957
|
+
|
|
2958
|
+
def test_resplitting_is_idempotent(self):
|
|
2959
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2960
|
+
pending.enqueue(_payload(content="w" * 40_000), RuntimeError("x"))
|
|
2961
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
2962
|
+
pending.resplit_over_bound_entries()
|
|
2963
|
+
depth = pending.count()
|
|
2964
|
+
self.assertEqual(pending.resplit_over_bound_entries(), (0, 0))
|
|
2965
|
+
self.assertEqual(pending.count(), depth, "a second pass is a no-op")
|
|
2966
|
+
|
|
2967
|
+
def test_an_entry_that_cannot_be_split_STAYS_QUEUED(self):
|
|
2968
|
+
"""No part written => the original is left exactly where it was."""
|
|
2969
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
2970
|
+
path = pending.enqueue(_payload(content="v" * 40_000), RuntimeError("x"))
|
|
2971
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
2972
|
+
|
|
2973
|
+
with unittest.mock.patch.object(pending, "_enqueue_one", return_value=None):
|
|
2974
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
2975
|
+
|
|
2976
|
+
self.assertEqual((entries, parts), (0, 0))
|
|
2977
|
+
self.assertTrue(os.path.exists(path), "never deleted on failure")
|
|
2978
|
+
self.assertEqual(self._resplit_names(), [])
|
|
2979
|
+
|
|
2980
|
+
#: The exact membership of `pending._ENTRY_ONLY_FIELDS`, spelled out
|
|
2981
|
+
#: here rather than read off the module under test.
|
|
2982
|
+
#:
|
|
2983
|
+
#: This literal is deliberate, and it is what makes the strip testable at
|
|
2984
|
+
#: all. Only THREE members are observable in the resulting parts:
|
|
2985
|
+
#: `attempt_count` (`_build_entry` uses `setdefault`), `last_attempt_at`
|
|
2986
|
+
#: and `dead_at` (`_build_entry` never writes them). The other four --
|
|
2987
|
+
#: `schema`, `failed_at`, `error_class`, `error_message` -- are assigned
|
|
2988
|
+
#: UNCONDITIONALLY by `_build_entry`, so deleting any of them from the
|
|
2989
|
+
#: frozenset changes no entry on disk and no outcome assertion can see
|
|
2990
|
+
#: it. They are in the set as defence-in-depth (see the comment on
|
|
2991
|
+
#: `_ENTRY_ONLY_FIELDS`), and defence-in-depth that is asserted against
|
|
2992
|
+
#: the module's own value is asserted against nothing.
|
|
2993
|
+
ENTRY_ONLY_FIELDS = frozenset(
|
|
2994
|
+
{
|
|
2995
|
+
"schema",
|
|
2996
|
+
"failed_at",
|
|
2997
|
+
"error_class",
|
|
2998
|
+
"error_message",
|
|
2999
|
+
"attempt_count",
|
|
3000
|
+
"last_attempt_at",
|
|
3001
|
+
"dead_at",
|
|
3002
|
+
}
|
|
3003
|
+
)
|
|
3004
|
+
|
|
3005
|
+
def test_entry_only_fields_is_every_field_build_entry_adds(self):
|
|
3006
|
+
"""Pin the membership itself; the strip's outcome cannot.
|
|
3007
|
+
|
|
3008
|
+
`_build_entry` is the only writer of the first four, and it writes
|
|
3009
|
+
them unconditionally -- so a member dropped from the frozenset is
|
|
3010
|
+
invisible in every part on disk. Asserting it here is the only
|
|
3011
|
+
thing that fails when one goes missing.
|
|
3012
|
+
"""
|
|
3013
|
+
self.assertEqual(pending._ENTRY_ONLY_FIELDS, self.ENTRY_ONLY_FIELDS)
|
|
3014
|
+
built = pending._build_entry(_payload(), RuntimeError("x"))
|
|
3015
|
+
self.assertEqual(
|
|
3016
|
+
self.ENTRY_ONLY_FIELDS - set(_payload()) - {"last_attempt_at", "dead_at"},
|
|
3017
|
+
set(built) - set(_payload()),
|
|
3018
|
+
"the set drifted from what `_build_entry` actually stamps; "
|
|
3019
|
+
"`last_attempt_at`/`dead_at` come from `update_attempt`/`mark_dead`",
|
|
3020
|
+
)
|
|
3021
|
+
|
|
3022
|
+
def test_the_parts_carry_no_stale_attempt_metadata(self):
|
|
3023
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3024
|
+
path = pending.enqueue(_payload(content="u" * 40_000), RuntimeError("x"))
|
|
3025
|
+
for _ in range(4):
|
|
3026
|
+
with open(path, encoding="utf-8") as f:
|
|
3027
|
+
entry = json.load(f)
|
|
3028
|
+
pending.update_attempt(path, entry, RuntimeError("again"))
|
|
3029
|
+
stale_stamp = "2020-01-01T00:00:00Z"
|
|
3030
|
+
with open(path, encoding="utf-8") as f:
|
|
3031
|
+
stale = json.load(f)
|
|
3032
|
+
stale["failed_at"] = stale_stamp
|
|
3033
|
+
stale["dead_at"] = stale_stamp
|
|
3034
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
3035
|
+
json.dump(stale, f)
|
|
3036
|
+
# Guard the fixture: the assertions below are vacuous unless the
|
|
3037
|
+
# original really is carrying EVERY entry-only field, since what is
|
|
3038
|
+
# under test is that all of them are stripped off the payload.
|
|
3039
|
+
self.assertEqual(stale["attempt_count"], 5)
|
|
3040
|
+
self.assertIn("last_attempt_at", stale)
|
|
3041
|
+
self.assertEqual(stale["error_class"], "RuntimeError")
|
|
3042
|
+
self.assertEqual(stale["error_message"], "again")
|
|
3043
|
+
self.assertEqual(
|
|
3044
|
+
self.ENTRY_ONLY_FIELDS - set(stale), frozenset(),
|
|
3045
|
+
"the fixture must carry every entry-only field",
|
|
3046
|
+
)
|
|
3047
|
+
|
|
3048
|
+
# The load-bearing assertion for the four fields `_build_entry`
|
|
3049
|
+
# overwrites: catch the payload ON THE WAY IN. "The entry on disk has
|
|
3050
|
+
# a fresh `failed_at`" is true whether or not `failed_at` was ever
|
|
3051
|
+
# stripped; "the dict handed to `enqueue_parts` has no `failed_at`"
|
|
3052
|
+
# is not.
|
|
3053
|
+
seen: list[dict] = []
|
|
3054
|
+
real_enqueue_parts = pending.enqueue_parts
|
|
3055
|
+
|
|
3056
|
+
def spy(payload, error, **kwargs):
|
|
3057
|
+
seen.append(dict(payload))
|
|
3058
|
+
return real_enqueue_parts(payload, error, **kwargs)
|
|
3059
|
+
|
|
3060
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3061
|
+
with unittest.mock.patch.object(pending, "enqueue_parts", spy):
|
|
3062
|
+
with redirect_stderr(io.StringIO()):
|
|
3063
|
+
pending.resplit_over_bound_entries()
|
|
3064
|
+
|
|
3065
|
+
self.assertEqual(len(seen), 1, "the over-bound entry was re-enqueued")
|
|
3066
|
+
self.assertEqual(
|
|
3067
|
+
sorted(set(seen[0]) & self.ENTRY_ONLY_FIELDS), [],
|
|
3068
|
+
"`enqueue()` takes a PAYLOAD, not an entry read off disk -- every "
|
|
3069
|
+
"entry-only field must be stripped before it goes back in",
|
|
3070
|
+
)
|
|
3071
|
+
self.assertEqual(
|
|
3072
|
+
seen[0]["content"], "u" * 40_000,
|
|
3073
|
+
"and the payload still carries the memory it is stripping around",
|
|
3074
|
+
)
|
|
3075
|
+
|
|
3076
|
+
entries = pending.iter_entries()
|
|
3077
|
+
self.assertGreaterEqual(len(entries), 13, "the entry really was split")
|
|
3078
|
+
for _p, entry in entries:
|
|
3079
|
+
# These three are the ones the strip alone decides: `_build_entry`
|
|
3080
|
+
# `setdefault`s `attempt_count` and never writes the other two.
|
|
3081
|
+
self.assertEqual(
|
|
3082
|
+
entry["attempt_count"], 1,
|
|
3083
|
+
"a re-split part starts its own budget -- inheriting an "
|
|
3084
|
+
"exhausted one would send it straight back toward .dead",
|
|
3085
|
+
)
|
|
3086
|
+
self.assertNotIn(
|
|
3087
|
+
"dead_at", entry,
|
|
3088
|
+
"a fresh part is not born already dead",
|
|
3089
|
+
)
|
|
3090
|
+
# `update_attempt` stamps `last_attempt_at`, so it is entry-only
|
|
3091
|
+
# metadata too. Round-tripping it pairs a timestamp from the old
|
|
3092
|
+
# entry's last retry with a fresh `attempt_count: 1` -- an entry
|
|
3093
|
+
# that claims it was last attempted before it was ever queued.
|
|
3094
|
+
self.assertNotIn(
|
|
3095
|
+
"last_attempt_at", entry,
|
|
3096
|
+
"a fresh part has never been attempted",
|
|
3097
|
+
)
|
|
3098
|
+
# The rest are `_build_entry` guarantees, not strip guarantees:
|
|
3099
|
+
# they hold even if the field were left on the payload. Kept as
|
|
3100
|
+
# a check on `_build_entry`, labelled so no one reads them as
|
|
3101
|
+
# coverage of `_ENTRY_ONLY_FIELDS`.
|
|
3102
|
+
self.assertEqual(entry["error_class"], "RuntimeError")
|
|
3103
|
+
self.assertIn("re-split", entry["error_message"])
|
|
3104
|
+
self.assertNotEqual(entry["failed_at"], stale_stamp)
|
|
3105
|
+
self.assertEqual(entry["schema"], pending.SCHEMA)
|
|
3106
|
+
|
|
3107
|
+
# --- The archive is only earned by a NEW file -------------------------
|
|
3108
|
+
#
|
|
3109
|
+
# `_enqueue_one` returns the path of an ALREADY-QUEUED identical entry on
|
|
3110
|
+
# a dedupe hit, so "enqueue handed back a path" is not "a part was
|
|
3111
|
+
# written". Both cases below are the same bug from two angles: a re-split
|
|
3112
|
+
# that wrote nothing must not archive the original, because the archive
|
|
3113
|
+
# is capped (`_trim_dir`) and the original is then the only copy.
|
|
3114
|
+
|
|
3115
|
+
def test_a_resplit_that_dedupes_onto_its_own_original_STAYS_QUEUED(self):
|
|
3116
|
+
"""The real repro, no patching: ONE part, so `enqueue` re-enqueues
|
|
3117
|
+
the payload unchanged, the dupe key matches the entry being re-split,
|
|
3118
|
+
and the "written" path IS the original.
|
|
3119
|
+
|
|
3120
|
+
A JSON transcript is re-serialised compactly by the splitter, so a
|
|
3121
|
+
pretty-printed one over the bound can compact to a single part under
|
|
3122
|
+
it. That is not exotic -- it is any indented transcript whose
|
|
3123
|
+
whitespace is most of its size.
|
|
3124
|
+
"""
|
|
3125
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3126
|
+
blocks = [{"type": "text", "text": f"line {i}"} for i in range(60)]
|
|
3127
|
+
content = json.dumps([{"role": "user", "content": blocks}], indent=8)
|
|
3128
|
+
path = pending.enqueue(_payload(content=content), RuntimeError("x"))
|
|
3129
|
+
self.assertEqual(pending.count(), 1)
|
|
3130
|
+
|
|
3131
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3132
|
+
self.assertGreater(len(content), 3000, "the entry is over the bound")
|
|
3133
|
+
self.assertEqual(
|
|
3134
|
+
len(retain_split.split_retain_content(content)), 1,
|
|
3135
|
+
"the splitter yields a SINGLE part -- the condition under test",
|
|
3136
|
+
)
|
|
3137
|
+
|
|
3138
|
+
with redirect_stderr(io.StringIO()):
|
|
3139
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3140
|
+
|
|
3141
|
+
self.assertEqual((entries, parts), (0, 0))
|
|
3142
|
+
self.assertTrue(os.path.exists(path), "the ONLY copy was archived away")
|
|
3143
|
+
self.assertEqual(pending.count(), 1)
|
|
3144
|
+
self.assertEqual(self._resplit_names(), [], "nothing was earned")
|
|
3145
|
+
|
|
3146
|
+
def test_a_deduped_part_is_not_counted_as_a_written_part(self):
|
|
3147
|
+
"""`_enqueue_one` returns an EXISTING path and writes no new file.
|
|
3148
|
+
|
|
3149
|
+
Distinct from `test_an_entry_that_cannot_be_split_STAYS_QUEUED`,
|
|
3150
|
+
which returns None and so makes "no path came back" and "no file
|
|
3151
|
+
appeared" true at once -- it cannot tell the two guards apart.
|
|
3152
|
+
"""
|
|
3153
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3154
|
+
path = pending.enqueue(_payload(content="d" * 40_000), RuntimeError("x"))
|
|
3155
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3156
|
+
|
|
3157
|
+
# Non-None, already on disk, and no new file is created.
|
|
3158
|
+
with unittest.mock.patch.object(pending, "_enqueue_one", return_value=path):
|
|
3159
|
+
with redirect_stderr(io.StringIO()):
|
|
3160
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3161
|
+
|
|
3162
|
+
self.assertEqual((entries, parts), (0, 0))
|
|
3163
|
+
self.assertTrue(os.path.exists(path), "never deleted on a no-op split")
|
|
3164
|
+
self.assertEqual(pending.count(), 1)
|
|
3165
|
+
self.assertEqual(self._resplit_names(), [])
|
|
3166
|
+
|
|
3167
|
+
def test_the_part_count_is_not_a_queue_depth_delta(self):
|
|
3168
|
+
"""Parts WRITTEN, not queue growth.
|
|
3169
|
+
|
|
3170
|
+
When the queue is near its cap, `_evict_to_fit` sheds an older entry
|
|
3171
|
+
for each part it makes room for, so the depth barely moves while N
|
|
3172
|
+
parts are genuinely written. A depth delta under-reports that (and at
|
|
3173
|
+
the extreme reads 0, which is the "stays queued" signal) -- so the
|
|
3174
|
+
count is taken from what `_enqueue_one` handed back.
|
|
3175
|
+
"""
|
|
3176
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3177
|
+
for i in range(10):
|
|
3178
|
+
pending.enqueue(
|
|
3179
|
+
_payload(content=f"filler-{i}", doc=_cd_doc(i + 50)),
|
|
3180
|
+
RuntimeError("x"),
|
|
3181
|
+
)
|
|
3182
|
+
path = pending.enqueue(_payload(content="y" * 40_000), RuntimeError("x"))
|
|
3183
|
+
self.assertEqual(pending.count(), 11)
|
|
3184
|
+
|
|
3185
|
+
# 11 queued + 14 parts does not fit, so writing the parts evicts.
|
|
3186
|
+
pending.MAX_ENTRIES = 20
|
|
3187
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3188
|
+
with redirect_stderr(io.StringIO()):
|
|
3189
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3190
|
+
|
|
3191
|
+
self.assertEqual(entries, 1)
|
|
3192
|
+
# 20 at the cap, less the original once it is archived out.
|
|
3193
|
+
self.assertEqual(pending.count(), 19, "the cap held, so eviction fired")
|
|
3194
|
+
self.assertLess(
|
|
3195
|
+
19 - 11, 14, "the depth grew by less than the parts written"
|
|
3196
|
+
)
|
|
3197
|
+
self.assertEqual(
|
|
3198
|
+
parts, 14,
|
|
3199
|
+
"every part written is counted, even the ones whose room was "
|
|
3200
|
+
"made by evicting an older entry",
|
|
3201
|
+
)
|
|
3202
|
+
self.assertFalse(os.path.exists(path), "the original was archived")
|
|
3203
|
+
|
|
3204
|
+
# --- A refusal the caller absorbs is not a permanent loss -------------
|
|
3205
|
+
|
|
3206
|
+
def _refused_resplit_leaves_the_ledger_clean(self, cap: str, value: int):
|
|
3207
|
+
"""`record_drop` means the memory is GONE; here it is still queued.
|
|
3208
|
+
|
|
3209
|
+
`switchroom doctor` FAILS on a non-zero drop ledger, and phase 0c
|
|
3210
|
+
retries on every backlog drain (10 min on this fleet), so stamping a
|
|
3211
|
+
refusal turns a safely-queued memory into a permanent, monotonically
|
|
3212
|
+
climbing doctor failure.
|
|
3213
|
+
"""
|
|
3214
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3215
|
+
path = pending.enqueue(_payload(content="r" * 40_000), RuntimeError("x"))
|
|
3216
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3217
|
+
setattr(pending, cap, value)
|
|
3218
|
+
|
|
3219
|
+
with redirect_stderr(io.StringIO()):
|
|
3220
|
+
for _ in range(3): # every drain retries the same entry
|
|
3221
|
+
self.assertEqual(pending.resplit_over_bound_entries(), (0, 0))
|
|
3222
|
+
|
|
3223
|
+
self.assertEqual(
|
|
3224
|
+
pending.read_drops(), {},
|
|
3225
|
+
"nothing was lost -- the original is still queued and retryable",
|
|
3226
|
+
)
|
|
3227
|
+
self.assertTrue(os.path.exists(path))
|
|
3228
|
+
self.assertEqual(self._resplit_names(), [])
|
|
3229
|
+
|
|
3230
|
+
def test_a_resplit_refused_on_the_entry_cap_stamps_no_drop(self):
|
|
3231
|
+
self._refused_resplit_leaves_the_ledger_clean("MAX_ENTRIES", 2)
|
|
3232
|
+
|
|
3233
|
+
def test_a_resplit_refused_on_the_byte_cap_stamps_no_drop(self):
|
|
3234
|
+
self._refused_resplit_leaves_the_ledger_clean("MAX_BYTES", 4096)
|
|
3235
|
+
|
|
3236
|
+
def test_the_producer_path_still_stamps_a_refused_memory_as_dropped(self):
|
|
3237
|
+
"""The suppression is scoped to the re-split caller, not global.
|
|
3238
|
+
|
|
3239
|
+
`session_end` / `retain` have no other copy of the memory, so when
|
|
3240
|
+
`enqueue` refuses one the turn really is lost and the ledger is the
|
|
3241
|
+
only record of it.
|
|
3242
|
+
"""
|
|
3243
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3244
|
+
pending.MAX_BYTES = 4096 # smaller than the split memory as a whole
|
|
3245
|
+
with redirect_stderr(io.StringIO()):
|
|
3246
|
+
self.assertIsNone(
|
|
3247
|
+
pending.enqueue(_payload(content="e" * 40_000), RuntimeError("x"))
|
|
3248
|
+
)
|
|
3249
|
+
self.assertEqual(pending.read_drops().get("count"), 1)
|
|
3250
|
+
self.assertEqual(pending.count(), 0)
|
|
3251
|
+
|
|
3252
|
+
# --- ... and neither is a PER-PART failure the caller absorbs ---------
|
|
3253
|
+
#
|
|
3254
|
+
# The cap refusals above are the whole-memory door. `_enqueue_one` has a
|
|
3255
|
+
# second one: its own MAX_BYTES guard and the post-eviction write failure
|
|
3256
|
+
# both `record_drop`. When EVERY part fails there, `enqueue_parts` hands
|
|
3257
|
+
# back nothing, `written == 0`, and the original correctly STAYS QUEUED --
|
|
3258
|
+
# so those drops describe a memory that was not lost, on an entry phase 0c
|
|
3259
|
+
# retries every backlog drain (10 min on this fleet), climbing forever and
|
|
3260
|
+
# never resetting once the disk is fixed. Same failure mode, same fix.
|
|
3261
|
+
|
|
3262
|
+
@staticmethod
|
|
3263
|
+
def _enospc_on_every_rename():
|
|
3264
|
+
"""A full disk, at the exact syscall `_enqueue_one` fails on."""
|
|
3265
|
+
return unittest.mock.patch.object(
|
|
3266
|
+
pending.os, "rename",
|
|
3267
|
+
side_effect=OSError(errno.ENOSPC, "No space left on device"),
|
|
3268
|
+
)
|
|
3269
|
+
|
|
3270
|
+
def test_a_resplit_whose_every_part_fails_to_write_stamps_no_drop(self):
|
|
3271
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3272
|
+
path = pending.enqueue(_payload(content="p" * 40_000), RuntimeError("x"))
|
|
3273
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3274
|
+
|
|
3275
|
+
with self._enospc_on_every_rename():
|
|
3276
|
+
with redirect_stderr(io.StringIO()):
|
|
3277
|
+
for _ in range(3): # every backlog drain retries this entry
|
|
3278
|
+
self.assertEqual(pending.resplit_over_bound_entries(), (0, 0))
|
|
3279
|
+
|
|
3280
|
+
self.assertEqual(
|
|
3281
|
+
pending.read_drops(), {},
|
|
3282
|
+
"nothing was lost -- not one part was written, so the original "
|
|
3283
|
+
"is still queued and the next drain retries the whole re-split",
|
|
3284
|
+
)
|
|
3285
|
+
self.assertTrue(os.path.exists(path), "the original never left")
|
|
3286
|
+
self.assertEqual(pending.count(), 1)
|
|
3287
|
+
self.assertEqual(self._resplit_names(), [])
|
|
3288
|
+
|
|
3289
|
+
def test_a_part_that_fails_is_stamped_once_the_original_is_retired(self):
|
|
3290
|
+
"""The deferral is not a suppression.
|
|
3291
|
+
|
|
3292
|
+
One part of fourteen hits ENOSPC, thirteen land, so the original IS
|
|
3293
|
+
archived -- nothing will ever retry part five again. That IS the
|
|
3294
|
+
permanent loss `record_drop` exists to make visible.
|
|
3295
|
+
"""
|
|
3296
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3297
|
+
path = pending.enqueue(_payload(content="q" * 40_000), RuntimeError("x"))
|
|
3298
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3299
|
+
|
|
3300
|
+
real_rename = os.rename
|
|
3301
|
+
seen = {"n": 0}
|
|
3302
|
+
|
|
3303
|
+
def flaky(src, dst):
|
|
3304
|
+
seen["n"] += 1
|
|
3305
|
+
if seen["n"] == 5: # part five of fourteen
|
|
3306
|
+
raise OSError(errno.ENOSPC, "No space left on device")
|
|
3307
|
+
return real_rename(src, dst)
|
|
3308
|
+
|
|
3309
|
+
with unittest.mock.patch.object(pending.os, "rename", flaky):
|
|
3310
|
+
with redirect_stderr(io.StringIO()):
|
|
3311
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3312
|
+
|
|
3313
|
+
self.assertEqual((entries, parts), (1, 13), "thirteen of fourteen")
|
|
3314
|
+
self.assertFalse(os.path.exists(path), "the original was archived")
|
|
3315
|
+
self.assertEqual(
|
|
3316
|
+
pending.read_drops().get("count"), 1,
|
|
3317
|
+
"the part that never landed is gone for good and must be visible",
|
|
3318
|
+
)
|
|
3319
|
+
self.assertEqual(
|
|
3320
|
+
pending.read_drops().get("last_error_class"), "OSError",
|
|
3321
|
+
)
|
|
3322
|
+
|
|
3323
|
+
def test_the_producer_path_still_stamps_a_failed_part_as_dropped(self):
|
|
3324
|
+
"""The per-part deferral is scoped to the re-split caller too.
|
|
3325
|
+
|
|
3326
|
+
`session_end` / `retain` hold no other copy, so every part that
|
|
3327
|
+
cannot be written really is a lost slice of the turn.
|
|
3328
|
+
"""
|
|
3329
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3330
|
+
with self._enospc_on_every_rename():
|
|
3331
|
+
with redirect_stderr(io.StringIO()):
|
|
3332
|
+
self.assertIsNone(
|
|
3333
|
+
pending.enqueue(_payload(content="f" * 40_000), RuntimeError("x"))
|
|
3334
|
+
)
|
|
3335
|
+
self.assertEqual(pending.count(), 0, "not one part landed")
|
|
3336
|
+
self.assertGreaterEqual(
|
|
3337
|
+
pending.read_drops().get("count", 0), 14,
|
|
3338
|
+
"one drop per part the producer could not write",
|
|
3339
|
+
)
|
|
3340
|
+
|
|
3341
|
+
# --- An original evicted out from under its own re-split --------------
|
|
3342
|
+
|
|
3343
|
+
@staticmethod
|
|
3344
|
+
def _clock_frozen_at(when: float):
|
|
3345
|
+
"""Freeze the millisecond `_enqueue_one` stamps into entry names.
|
|
3346
|
+
|
|
3347
|
+
Entry names are `<unix-ms>-<dupe-key>-<uuid>.json` and FIFO order is
|
|
3348
|
+
their lexicographic sort, so entries written inside ONE millisecond
|
|
3349
|
+
tie-break on the CONTENT-DERIVED dupe key -- stable and total, but
|
|
3350
|
+
arbitrary. Production never hits that on the re-split path: the
|
|
3351
|
+
original was queued by an EARLIER process, milliseconds to days
|
|
3352
|
+
before its parts. A test that enqueues both inside one tick does hit
|
|
3353
|
+
it, and then `_evict_to_fit` sheds whichever name happens to sort
|
|
3354
|
+
first -- measured 6 failures in 40 isolated runs of the case below
|
|
3355
|
+
before this pin. So pin the age relationship the product guarantees
|
|
3356
|
+
instead of racing the clock for it.
|
|
3357
|
+
"""
|
|
3358
|
+
return unittest.mock.patch.object(pending.time, "time", lambda: when)
|
|
3359
|
+
|
|
3360
|
+
def _nth_part_write_fails(self, n: int):
|
|
3361
|
+
"""ENOSPC on the `n`-th part write, and on nothing else.
|
|
3362
|
+
|
|
3363
|
+
Counting every `os.rename` would be wrong: `shutil.move` renames too
|
|
3364
|
+
when source and destination share a filesystem, so an eviction inside
|
|
3365
|
+
the same loop shifts the count. Only the tmp -> `<entry>.json` rename
|
|
3366
|
+
inside the LIVE QUEUE dir is a part landing.
|
|
3367
|
+
"""
|
|
3368
|
+
real_rename = os.rename
|
|
3369
|
+
seen = {"n": 0}
|
|
3370
|
+
|
|
3371
|
+
def flaky(src, dst):
|
|
3372
|
+
landing = str(dst).endswith(".json") and os.path.dirname(
|
|
3373
|
+
os.path.abspath(str(dst))
|
|
3374
|
+
) == os.path.abspath(self._dir)
|
|
3375
|
+
if landing:
|
|
3376
|
+
seen["n"] += 1
|
|
3377
|
+
if seen["n"] == n:
|
|
3378
|
+
raise OSError(errno.ENOSPC, "No space left on device")
|
|
3379
|
+
return real_rename(src, dst)
|
|
3380
|
+
|
|
3381
|
+
return unittest.mock.patch.object(pending.os, "rename", flaky)
|
|
3382
|
+
|
|
3383
|
+
def _an_original_older_than_its_parts(self, content: str) -> str:
|
|
3384
|
+
"""Enqueue `content` stamped an hour ago; return its queue path."""
|
|
3385
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3386
|
+
with self._clock_frozen_at(time.time() - 3600):
|
|
3387
|
+
path = pending.enqueue(_payload(content=content), RuntimeError("x"))
|
|
3388
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3389
|
+
return path
|
|
3390
|
+
|
|
3391
|
+
def test_a_resplit_that_evicts_its_own_original_says_so(self):
|
|
3392
|
+
"""The parts fill the queue and FIFO-shed the entry being re-split.
|
|
3393
|
+
|
|
3394
|
+
`total > MAX_ENTRIES` is false at exactly 14 parts and a cap of 14,
|
|
3395
|
+
so there is no refusal: part fourteen calls `_evict_to_fit`, which
|
|
3396
|
+
sheds the OLDEST entry -- the original. `shutil.move` then raises
|
|
3397
|
+
`FileNotFoundError`. Nothing is lost (fourteen parts plus the evicted
|
|
3398
|
+
copy), but the old line told the operator the original "STAYS QUEUED"
|
|
3399
|
+
when it is in `pending-evicted/`, and returned (0, 0) after writing
|
|
3400
|
+
fourteen parts.
|
|
3401
|
+
"""
|
|
3402
|
+
path = self._an_original_older_than_its_parts("z" * 40_000)
|
|
3403
|
+
name = os.path.basename(path)
|
|
3404
|
+
pending.MAX_ENTRIES = 14
|
|
3405
|
+
|
|
3406
|
+
err = io.StringIO()
|
|
3407
|
+
with redirect_stderr(err):
|
|
3408
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3409
|
+
|
|
3410
|
+
self.assertEqual(len(retain_split.split_retain_content("z" * 40_000)), 14)
|
|
3411
|
+
self.assertFalse(os.path.exists(path), "the original was evicted")
|
|
3412
|
+
self.assertIn(name, self._archive_names(), "...into pending-evicted/")
|
|
3413
|
+
self.assertEqual(self._resplit_names(), [], "it was never archived")
|
|
3414
|
+
self.assertEqual(pending.count(), 14, "fourteen parts, at the cap")
|
|
3415
|
+
|
|
3416
|
+
log = err.getvalue()
|
|
3417
|
+
self.assertNotIn(
|
|
3418
|
+
"STAYS QUEUED", log,
|
|
3419
|
+
"it is NOT queued -- that is the line an operator reads under "
|
|
3420
|
+
"exactly this pressure",
|
|
3421
|
+
)
|
|
3422
|
+
self.assertIn(pending.evicted_dir(), log, "say where it actually is")
|
|
3423
|
+
|
|
3424
|
+
def test_the_evicted_branch_counts_the_parts_it_actually_wrote(self):
|
|
3425
|
+
"""The return tuple alone, pinned on its own.
|
|
3426
|
+
|
|
3427
|
+
This is the assertion that keeps the `continue` off the evicted
|
|
3428
|
+
branch: with it restored the drain writes fourteen parts and reports
|
|
3429
|
+
(0, 0), so phase 0c's `_blog` line and `summary["resplit"]` both go
|
|
3430
|
+
silent on a re-split that happened. It is deliberately free of log
|
|
3431
|
+
wording and of every archive-location assertion, so no rewording of
|
|
3432
|
+
the operator line can retire the guard by accident.
|
|
3433
|
+
"""
|
|
3434
|
+
self._an_original_older_than_its_parts("z" * 40_000)
|
|
3435
|
+
pending.MAX_ENTRIES = 14
|
|
3436
|
+
|
|
3437
|
+
with redirect_stderr(io.StringIO()):
|
|
3438
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3439
|
+
|
|
3440
|
+
self.assertEqual(
|
|
3441
|
+
(entries, parts), (1, 14),
|
|
3442
|
+
"fourteen parts were genuinely written and are queued, so phase "
|
|
3443
|
+
"0c's summary and `_blog` line must report them",
|
|
3444
|
+
)
|
|
3445
|
+
self.assertEqual(pending.count(), 14, "and all fourteen really are live")
|
|
3446
|
+
|
|
3447
|
+
def test_a_part_shed_to_make_room_is_not_counted_as_queued(self):
|
|
3448
|
+
"""The other side of the eviction race, and an accounting lie.
|
|
3449
|
+
|
|
3450
|
+
FIFO sheds the OLDEST name, which is the original only while the
|
|
3451
|
+
original IS the oldest. Freeze the parts into an older millisecond
|
|
3452
|
+
and part fourteen evicts a PART instead: `shutil.move` then SUCCEEDS,
|
|
3453
|
+
the original is archived under `pending-resplit/`, and the drain used
|
|
3454
|
+
to report "into 14 queued part(s)" with thirteen queued and one
|
|
3455
|
+
sitting in `pending-evicted/`. Nothing re-drains that directory (see
|
|
3456
|
+
`drain_pending`, `probe.ts`, `doctor.ts` -- all of them only count or
|
|
3457
|
+
trim it), so the shed part is a slice of the memory that will not
|
|
3458
|
+
reach the bank, and the operator line must not paper over it.
|
|
3459
|
+
"""
|
|
3460
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3461
|
+
path = pending.enqueue(_payload(content="y" * 40_000), RuntimeError("x"))
|
|
3462
|
+
name = os.path.basename(path)
|
|
3463
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3464
|
+
pending.MAX_ENTRIES = 14
|
|
3465
|
+
|
|
3466
|
+
err = io.StringIO()
|
|
3467
|
+
# The parts land an hour "before" the original, so the oldest name in
|
|
3468
|
+
# the queue when part fourteen needs room is a part, not the original.
|
|
3469
|
+
with self._clock_frozen_at(time.time() - 3600):
|
|
3470
|
+
with redirect_stderr(err):
|
|
3471
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3472
|
+
|
|
3473
|
+
self.assertEqual(pending.count(), 13, "thirteen parts survived")
|
|
3474
|
+
self.assertEqual(
|
|
3475
|
+
self._resplit_names(), [name], "the original WAS archived here"
|
|
3476
|
+
)
|
|
3477
|
+
shed = self._archive_names()
|
|
3478
|
+
self.assertEqual(len(shed), 1, "one part was shed to make room")
|
|
3479
|
+
self.assertNotIn(name, shed, "and it was a part, not the original")
|
|
3480
|
+
|
|
3481
|
+
self.assertEqual(
|
|
3482
|
+
(entries, parts), (1, 13),
|
|
3483
|
+
"a part in pending-evicted/ is not a queued part -- nothing "
|
|
3484
|
+
"re-drains that directory",
|
|
3485
|
+
)
|
|
3486
|
+
log = err.getvalue()
|
|
3487
|
+
self.assertIn("into 13 queued part(s)", log)
|
|
3488
|
+
self.assertNotIn("into 14 queued part(s)", log)
|
|
3489
|
+
# The eviction itself already logs "evicted OLDEST ... into <dir>", so
|
|
3490
|
+
# asserting on `evicted_dir()` alone here passes with no re-split line
|
|
3491
|
+
# at all -- vacuous. Pin the re-split's OWN line: the operator reading
|
|
3492
|
+
# "the original is archived" has to be told, in the same breath, that
|
|
3493
|
+
# a part went the other way.
|
|
3494
|
+
self.assertRegex(
|
|
3495
|
+
log,
|
|
3496
|
+
r"re-split \S+ wrote 14 part\(s\).*13 part\(s\) of this memory "
|
|
3497
|
+
r"are live",
|
|
3498
|
+
"the re-split must reconcile 14 written against 13 live",
|
|
3499
|
+
)
|
|
3500
|
+
self.assertIn(
|
|
3501
|
+
f"FIFO-evicted into {pending.evicted_dir()}", log,
|
|
3502
|
+
"and say where the shed part went",
|
|
3503
|
+
)
|
|
3504
|
+
self.assertIn(shed[0], log, "named, so it can be found")
|
|
3505
|
+
|
|
3506
|
+
def _resplit_with_the_last_part_failing(self):
|
|
3507
|
+
"""Original evicted AND one part unwritten, the two together.
|
|
3508
|
+
|
|
3509
|
+
Part fourteen is the one that triggers `_evict_to_fit` (the queue is
|
|
3510
|
+
at its cap of fourteen), so failing THAT part's write is the only
|
|
3511
|
+
arrangement where the original leaves the queue and a part is
|
|
3512
|
+
genuinely lost in the same drain. Returns `(entries, parts, log)`.
|
|
3513
|
+
"""
|
|
3514
|
+
path = self._an_original_older_than_its_parts("w" * 40_000)
|
|
3515
|
+
pending.MAX_ENTRIES = 14
|
|
3516
|
+
|
|
3517
|
+
err = io.StringIO()
|
|
3518
|
+
with self._nth_part_write_fails(14):
|
|
3519
|
+
with redirect_stderr(err):
|
|
3520
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3521
|
+
|
|
3522
|
+
self.assertFalse(os.path.exists(path), "the original left the queue")
|
|
3523
|
+
self.assertIn(
|
|
3524
|
+
os.path.basename(path), self._archive_names(),
|
|
3525
|
+
"by eviction -- pending-resplit/ never got it",
|
|
3526
|
+
)
|
|
3527
|
+
self.assertEqual(self._resplit_names(), [])
|
|
3528
|
+
self.assertEqual((entries, parts), (1, 13), "thirteen of fourteen")
|
|
3529
|
+
return entries, parts, err.getvalue()
|
|
3530
|
+
|
|
3531
|
+
def test_a_part_that_fails_is_stamped_when_the_original_is_EVICTED(self):
|
|
3532
|
+
"""The archived branch is not the only one that retires the original.
|
|
3533
|
+
|
|
3534
|
+
`record_deferred_drops` is reached on BOTH branches below the move,
|
|
3535
|
+
and only the archived one was covered: gating the stamp on the
|
|
3536
|
+
original having reached `pending-resplit/` left this drain silent
|
|
3537
|
+
about part fourteen, which nothing will ever retry -- the original is
|
|
3538
|
+
in `pending-evicted/` and no consumer re-drains that directory.
|
|
3539
|
+
"""
|
|
3540
|
+
self._resplit_with_the_last_part_failing()
|
|
3541
|
+
|
|
3542
|
+
self.assertEqual(
|
|
3543
|
+
pending.read_drops().get("count"), 1,
|
|
3544
|
+
"the part that never landed is gone for good and must be visible",
|
|
3545
|
+
)
|
|
3546
|
+
self.assertEqual(pending.read_drops().get("last_error_class"), "OSError")
|
|
3547
|
+
|
|
3548
|
+
def test_the_evicted_branch_does_not_reassure_when_a_part_was_lost(self):
|
|
3549
|
+
"""Two contradictory lines in one drain is a log-lie, not a nuance.
|
|
3550
|
+
|
|
3551
|
+
The evicted branch printed "(the parts carry the whole memory)"
|
|
3552
|
+
unconditionally and `record_deferred_drops` then printed "permanently
|
|
3553
|
+
lost" for the same entry, in the same drain. A non-empty `deferred`
|
|
3554
|
+
is EXACTLY the case where the parts do not carry the whole memory.
|
|
3555
|
+
"""
|
|
3556
|
+
_entries, _parts, log = self._resplit_with_the_last_part_failing()
|
|
3557
|
+
|
|
3558
|
+
self.assertNotIn(
|
|
3559
|
+
"carry the whole memory", log,
|
|
3560
|
+
"one part never got written, so they demonstrably do not",
|
|
3561
|
+
)
|
|
3562
|
+
self.assertIn("permanently lost", log, "and the loss is still said")
|
|
3563
|
+
self.assertIn(pending.evicted_dir(), log, "with the original's location")
|
|
3564
|
+
|
|
3565
|
+
def test_a_concurrent_reconcile_is_not_reported_as_an_eviction(self):
|
|
3566
|
+
""""Gone from the queue" is resolved, not assumed.
|
|
3567
|
+
|
|
3568
|
+
Nothing serialises this drain against an in-hook `drain()` --
|
|
3569
|
+
`lib/pacing.py`'s `retain-inflight.lock` is a per-POST storm guard and
|
|
3570
|
+
`drain_pending` takes no lock -- so the original can be RECONCILED out
|
|
3571
|
+
from under the archive move. Attributing that to eviction told the
|
|
3572
|
+
operator the memory was shed when it had just been confirmed durable
|
|
3573
|
+
upstream, and stamped a permanent DROP for a part of a memory that
|
|
3574
|
+
reached the bank whole.
|
|
3575
|
+
"""
|
|
3576
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3577
|
+
path = pending.enqueue(_payload(content="v" * 40_000), RuntimeError("x"))
|
|
3578
|
+
name = os.path.basename(path)
|
|
3579
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3580
|
+
|
|
3581
|
+
real_move = shutil.move
|
|
3582
|
+
|
|
3583
|
+
def reconcile_first(src, dst):
|
|
3584
|
+
"""The other process wins the race, between two syscalls here."""
|
|
3585
|
+
if os.path.abspath(src) == os.path.abspath(path):
|
|
3586
|
+
# Exactly what `archive_reconciled` does in the other
|
|
3587
|
+
# process, minus the recursion back through this patch.
|
|
3588
|
+
os.makedirs(pending.reconciled_dir(), mode=0o700, exist_ok=True)
|
|
3589
|
+
real_move(src, os.path.join(pending.reconciled_dir(), name))
|
|
3590
|
+
raise OSError(errno.ENOENT, "No such file or directory")
|
|
3591
|
+
return real_move(src, dst)
|
|
3592
|
+
|
|
3593
|
+
err = io.StringIO()
|
|
3594
|
+
with self._nth_part_write_fails(5): # part five never lands
|
|
3595
|
+
with unittest.mock.patch.object(pending.shutil, "move", reconcile_first):
|
|
3596
|
+
with redirect_stderr(err):
|
|
3597
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3598
|
+
|
|
3599
|
+
self.assertEqual(self._reconciled_names(), [name], "that is where it went")
|
|
3600
|
+
self.assertEqual(self._archive_names(), [], "it was never evicted")
|
|
3601
|
+
self.assertEqual((entries, parts), (1, 13))
|
|
3602
|
+
|
|
3603
|
+
log = err.getvalue()
|
|
3604
|
+
self.assertIn(pending.reconciled_dir(), log, "say where it actually is")
|
|
3605
|
+
self.assertNotIn("it was itself evicted", log)
|
|
3606
|
+
self.assertEqual(
|
|
3607
|
+
pending.read_drops(), {},
|
|
3608
|
+
"the whole memory is durable upstream, so the part that never "
|
|
3609
|
+
"got written is a redundant copy, not a lost turn",
|
|
3610
|
+
)
|
|
3611
|
+
|
|
3612
|
+
def test_content_exactly_at_the_bound_is_not_re_split(self):
|
|
3613
|
+
"""`<= limit` is the bound, and the equal case is the whole point.
|
|
3614
|
+
|
|
3615
|
+
`split_retain_content` passes content of exactly `limit` chars
|
|
3616
|
+
through unsplit, so re-splitting it here is pure churn: the payload
|
|
3617
|
+
dedupes straight back onto the original and the drain prints "could
|
|
3618
|
+
not re-split" about an entry that never needed splitting. At `<`
|
|
3619
|
+
every drain does that for every at-the-bound entry.
|
|
3620
|
+
"""
|
|
3621
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3622
|
+
path = pending.enqueue(_payload(content="b" * 3000), RuntimeError("x"))
|
|
3623
|
+
self.assertEqual(pending.count(), 1, "it was queued whole, not split")
|
|
3624
|
+
|
|
3625
|
+
err = io.StringIO()
|
|
3626
|
+
with redirect_stderr(err):
|
|
3627
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3628
|
+
|
|
3629
|
+
self.assertEqual((entries, parts), (0, 0))
|
|
3630
|
+
self.assertTrue(os.path.exists(path), "untouched")
|
|
3631
|
+
self.assertEqual(
|
|
3632
|
+
err.getvalue(), "",
|
|
3633
|
+
"an entry AT the bound is not even considered -- one char over "
|
|
3634
|
+
"is over, one char at is not",
|
|
3635
|
+
)
|
|
3636
|
+
|
|
3637
|
+
def test_an_archive_failure_that_leaves_the_original_queued_still_says_so(self):
|
|
3638
|
+
"""The other branch: the move fails but the entry is still there."""
|
|
3639
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3640
|
+
path = pending.enqueue(_payload(content="a" * 40_000), RuntimeError("x"))
|
|
3641
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3642
|
+
|
|
3643
|
+
err = io.StringIO()
|
|
3644
|
+
with unittest.mock.patch.object(
|
|
3645
|
+
pending.shutil, "move",
|
|
3646
|
+
side_effect=OSError(errno.EACCES, "Permission denied"),
|
|
3647
|
+
):
|
|
3648
|
+
with redirect_stderr(err):
|
|
3649
|
+
entries, parts = pending.resplit_over_bound_entries()
|
|
3650
|
+
|
|
3651
|
+
self.assertEqual((entries, parts), (0, 0), "the original is still queued")
|
|
3652
|
+
self.assertTrue(os.path.exists(path))
|
|
3653
|
+
self.assertIn("STAYS QUEUED", err.getvalue())
|
|
3654
|
+
|
|
3655
|
+
# --- The (first, paths) contract at total <= 1 ------------------------
|
|
3656
|
+
|
|
3657
|
+
def test_a_single_part_enqueue_returns_the_path_it_wrote(self):
|
|
3658
|
+
"""`enqueue_parts` exists so a caller holding a copy can decide when
|
|
3659
|
+
to retire it. That decision reads the LIST, so the list must carry
|
|
3660
|
+
the single-part write too -- returning `(path, [])` would tell every
|
|
3661
|
+
such caller "nothing was queued" for the commonest payload there is.
|
|
3662
|
+
"""
|
|
3663
|
+
first, paths = pending.enqueue_parts(
|
|
3664
|
+
_payload(content="under the bound"), RuntimeError("x")
|
|
3665
|
+
)
|
|
3666
|
+
self.assertIsNotNone(first)
|
|
3667
|
+
self.assertEqual(
|
|
3668
|
+
len(retain_split.split_retain_content("under the bound")), 1,
|
|
3669
|
+
"the single-part branch is the one under test",
|
|
3670
|
+
)
|
|
3671
|
+
self.assertEqual(
|
|
3672
|
+
paths, [first],
|
|
3673
|
+
"one part written is one path handed back",
|
|
3674
|
+
)
|
|
3675
|
+
self.assertEqual(pending.count(), 1)
|
|
3676
|
+
|
|
3677
|
+
def test_a_single_part_that_could_not_be_written_returns_no_paths(self):
|
|
3678
|
+
"""The other half of the contract: `None` means nothing to retire."""
|
|
3679
|
+
with self._enospc_on_every_rename():
|
|
3680
|
+
with redirect_stderr(io.StringIO()):
|
|
3681
|
+
first, paths = pending.enqueue_parts(
|
|
3682
|
+
_payload(content="under the bound"), RuntimeError("x")
|
|
3683
|
+
)
|
|
3684
|
+
self.assertIsNone(first)
|
|
3685
|
+
self.assertEqual(paths, [])
|
|
3686
|
+
|
|
3687
|
+
def test_the_backlog_drain_resplits_before_paying_for_extraction(self):
|
|
3688
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3689
|
+
pending.enqueue(_payload(content="t" * 40_000), RuntimeError("x"))
|
|
3690
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3691
|
+
|
|
3692
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
|
|
3693
|
+
self.assertEqual(summary["resplit"], 1)
|
|
3694
|
+
self.assertGreaterEqual(summary["resplit_parts"], 14)
|
|
3695
|
+
|
|
3696
|
+
def test_a_dry_run_resplits_nothing(self):
|
|
3697
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
3698
|
+
path = pending.enqueue(_payload(content="s" * 40_000), RuntimeError("x"))
|
|
3699
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
3700
|
+
|
|
3701
|
+
summary = drain_pending.drain_backlog(
|
|
3702
|
+
CONFIG, phase="reconcile", dry_run=True
|
|
3703
|
+
)
|
|
3704
|
+
self.assertEqual(summary["resplit"], 0)
|
|
3705
|
+
self.assertTrue(os.path.exists(path))
|
|
3706
|
+
|
|
3707
|
+
|
|
2274
3708
|
if __name__ == "__main__":
|
|
2275
3709
|
unittest.main()
|