switchroom 0.19.27 → 0.19.29
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 +16 -2
- package/dist/auth-broker/index.js +141 -8
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +18 -2
- package/dist/cli/switchroom.js +864 -43
- package/dist/host-control/main.js +144 -9
- package/dist/vault/approvals/kernel-server.js +146 -9
- package/dist/vault/broker/server.js +146 -9
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -15
- package/telegram-plugin/dist/bridge/bridge.js +1 -0
- package/telegram-plugin/dist/gateway/gateway.js +585 -50
- package/telegram-plugin/dist/server.js +1 -0
- package/telegram-plugin/edit-flood-fuse.ts +230 -27
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/gateway.ts +9 -2
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/operator-events.ts +38 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +58 -1
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
- package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
- package/vendor/hindsight-memory/CHANGELOG.md +50 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +1 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +437 -11
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +91 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +199 -28
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +1 -0
- package/vendor/hindsight-memory/scripts/retain.py +41 -1
- package/vendor/hindsight-memory/scripts/subagent_retain.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +23 -1
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +5 -1
- package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +325 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +160 -1
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +40 -2
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
"""Queue eviction/dedupe + two-phase backlog drain (switchroom #3596).
|
|
2
2
|
|
|
3
|
-
These live under ``scripts/tests/`` deliberately:
|
|
4
|
-
|
|
5
|
-
``
|
|
6
|
-
``
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
These live under ``scripts/tests/`` deliberately: it is the stdlib-only
|
|
4
|
+
suite, discovered by ``python3 -m unittest discover tests/`` with
|
|
5
|
+
``working-directory: vendor/hindsight-memory/scripts`` in both
|
|
6
|
+
``ci-tests-python.yml`` and ``ci-full.yml``, and it needs no third-party
|
|
7
|
+
package to gate a merge.
|
|
8
|
+
|
|
9
|
+
Until switchroom #3688 it was also the ONLY python test directory CI ran at
|
|
10
|
+
all: the sibling suite at ``vendor/hindsight-memory/tests/`` started no
|
|
11
|
+
check, so a regression test placed there gated nothing. #3688 turned that
|
|
12
|
+
suite on too (a ``pip install -q pytest`` step in the same two workflows --
|
|
13
|
+
it is pytest-only because its ``conftest.py`` is what puts ``scripts/`` on
|
|
14
|
+
``sys.path``), and turning it on immediately surfaced one test that had
|
|
15
|
+
rotted red on ``main``. Both directories are now gates; prefer this one for
|
|
16
|
+
anything that can be written against the stdlib.
|
|
9
17
|
|
|
10
18
|
The behaviours pinned here are the ones a well-meaning refactor would
|
|
11
19
|
otherwise undo:
|
|
@@ -817,6 +825,202 @@ class CollapseDuplicatesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
817
825
|
self.assertEqual(summary["collapsed"], 0)
|
|
818
826
|
self.assertEqual(pending.count(), 3)
|
|
819
827
|
|
|
828
|
+
# ---- #3688 review R1-M1: a broken phase 0 must not wedge the drain -----
|
|
829
|
+
|
|
830
|
+
def test_a_failing_phase_0_never_blocks_the_phases_that_drain(self):
|
|
831
|
+
"""The module's own invariant, one level up.
|
|
832
|
+
|
|
833
|
+
``iter_entries`` quarantines an unparsable entry precisely so that no
|
|
834
|
+
single corrupt file can make the whole queue immortal. Phase 0 runs
|
|
835
|
+
BEFORE the phases that drain, so an unguarded exception in it
|
|
836
|
+
re-creates that failure with a bigger blast radius: nothing drains,
|
|
837
|
+
ever, until a human intervenes — strictly worse than the pre-#3688
|
|
838
|
+
drain, where the same queue drains fine.
|
|
839
|
+
|
|
840
|
+
A collapse failure can only ever cost duplicated work (the pass just
|
|
841
|
+
MOVES byte-identical copies), so it is logged and stepped over.
|
|
842
|
+
"""
|
|
843
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "one-memory")
|
|
844
|
+
|
|
845
|
+
def boom():
|
|
846
|
+
raise ValueError("invalid literal for int() with base 10: 'many'")
|
|
847
|
+
|
|
848
|
+
posted = []
|
|
849
|
+
with unittest.mock.patch.object(drain_pending, "collapse_duplicates", boom):
|
|
850
|
+
with unittest.mock.patch.object(
|
|
851
|
+
drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
|
|
852
|
+
):
|
|
853
|
+
with unittest.mock.patch.object(
|
|
854
|
+
drain_pending,
|
|
855
|
+
"_retry_one",
|
|
856
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
857
|
+
):
|
|
858
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
859
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
860
|
+
|
|
861
|
+
self.assertEqual(summary["collapsed"], 0, "nothing was collapsed, honestly")
|
|
862
|
+
self.assertEqual(len(posted), 1, "phase 2 still ran")
|
|
863
|
+
self.assertEqual(pending.count(), 0, "the entry drained anyway")
|
|
864
|
+
self.assertIn("phase 0 FAILED", err.getvalue(), "and it was not silent")
|
|
865
|
+
|
|
866
|
+
def test_a_malformed_attempt_count_cannot_make_the_backlog_immortal(self):
|
|
867
|
+
"""The concrete reproduction, end to end.
|
|
868
|
+
|
|
869
|
+
Two byte-identical entries, one carrying ``"attempt_count": "many"``
|
|
870
|
+
— valid JSON, so ``iter_entries`` hands it over rather than
|
|
871
|
+
quarantining it. Before #3688 review R1-M1/R1-L3 the ``int()`` in
|
|
872
|
+
survivor selection raised, phase 0 died and phases 1 and 2 never ran.
|
|
873
|
+
Both halves are fixed (``_attempt_count`` no longer raises, and the
|
|
874
|
+
phase is guarded regardless); this asserts the OUTCOME either way.
|
|
875
|
+
"""
|
|
876
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=1)
|
|
877
|
+
self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts="many")
|
|
878
|
+
|
|
879
|
+
posted = []
|
|
880
|
+
with unittest.mock.patch.object(
|
|
881
|
+
drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
|
|
882
|
+
):
|
|
883
|
+
with unittest.mock.patch.object(
|
|
884
|
+
drain_pending,
|
|
885
|
+
"_retry_one",
|
|
886
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
887
|
+
):
|
|
888
|
+
with redirect_stderr(io.StringIO()):
|
|
889
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
890
|
+
|
|
891
|
+
self.assertEqual(summary["collapsed"], 1)
|
|
892
|
+
self.assertEqual(len(posted), 1, "the memory still reached the bank")
|
|
893
|
+
self.assertEqual(pending.count(), 0)
|
|
894
|
+
|
|
895
|
+
# ---- #3688 review R1-L3: what "lowest attempt_count" actually means ----
|
|
896
|
+
|
|
897
|
+
def test_a_never_attempted_copy_beats_a_once_attempted_older_one(self):
|
|
898
|
+
"""``attempt_count: 0`` must WIN, not tie.
|
|
899
|
+
|
|
900
|
+
The rule is "the copy with the most retries left before
|
|
901
|
+
MAX_ATTEMPTS". ``int(x or 1)`` scored a 0 as a 1, so a
|
|
902
|
+
never-attempted copy tied with a once-attempted one and lost the
|
|
903
|
+
tie-break to it for being newer — backwards from the documented rule.
|
|
904
|
+
Latent today (``_build_entry`` seeds 1) and therefore exactly the
|
|
905
|
+
kind of thing nothing else would catch turning real.
|
|
906
|
+
"""
|
|
907
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=1)
|
|
908
|
+
keep = self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts=0)
|
|
909
|
+
|
|
910
|
+
with redirect_stderr(io.StringIO()):
|
|
911
|
+
self.assertEqual(pending.collapse_duplicates(), 1)
|
|
912
|
+
|
|
913
|
+
self.assertEqual(
|
|
914
|
+
[p for p, _ in pending.iter_entries()],
|
|
915
|
+
[keep],
|
|
916
|
+
"the copy with 5 retries left survives, not the one with 4",
|
|
917
|
+
)
|
|
918
|
+
|
|
919
|
+
def test_an_unparseable_attempt_count_is_never_preferred_as_survivor(self):
|
|
920
|
+
"""Unknown burn count ⇒ scored as exhausted, never as fresh.
|
|
921
|
+
|
|
922
|
+
Costs nothing: every copy in a group is byte-identical and the loser
|
|
923
|
+
is archived, not deleted. The point is that it can neither win the
|
|
924
|
+
survivor slot on a lie nor raise.
|
|
925
|
+
"""
|
|
926
|
+
keep = self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=2)
|
|
927
|
+
self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts="many")
|
|
928
|
+
|
|
929
|
+
with redirect_stderr(io.StringIO()):
|
|
930
|
+
self.assertEqual(pending.collapse_duplicates(), 1)
|
|
931
|
+
|
|
932
|
+
self.assertEqual([p for p, _ in pending.iter_entries()], [keep])
|
|
933
|
+
|
|
934
|
+
# ---- #3688 re-review B3: the two unpinned `_attempt_count` branches ----
|
|
935
|
+
#
|
|
936
|
+
# Both mutations SURVIVED the suite: deleting the `isinstance(raw, bool)`
|
|
937
|
+
# branch (M6) and turning `raw is None: return 1` into
|
|
938
|
+
# `return MAX_ATTEMPTS` (M7). The logic is right; it was simply ungated,
|
|
939
|
+
# so a refactor could undo it with CI green.
|
|
940
|
+
|
|
941
|
+
def test_a_boolean_attempt_count_is_scored_as_UNPARSEABLE_not_as_one(self):
|
|
942
|
+
"""``bool`` must be rejected BEFORE ``int()`` sees it.
|
|
943
|
+
|
|
944
|
+
#3688 re-review B3 / mutation M6. ``bool`` is an ``int`` subclass,
|
|
945
|
+
so ``int(True)`` is ``1`` — the score of a FRESH, never-retried
|
|
946
|
+
copy. Delete the ``isinstance(raw, bool)`` guard (it reads as
|
|
947
|
+
redundant next to the ``try``/``except``, which is exactly why a
|
|
948
|
+
future "simplification" would take it) and an entry carrying
|
|
949
|
+
``"attempt_count": true`` becomes the most PREFERRED survivor in
|
|
950
|
+
its group: it wins the election, and every well-formed
|
|
951
|
+
byte-identical sibling is archived instead. Silent, and invisible
|
|
952
|
+
to the rest of the suite.
|
|
953
|
+
|
|
954
|
+
Scoring it ``MAX_ATTEMPTS`` makes it the LEAST preferred, which is
|
|
955
|
+
the honest reading — a garbage count tells us nothing about how
|
|
956
|
+
many retries are left, so it must never be trusted over a real one.
|
|
957
|
+
A raise would be wrong here for a different reason: this runs
|
|
958
|
+
inside ``collapse_duplicates``'s sort key over the whole queue, so
|
|
959
|
+
one malformed file would abort the collapse for every other entry.
|
|
960
|
+
"""
|
|
961
|
+
self.assertEqual(
|
|
962
|
+
pending._attempt_count({"attempt_count": True}),
|
|
963
|
+
pending.MAX_ATTEMPTS,
|
|
964
|
+
"True is not one attempt — bool must take the unparseable branch",
|
|
965
|
+
)
|
|
966
|
+
self.assertEqual(
|
|
967
|
+
pending._attempt_count({"attempt_count": False}),
|
|
968
|
+
pending.MAX_ATTEMPTS,
|
|
969
|
+
"False is not zero attempts either",
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
# …and the consequence that actually costs a memory: the malformed
|
|
973
|
+
# copy must LOSE the survivor election to a well-formed sibling.
|
|
974
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=True)
|
|
975
|
+
keep = self._write_legacy("0000000002000-aaaaaaaaaaa1.json", "same", attempts=2)
|
|
976
|
+
|
|
977
|
+
with redirect_stderr(io.StringIO()):
|
|
978
|
+
self.assertEqual(pending.collapse_duplicates(), 1)
|
|
979
|
+
|
|
980
|
+
self.assertEqual(
|
|
981
|
+
[p for p, _ in pending.iter_entries()],
|
|
982
|
+
[keep],
|
|
983
|
+
"the well-formed copy survives; the boolean one is archived",
|
|
984
|
+
)
|
|
985
|
+
self.assertEqual(
|
|
986
|
+
self._duplicate_names(), ["0000000001000-aaaaaaaaaaa0.json"]
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
def test_a_missing_or_null_attempt_count_reads_as_FRESH_not_exhausted(self):
|
|
990
|
+
"""Absent/``None`` scores ``1``, matching what ``_build_entry`` writes.
|
|
991
|
+
|
|
992
|
+
#3688 re-review B3 / mutation M7 (``return 1`` → ``return
|
|
993
|
+
MAX_ATTEMPTS`` survived). An entry from an older build that predates
|
|
994
|
+
the field has burned no *recorded* attempts; scoring it as exhausted
|
|
995
|
+
would systematically archive it in favour of a copy with a real,
|
|
996
|
+
HIGHER burn count — i.e. deliberately keep the copy closer to
|
|
997
|
+
``.dead``, which is backwards from the durability-first rule
|
|
998
|
+
``collapse_duplicates`` documents. This is the one malformed-ish
|
|
999
|
+
shape that is genuinely benign, and it must not be lumped in with
|
|
1000
|
+
the garbage above.
|
|
1001
|
+
"""
|
|
1002
|
+
self.assertEqual(pending._attempt_count({"attempt_count": None}), 1)
|
|
1003
|
+
self.assertEqual(pending._attempt_count({}), 1, "absent reads the same")
|
|
1004
|
+
self.assertLess(
|
|
1005
|
+
pending._attempt_count({"attempt_count": None}),
|
|
1006
|
+
pending._attempt_count({"attempt_count": 2}),
|
|
1007
|
+
"a null count must be PREFERRED over a copy with 2 attempts burned",
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
self._write_legacy("0000000001000-aaaaaaaaaaa0.json", "same", attempts=2)
|
|
1011
|
+
keep = self._write_legacy(
|
|
1012
|
+
"0000000002000-aaaaaaaaaaa1.json", "same", attempts=None
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
with redirect_stderr(io.StringIO()):
|
|
1016
|
+
self.assertEqual(pending.collapse_duplicates(), 1)
|
|
1017
|
+
|
|
1018
|
+
self.assertEqual(
|
|
1019
|
+
[p for p, _ in pending.iter_entries()],
|
|
1020
|
+
[keep],
|
|
1021
|
+
"the null-count copy has 5 retries left; the other has 3",
|
|
1022
|
+
)
|
|
1023
|
+
|
|
820
1024
|
|
|
821
1025
|
class DropLedgerTest(_QueueTempDirMixin, unittest.TestCase):
|
|
822
1026
|
"""Residual drops -- the entry could not be written even after eviction."""
|
|
@@ -2076,6 +2280,267 @@ class TrimDirBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
2076
2280
|
self.assertIn("+8 more", msg)
|
|
2077
2281
|
self.assertLessEqual(msg.count("-x.json"), 10)
|
|
2078
2282
|
|
|
2283
|
+
def test_a_copy_that_could_NOT_be_removed_is_never_reported_as_dropped(self):
|
|
2284
|
+
"""A failed ``os.remove`` must not produce a ``trimmed=`` line.
|
|
2285
|
+
|
|
2286
|
+
#3688 re-review B2. ``dropped.append`` and ``_log_archive_trim``
|
|
2287
|
+
ran UNCONDITIONALLY after the ``try``, so a read-only mount or an
|
|
2288
|
+
EACCES made ``_trim_dir`` return a drop count for files that were
|
|
2289
|
+
all still on disk and write one durable ``trimmed=`` line per
|
|
2290
|
+
phantom deletion. ``pending-evictions.log`` is the forensic record
|
|
2291
|
+
an operator reads AFTER suspected loss; one that claims deletions
|
|
2292
|
+
that never happened sends them hunting a payload that never left,
|
|
2293
|
+
and is worse than no ledger at all.
|
|
2294
|
+
|
|
2295
|
+
Measured on the unfixed tree with three files and a cap of one:
|
|
2296
|
+
``dropped=2``, three files still present, two false ledger lines.
|
|
2297
|
+
"""
|
|
2298
|
+
d = self._archive([10, 10, 10])
|
|
2299
|
+
real_remove = os.remove
|
|
2300
|
+
|
|
2301
|
+
def denied(path, *a, **kw):
|
|
2302
|
+
if os.path.dirname(os.path.abspath(path)) == os.path.abspath(d):
|
|
2303
|
+
raise PermissionError(13, "Permission denied")
|
|
2304
|
+
return real_remove(path, *a, **kw)
|
|
2305
|
+
|
|
2306
|
+
with unittest.mock.patch.object(os, "remove", denied):
|
|
2307
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2308
|
+
dropped = pending._trim_dir(d, max_entries=1, max_bytes=10**9)
|
|
2309
|
+
|
|
2310
|
+
self.assertEqual(dropped, 0, "nothing was deleted, so nothing was dropped")
|
|
2311
|
+
self.assertEqual(
|
|
2312
|
+
len(self._names_in(d)), 3, "fixture: every copy is still on disk"
|
|
2313
|
+
)
|
|
2314
|
+
self.assertEqual(
|
|
2315
|
+
err.getvalue(), "", "no stderr line may name a file still present"
|
|
2316
|
+
)
|
|
2317
|
+
ledger = pending.evictions_log_path()
|
|
2318
|
+
trimmed = 0
|
|
2319
|
+
if os.path.exists(ledger):
|
|
2320
|
+
with open(ledger, encoding="utf-8") as f:
|
|
2321
|
+
trimmed = f.read().count("trimmed=")
|
|
2322
|
+
self.assertEqual(
|
|
2323
|
+
trimmed, 0, "the ledger must never claim a deletion that did not happen"
|
|
2324
|
+
)
|
|
2325
|
+
|
|
2326
|
+
def test_a_partial_failure_ledgers_only_the_copies_actually_shed(self):
|
|
2327
|
+
"""The mixed case: one victim deletes, the next does not.
|
|
2328
|
+
|
|
2329
|
+
Pins that the guard is per-victim rather than an all-or-nothing
|
|
2330
|
+
bail — the ledger and the returned count must both describe
|
|
2331
|
+
exactly the copies that really left.
|
|
2332
|
+
"""
|
|
2333
|
+
d = self._archive([10, 10, 10, 10])
|
|
2334
|
+
victims = [f"{1000 + i:013d}-x.json" for i in range(4)]
|
|
2335
|
+
real_remove = os.remove
|
|
2336
|
+
allowed = {os.path.abspath(os.path.join(d, victims[0]))}
|
|
2337
|
+
|
|
2338
|
+
def selective(path, *a, **kw):
|
|
2339
|
+
if os.path.dirname(os.path.abspath(path)) == os.path.abspath(d):
|
|
2340
|
+
if os.path.abspath(path) not in allowed:
|
|
2341
|
+
raise PermissionError(13, "Permission denied")
|
|
2342
|
+
return real_remove(path, *a, **kw)
|
|
2343
|
+
|
|
2344
|
+
with unittest.mock.patch.object(os, "remove", selective):
|
|
2345
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2346
|
+
dropped = pending._trim_dir(d, max_entries=1, max_bytes=10**9)
|
|
2347
|
+
|
|
2348
|
+
self.assertEqual(dropped, 1, "only the one that really went is counted")
|
|
2349
|
+
self.assertEqual(
|
|
2350
|
+
self._names_in(d), victims[1:], "the undeletable copies are still here"
|
|
2351
|
+
)
|
|
2352
|
+
with open(pending.evictions_log_path(), encoding="utf-8") as f:
|
|
2353
|
+
lines = [ln for ln in f.read().splitlines() if "trimmed=" in ln]
|
|
2354
|
+
self.assertEqual(len(lines), 1, "exactly one real deletion, one ledger line")
|
|
2355
|
+
self.assertIn(victims[0], lines[0])
|
|
2356
|
+
for name in victims[1:]:
|
|
2357
|
+
self.assertNotIn(name, err.getvalue())
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
class ArchiveTrimLedgerTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2361
|
+
"""A payload must not leave an ARCHIVE without a durable trace either.
|
|
2362
|
+
|
|
2363
|
+
``_evict_to_fit`` has had ``pending-evictions.log`` since #3599, so the
|
|
2364
|
+
moment a memory leaves the live queue is on the record. The other end of
|
|
2365
|
+
the same payload's life — ``_trim_dir`` deleting it out of
|
|
2366
|
+
``pending-evicted/``, ``pending-reconciled/`` or (since #3688)
|
|
2367
|
+
``pending-duplicate/`` — was stderr-only, and stderr is gone with the
|
|
2368
|
+
process. #3688 review R1-M2.
|
|
2369
|
+
|
|
2370
|
+
Two properties, and the second is the one with teeth: the line must NOT
|
|
2371
|
+
be countable as an eviction. ``switchroom doctor``'s in-container probe
|
|
2372
|
+
counts ledger lines matching ``/evicted=/`` inside a 7-day window and
|
|
2373
|
+
FAILS the pending-retains row on any hit (src/cli/doctor.ts,
|
|
2374
|
+
``buildPendingRetainsProbeScript``). A duplicate archive rolling over is
|
|
2375
|
+
not memory loss — a byte-identical copy is still queued — so it must not
|
|
2376
|
+
be able to turn that row red.
|
|
2377
|
+
"""
|
|
2378
|
+
|
|
2379
|
+
def _archive(self, name, n=4):
|
|
2380
|
+
d = os.path.join(self._tmp, name)
|
|
2381
|
+
os.makedirs(d, exist_ok=True)
|
|
2382
|
+
for i in range(n):
|
|
2383
|
+
with open(os.path.join(d, f"{1000 + i:013d}-x.json"), "w") as f:
|
|
2384
|
+
f.write("x")
|
|
2385
|
+
return d
|
|
2386
|
+
|
|
2387
|
+
def _ledger(self):
|
|
2388
|
+
try:
|
|
2389
|
+
with open(pending.evictions_log_path(), encoding="utf-8") as f:
|
|
2390
|
+
return [ln.strip() for ln in f if ln.strip()]
|
|
2391
|
+
except OSError:
|
|
2392
|
+
return []
|
|
2393
|
+
|
|
2394
|
+
def test_every_bounded_archive_ledgers_the_copies_it_sheds(self):
|
|
2395
|
+
for name in ("pending-evicted", "pending-reconciled", "pending-duplicate"):
|
|
2396
|
+
d = self._archive(name)
|
|
2397
|
+
with redirect_stderr(io.StringIO()):
|
|
2398
|
+
pending._trim_dir(d, max_entries=1, max_bytes=10**9)
|
|
2399
|
+
|
|
2400
|
+
lines = self._ledger()
|
|
2401
|
+
self.assertEqual(len(lines), 9, "3 archives x 3 shed copies, all ledgered")
|
|
2402
|
+
for name in ("pending-evicted", "pending-reconciled", "pending-duplicate"):
|
|
2403
|
+
got = [ln for ln in lines if f"archive={name}" in ln]
|
|
2404
|
+
self.assertEqual(len(got), 3, f"{name} trims are on the record")
|
|
2405
|
+
self.assertIn("trimmed=0000000001000-x.json", got[0], "oldest first")
|
|
2406
|
+
self.assertIn("bytes=1", got[0], "and how much went with it")
|
|
2407
|
+
|
|
2408
|
+
def test_a_trim_can_never_be_counted_as_an_eviction_by_doctor(self):
|
|
2409
|
+
"""``trimmed=``, never ``evicted=`` — see ``buildPendingRetainsProbeScript``.
|
|
2410
|
+
|
|
2411
|
+
Renaming the token to ``evicted=`` would make every duplicate-archive
|
|
2412
|
+
rollover fail the operator's pending-retains row, which is the check
|
|
2413
|
+
that means "memory was actually shed".
|
|
2414
|
+
"""
|
|
2415
|
+
d = self._archive("pending-duplicate", n=3)
|
|
2416
|
+
with redirect_stderr(io.StringIO()):
|
|
2417
|
+
pending._trim_dir(d, max_entries=1, max_bytes=10**9)
|
|
2418
|
+
|
|
2419
|
+
lines = self._ledger()
|
|
2420
|
+
self.assertTrue(lines, "the trim was ledgered at all")
|
|
2421
|
+
for ln in lines:
|
|
2422
|
+
self.assertNotIn(
|
|
2423
|
+
"evicted=", ln, "doctor counts /evicted=/ and FAILS the row on it"
|
|
2424
|
+
)
|
|
2425
|
+
self.assertIn("trimmed=", ln)
|
|
2426
|
+
# doctor windows the ledger with `awk '$1 >= cutoff'`, so the
|
|
2427
|
+
# first field has to stay a lexicographically-sortable UTC stamp.
|
|
2428
|
+
self.assertRegex(ln.split()[0], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
|
2429
|
+
|
|
2430
|
+
def test_a_collapsed_duplicate_that_rolls_out_of_the_archive_is_traceable(self):
|
|
2431
|
+
"""End to end: collapse → archive full → the copy is gone, on record.
|
|
2432
|
+
|
|
2433
|
+
This is the honest reading of "reversible": reversible until the
|
|
2434
|
+
archive rolls, and when it rolls the operator can still see WHAT
|
|
2435
|
+
rolled and WHEN.
|
|
2436
|
+
"""
|
|
2437
|
+
prev = pending.DUPLICATE_MAX_ENTRIES
|
|
2438
|
+
pending.DUPLICATE_MAX_ENTRIES = 1
|
|
2439
|
+
try:
|
|
2440
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
2441
|
+
for i in range(4):
|
|
2442
|
+
entry = dict(_payload(content="same"))
|
|
2443
|
+
entry["attempt_count"] = 1
|
|
2444
|
+
with open(
|
|
2445
|
+
os.path.join(self._dir, f"{1000 + i:013d}-aaaaaaaaaaa{i}.json"),
|
|
2446
|
+
"w",
|
|
2447
|
+
encoding="utf-8",
|
|
2448
|
+
) as f:
|
|
2449
|
+
json.dump(entry, f)
|
|
2450
|
+
with redirect_stderr(io.StringIO()):
|
|
2451
|
+
self.assertEqual(pending.collapse_duplicates(), 3)
|
|
2452
|
+
finally:
|
|
2453
|
+
pending.DUPLICATE_MAX_ENTRIES = prev
|
|
2454
|
+
|
|
2455
|
+
self.assertEqual(
|
|
2456
|
+
len(os.listdir(pending.duplicate_dir())), 1, "the archive stayed bounded"
|
|
2457
|
+
)
|
|
2458
|
+
trims = [ln for ln in self._ledger() if "archive=pending-duplicate" in ln]
|
|
2459
|
+
self.assertEqual(len(trims), 2, "the two shed copies are on the record")
|
|
2460
|
+
|
|
2461
|
+
def test_trim_noise_can_never_push_a_real_eviction_out_of_the_ledger(self):
|
|
2462
|
+
"""The cost of SHARING ``pending-evictions.log`` with ``_trim_dir``.
|
|
2463
|
+
|
|
2464
|
+
Before #3688 every line in this file was an eviction, so "keep the
|
|
2465
|
+
newest ``EVICTIONS_LOG_KEEP_LINES`` lines" and "keep the newest 2,000
|
|
2466
|
+
evictions" meant the same thing. Ledgering trims broke that
|
|
2467
|
+
equivalence: one ``collapse_duplicates`` run over the measured
|
|
2468
|
+
1,060-file backlog writes ~192 ``trimmed=`` lines, and a plain
|
|
2469
|
+
tail-rotate would let that noise evict real ``evicted=`` lines from
|
|
2470
|
+
the 7-day window ``switchroom doctor`` reads.
|
|
2471
|
+
|
|
2472
|
+
That failure mode is worth spelling out because it INVERTS the fix:
|
|
2473
|
+
adding observability for the benign channel (a collapse) would have
|
|
2474
|
+
removed it for the only channel that means memory is actually gone.
|
|
2475
|
+
Rotation therefore fills its window evictions-first — inside the SAME
|
|
2476
|
+
budget, so the file is no less bounded than it was before #3688.
|
|
2477
|
+
"""
|
|
2478
|
+
keep = pending.EVICTIONS_LOG_KEEP_LINES
|
|
2479
|
+
log = pending.evictions_log_path()
|
|
2480
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
2481
|
+
# The oldest lines are the real evictions; everything after them is
|
|
2482
|
+
# trim noise, deep enough to bury them past the tail window.
|
|
2483
|
+
with open(log, "w", encoding="utf-8") as f:
|
|
2484
|
+
for i in range(5):
|
|
2485
|
+
f.write(
|
|
2486
|
+
"2026-07-20T00:00:%02dZ evicted=old-%d.json bytes=1 "
|
|
2487
|
+
"reason=count queue_depth=1 queue_bytes=1\n" % (i, i)
|
|
2488
|
+
)
|
|
2489
|
+
for i in range(keep + 50):
|
|
2490
|
+
f.write(
|
|
2491
|
+
"2026-07-26T00:00:00Z trimmed=n-%d.json bytes=1 "
|
|
2492
|
+
"archive=pending-duplicate reason=archive-count "
|
|
2493
|
+
"archive_depth=0 archive_bytes=0\n" % i
|
|
2494
|
+
)
|
|
2495
|
+
|
|
2496
|
+
# Force the rotate on the next append.
|
|
2497
|
+
original = pending.EVICTIONS_LOG_MAX_BYTES
|
|
2498
|
+
pending.EVICTIONS_LOG_MAX_BYTES = 1
|
|
2499
|
+
try:
|
|
2500
|
+
pending._append_ledger("2026-07-26T00:00:01Z trimmed=last.json bytes=1")
|
|
2501
|
+
finally:
|
|
2502
|
+
pending.EVICTIONS_LOG_MAX_BYTES = original
|
|
2503
|
+
|
|
2504
|
+
lines = self._ledger()
|
|
2505
|
+
evictions = [ln for ln in lines if "evicted=" in ln]
|
|
2506
|
+
self.assertEqual(len(evictions), 5, "every real eviction survived the rotate")
|
|
2507
|
+
# …and the file is still BOUNDED, on the SAME budget as before
|
|
2508
|
+
# #3688. Prioritising evictions must not raise the ceiling, or it
|
|
2509
|
+
# trades a monitoring bug for the unbounded log #3599 bounded.
|
|
2510
|
+
self.assertLessEqual(len(lines), keep)
|
|
2511
|
+
# Chronological order is what doctor's `$1 >= cutoff` awk depends on.
|
|
2512
|
+
self.assertEqual(
|
|
2513
|
+
[ln.split()[0] for ln in lines],
|
|
2514
|
+
sorted(ln.split()[0] for ln in lines),
|
|
2515
|
+
"the rotate preserved timestamp order",
|
|
2516
|
+
)
|
|
2517
|
+
|
|
2518
|
+
def test_the_rotate_still_sheds_trim_lines(self):
|
|
2519
|
+
"""The eviction rescue is a floor, not an excuse to keep everything.
|
|
2520
|
+
|
|
2521
|
+
Without this the previous test is satisfied by simply never
|
|
2522
|
+
rotating, which would restore the unbounded ledger #3599 bounded.
|
|
2523
|
+
"""
|
|
2524
|
+
keep = pending.EVICTIONS_LOG_KEEP_LINES
|
|
2525
|
+
log = pending.evictions_log_path()
|
|
2526
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
2527
|
+
with open(log, "w", encoding="utf-8") as f:
|
|
2528
|
+
for i in range(keep + 500):
|
|
2529
|
+
f.write(
|
|
2530
|
+
"2026-07-26T00:00:00Z trimmed=n-%d.json bytes=1 "
|
|
2531
|
+
"archive=pending-duplicate reason=archive-count "
|
|
2532
|
+
"archive_depth=0 archive_bytes=0\n" % i
|
|
2533
|
+
)
|
|
2534
|
+
original = pending.EVICTIONS_LOG_MAX_BYTES
|
|
2535
|
+
pending.EVICTIONS_LOG_MAX_BYTES = 1
|
|
2536
|
+
try:
|
|
2537
|
+
pending._append_ledger("2026-07-26T00:00:01Z trimmed=last.json bytes=1")
|
|
2538
|
+
finally:
|
|
2539
|
+
pending.EVICTIONS_LOG_MAX_BYTES = original
|
|
2540
|
+
lines = self._ledger()
|
|
2541
|
+
self.assertEqual(len(lines), keep, "a pure-trim ledger tail-rotates")
|
|
2542
|
+
self.assertIn("trimmed=last.json", lines[-1])
|
|
2543
|
+
|
|
2079
2544
|
|
|
2080
2545
|
class ClampAndEnvKnobBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2081
2546
|
"""``_clamp``'s floor and ``_env_num``'s clamps (#3599 review R3-L4)."""
|
|
@@ -2259,8 +2724,10 @@ class CliTest(unittest.TestCase):
|
|
|
2259
2724
|
def _run(self, argv):
|
|
2260
2725
|
seen = {}
|
|
2261
2726
|
|
|
2262
|
-
def fake_drain(
|
|
2263
|
-
|
|
2727
|
+
def fake_drain(
|
|
2728
|
+
config=None, backlog=False, phase="both", dry_run=False, force=False
|
|
2729
|
+
):
|
|
2730
|
+
seen.update(backlog=backlog, phase=phase, dry_run=dry_run, force=force)
|
|
2264
2731
|
return drain_pending._new_summary()
|
|
2265
2732
|
|
|
2266
2733
|
with unittest.mock.patch.object(drain_pending, "drain", fake_drain):
|
|
@@ -2285,11 +2752,60 @@ class CliTest(unittest.TestCase):
|
|
|
2285
2752
|
self.assertEqual(seen["phase"], "reconcile")
|
|
2286
2753
|
self.assertTrue(seen["dry_run"])
|
|
2287
2754
|
|
|
2755
|
+
def test_force_is_plumbed_and_defaults_off(self):
|
|
2756
|
+
# `--force` overrides the circuit breaker. Parsed-but-dropped would
|
|
2757
|
+
# leave the operator's explicit "retry the parked entries" silently
|
|
2758
|
+
# doing nothing, which is exactly the class of bug this file exists
|
|
2759
|
+
# for (a flag accepted and ignored).
|
|
2760
|
+
_, seen = self._run(["--backlog", "--force"])
|
|
2761
|
+
self.assertTrue(seen["force"])
|
|
2762
|
+
_, seen = self._run(["--backlog"])
|
|
2763
|
+
self.assertFalse(seen["force"])
|
|
2764
|
+
|
|
2288
2765
|
def test_phase_without_backlog_is_refused(self):
|
|
2289
2766
|
with redirect_stderr(io.StringIO()):
|
|
2290
2767
|
rc, _ = self._run(["--phase", "reconcile"])
|
|
2291
2768
|
self.assertEqual(rc, 2)
|
|
2292
2769
|
|
|
2770
|
+
# ---- #3895: a failed pre-drain phase must not be invisible ------------
|
|
2771
|
+
|
|
2772
|
+
def _run_with(self, summary, argv=("--backlog",)):
|
|
2773
|
+
with unittest.mock.patch.object(
|
|
2774
|
+
drain_pending, "drain", lambda *a, **kw: summary
|
|
2775
|
+
):
|
|
2776
|
+
with unittest.mock.patch.object(drain_pending, "load_config", lambda: {}):
|
|
2777
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2778
|
+
drain_pending.main(list(argv))
|
|
2779
|
+
return err.getvalue()
|
|
2780
|
+
|
|
2781
|
+
def test_a_run_whose_phases_all_failed_does_not_look_like_a_clean_one(self):
|
|
2782
|
+
"""The whole reason the failure is in the SUMMARY, not only a log.
|
|
2783
|
+
|
|
2784
|
+
Every pre-drain counter reads 0 on a run where the phases blew up
|
|
2785
|
+
and on a run over an empty, healthy queue — they are the same
|
|
2786
|
+
numbers. `main()`'s report gate is `any(...)` over those counters,
|
|
2787
|
+
so without `phase_failures` in it a permanently broken phase 0b/0c
|
|
2788
|
+
prints, from the CLI, EXACTLY what a clean run prints: nothing.
|
|
2789
|
+
Same failure shape `parked` was added to that gate for (#3893), one
|
|
2790
|
+
stage earlier in the run. Pinned as a DIFFERENCE rather than a
|
|
2791
|
+
substring, because "these two runs are indistinguishable" is the
|
|
2792
|
+
defect.
|
|
2793
|
+
"""
|
|
2794
|
+
clean = drain_pending._new_summary()
|
|
2795
|
+
broken = drain_pending._new_summary()
|
|
2796
|
+
broken["phase_failures"] = ["0b", "0c"]
|
|
2797
|
+
|
|
2798
|
+
self.assertEqual(self._run_with(clean), "", "a clean quiet run stays quiet")
|
|
2799
|
+
out = self._run_with(broken)
|
|
2800
|
+
self.assertNotEqual(out, "", "a run that lost both phases must report")
|
|
2801
|
+
self.assertIn("phase_failures=0b,0c", out, "and it must name which")
|
|
2802
|
+
|
|
2803
|
+
def test_a_reported_run_with_no_phase_failure_says_so(self):
|
|
2804
|
+
"""The negative half: the field is present, not conditional noise."""
|
|
2805
|
+
summary = drain_pending._new_summary()
|
|
2806
|
+
summary["drained"] = 1
|
|
2807
|
+
self.assertIn("phase_failures=none", self._run_with(summary))
|
|
2808
|
+
|
|
2293
2809
|
|
|
2294
2810
|
class StallGuardBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2295
2811
|
"""The stall guard counts CONSECUTIVE failures of the SAME class.
|
|
@@ -2851,6 +3367,61 @@ class DeadMarkersLeaveTheLiveQueueTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
2851
3367
|
self.assertEqual(summary["dead_relocated"], 0)
|
|
2852
3368
|
self.assertTrue(os.path.exists(legacy))
|
|
2853
3369
|
|
|
3370
|
+
# ---- #3895: a broken phase 0b must not wedge the drain ----------------
|
|
3371
|
+
|
|
3372
|
+
def test_a_failing_phase_0b_never_blocks_the_phases_that_drain(self):
|
|
3373
|
+
"""Phase 0b's blast radius, pinned.
|
|
3374
|
+
|
|
3375
|
+
0b runs BEFORE the phases that actually drain, so an exception out
|
|
3376
|
+
of it takes phases 1 and 2 with it and the whole backlog becomes
|
|
3377
|
+
immortal — for every OTHER entry in the queue, none of which had
|
|
3378
|
+
anything to do with the failure.
|
|
3379
|
+
|
|
3380
|
+
The raise is INJECTED here rather than provoked from queue data, and
|
|
3381
|
+
that is an honest statement about this phase: unlike phase 0c,
|
|
3382
|
+
`sweep_legacy_dead_markers` parses no entry — it walks filenames and
|
|
3383
|
+
already catches `OSError` per marker (`shutil.Error` is an `OSError`
|
|
3384
|
+
subclass, so a cross-device move is covered too). No queue state
|
|
3385
|
+
reachable today makes it raise; several were tried. The guard is
|
|
3386
|
+
therefore defence-in-depth on the CALLER contract, which is the part
|
|
3387
|
+
that matters: nothing that runs before the drain may be able to stop
|
|
3388
|
+
it, and any future line added inside that loop re-opens the hole
|
|
3389
|
+
this pins shut.
|
|
3390
|
+
|
|
3391
|
+
A sweep only MOVES a marker out of the janitor's path, so skipping
|
|
3392
|
+
it leaves the markers exactly where every earlier build left them.
|
|
3393
|
+
"""
|
|
3394
|
+
path = pending.enqueue(_payload(content="a real memory"), RuntimeError("x"))
|
|
3395
|
+
self.assertTrue(os.path.exists(path), "fixture")
|
|
3396
|
+
|
|
3397
|
+
def boom():
|
|
3398
|
+
raise OSError(errno.EACCES, "Permission denied")
|
|
3399
|
+
|
|
3400
|
+
posted = []
|
|
3401
|
+
with unittest.mock.patch.object(
|
|
3402
|
+
drain_pending, "sweep_legacy_dead_markers", boom
|
|
3403
|
+
):
|
|
3404
|
+
with unittest.mock.patch.object(
|
|
3405
|
+
drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
|
|
3406
|
+
):
|
|
3407
|
+
with unittest.mock.patch.object(
|
|
3408
|
+
drain_pending,
|
|
3409
|
+
"_retry_one",
|
|
3410
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
3411
|
+
):
|
|
3412
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
3413
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
3414
|
+
|
|
3415
|
+
self.assertEqual(len(posted), 1, "phase 2 still ran")
|
|
3416
|
+
self.assertEqual(pending.count(), 0, "the entry drained anyway")
|
|
3417
|
+
self.assertEqual(summary["dead_relocated"], 0, "nothing moved, honestly")
|
|
3418
|
+
self.assertIn("phase 0b FAILED", err.getvalue(), "and it was not silent")
|
|
3419
|
+
self.assertEqual(
|
|
3420
|
+
summary["phase_failures"],
|
|
3421
|
+
["0b"],
|
|
3422
|
+
"the failure must survive into the summary, not only into stderr",
|
|
3423
|
+
)
|
|
3424
|
+
|
|
2854
3425
|
|
|
2855
3426
|
class ResplitOverBoundEntriesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2856
3427
|
"""An entry over the retain bound is RECOVERABLE, so it must never die.
|
|
@@ -3704,6 +4275,244 @@ class ResplitOverBoundEntriesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
|
3704
4275
|
self.assertEqual(summary["resplit"], 0)
|
|
3705
4276
|
self.assertTrue(os.path.exists(path))
|
|
3706
4277
|
|
|
4278
|
+
# ---- #3895: a broken phase 0c must not wedge the drain ----------------
|
|
4279
|
+
|
|
4280
|
+
def test_a_non_dict_metadata_cannot_make_the_backlog_immortal(self):
|
|
4281
|
+
"""The concrete reproduction, end to end — measured, not imagined.
|
|
4282
|
+
|
|
4283
|
+
Phase 0c PARSES every queued entry, so a
|
|
4284
|
+
semantically-malformed-but-valid-JSON entry reaches it.
|
|
4285
|
+
`iter_entries()`' quarantine cannot help: the file is valid JSON, so
|
|
4286
|
+
it is handed over rather than quarantined — that is the whole point
|
|
4287
|
+
of the quarantine, and the whole reason this class of entry is the
|
|
4288
|
+
dangerous one.
|
|
4289
|
+
|
|
4290
|
+
One entry with `"metadata": "nope"` (a string where a mapping
|
|
4291
|
+
belongs) is enough: the payload rebuild inside `enqueue_parts`
|
|
4292
|
+
raises `ValueError: dictionary update sequence element #0 has
|
|
4293
|
+
length 1; 2 is required`. Unguarded, that ONE entry stops phases 1
|
|
4294
|
+
and 2 for the WHOLE queue, on every run, forever.
|
|
4295
|
+
|
|
4296
|
+
The healthy entry is the assertion that matters: it has nothing to
|
|
4297
|
+
do with the malformed one and must still reach the bank.
|
|
4298
|
+
"""
|
|
4299
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "100000"
|
|
4300
|
+
bad = pending.enqueue(
|
|
4301
|
+
dict(_payload(content="m" * 40_000, doc=_cd_doc(1)), metadata="nope"),
|
|
4302
|
+
RuntimeError("x"),
|
|
4303
|
+
)
|
|
4304
|
+
pending.enqueue(
|
|
4305
|
+
_payload(content="a healthy memory", doc=_cd_doc(5)), RuntimeError("x")
|
|
4306
|
+
)
|
|
4307
|
+
self.assertEqual(pending.count(), 2, "fixture")
|
|
4308
|
+
self.assertTrue(os.path.exists(bad), "fixture")
|
|
4309
|
+
# The bound MOVES under the entry — exactly what a deadline change
|
|
4310
|
+
# does — so phase 0c now has work to do and reaches the bad entry.
|
|
4311
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
4312
|
+
|
|
4313
|
+
posted = []
|
|
4314
|
+
with unittest.mock.patch.object(
|
|
4315
|
+
drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
|
|
4316
|
+
):
|
|
4317
|
+
with unittest.mock.patch.object(
|
|
4318
|
+
drain_pending,
|
|
4319
|
+
"_retry_one",
|
|
4320
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
4321
|
+
):
|
|
4322
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
4323
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
4324
|
+
|
|
4325
|
+
self.assertIn("phase 0c FAILED", err.getvalue(), "and it was not silent")
|
|
4326
|
+
self.assertEqual(summary["phase_failures"], ["0c"])
|
|
4327
|
+
self.assertEqual(summary["resplit"], 0, "nothing was re-split, honestly")
|
|
4328
|
+
self.assertEqual(summary["resplit_parts"], 0)
|
|
4329
|
+
self.assertIn(_cd_doc(5), posted, "the healthy memory still reached the bank")
|
|
4330
|
+
self.assertEqual(pending.count(), 0, "and the queue drained")
|
|
4331
|
+
|
|
4332
|
+
def test_a_failing_phase_0c_never_blocks_the_phases_that_drain(self):
|
|
4333
|
+
"""The guard itself, independent of which raise provokes it.
|
|
4334
|
+
|
|
4335
|
+
The reproduction above pins ONE measured raise. This pins the
|
|
4336
|
+
contract that outlives it: whatever `resplit_over_bound_entries`
|
|
4337
|
+
raises, phases 1 and 2 still run. Skipping a re-split costs the
|
|
4338
|
+
over-bound entries only — they stay as undrainable as they were
|
|
4339
|
+
before phase 0c existed — and costs the rest of the queue nothing.
|
|
4340
|
+
"""
|
|
4341
|
+
pending.enqueue(_payload(content="a real memory"), RuntimeError("x"))
|
|
4342
|
+
|
|
4343
|
+
def boom():
|
|
4344
|
+
raise ValueError("invalid literal for int() with base 10: 'many'")
|
|
4345
|
+
|
|
4346
|
+
posted = []
|
|
4347
|
+
with unittest.mock.patch.object(
|
|
4348
|
+
drain_pending, "resplit_over_bound_entries", boom
|
|
4349
|
+
):
|
|
4350
|
+
with unittest.mock.patch.object(
|
|
4351
|
+
drain_pending, "_document_state", lambda e, timeout=30: bool(posted)
|
|
4352
|
+
):
|
|
4353
|
+
with unittest.mock.patch.object(
|
|
4354
|
+
drain_pending,
|
|
4355
|
+
"_retry_one",
|
|
4356
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
4357
|
+
):
|
|
4358
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
4359
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
4360
|
+
|
|
4361
|
+
self.assertEqual(len(posted), 1, "phase 2 still ran")
|
|
4362
|
+
self.assertEqual(pending.count(), 0, "the entry drained anyway")
|
|
4363
|
+
self.assertEqual(summary["resplit"], 0)
|
|
4364
|
+
self.assertEqual(summary["resplit_parts"], 0)
|
|
4365
|
+
self.assertIn("phase 0c FAILED", err.getvalue())
|
|
4366
|
+
self.assertEqual(summary["phase_failures"], ["0c"])
|
|
4367
|
+
|
|
4368
|
+
def test_every_pre_drain_phase_can_fail_and_the_drain_still_completes(self):
|
|
4369
|
+
"""All three at once, and the summary names all three, in run order.
|
|
4370
|
+
|
|
4371
|
+
Phases 0, 0b and 0c share a block and a failure mode, so the
|
|
4372
|
+
interesting case is not one of them breaking but the state where the
|
|
4373
|
+
whole pre-drain stage is unusable. The drain must still be the thing
|
|
4374
|
+
that runs, and the queue must still empty.
|
|
4375
|
+
|
|
4376
|
+
Phase 0 is included because #3894's guard now routes through the same
|
|
4377
|
+
`_phase_failed` helper: this is the test that keeps the three of them
|
|
4378
|
+
on ONE mechanism, rather than three `except` blocks that drift.
|
|
4379
|
+
"""
|
|
4380
|
+
pending.enqueue(_payload(content="a real memory"), RuntimeError("x"))
|
|
4381
|
+
|
|
4382
|
+
def boom(*a, **kw):
|
|
4383
|
+
raise RuntimeError("the pre-drain stage is unusable")
|
|
4384
|
+
|
|
4385
|
+
posted = []
|
|
4386
|
+
with unittest.mock.patch.object(drain_pending, "collapse_duplicates", boom):
|
|
4387
|
+
with unittest.mock.patch.object(
|
|
4388
|
+
drain_pending, "sweep_legacy_dead_markers", boom
|
|
4389
|
+
):
|
|
4390
|
+
with unittest.mock.patch.object(
|
|
4391
|
+
drain_pending, "resplit_over_bound_entries", boom
|
|
4392
|
+
):
|
|
4393
|
+
with unittest.mock.patch.object(
|
|
4394
|
+
drain_pending,
|
|
4395
|
+
"_document_state",
|
|
4396
|
+
lambda e, timeout=30: bool(posted),
|
|
4397
|
+
):
|
|
4398
|
+
with unittest.mock.patch.object(
|
|
4399
|
+
drain_pending,
|
|
4400
|
+
"_retry_one",
|
|
4401
|
+
lambda e, timeout: posted.append(e["document_id"]),
|
|
4402
|
+
):
|
|
4403
|
+
with redirect_stderr(io.StringIO()):
|
|
4404
|
+
summary = drain_pending.drain_backlog(CONFIG)
|
|
4405
|
+
|
|
4406
|
+
self.assertEqual(len(posted), 1, "phase 2 still ran")
|
|
4407
|
+
self.assertEqual(pending.count(), 0)
|
|
4408
|
+
self.assertEqual(summary["phase_failures"], ["0", "0b", "0c"])
|
|
4409
|
+
|
|
4410
|
+
|
|
4411
|
+
class CorruptLedgerNeverStallsTheDrainTest(_QueueTempDirMixin, unittest.TestCase):
|
|
4412
|
+
"""A corrupt ``pending-evictions.log`` must not take the caller down.
|
|
4413
|
+
|
|
4414
|
+
#3688 re-review B1, and the reason it is a blocker rather than a nit:
|
|
4415
|
+
the rotation now READS the ledger back (``open(log,
|
|
4416
|
+
encoding="utf-8")`` + ``readlines()``) and ``UnicodeDecodeError`` is a
|
|
4417
|
+
``ValueError`` subclass, NOT an ``OSError``. Under a bare
|
|
4418
|
+
``except OSError`` guard the codec error escapes the "best-effort"
|
|
4419
|
+
ledger entirely and propagates back out through every caller:
|
|
4420
|
+
|
|
4421
|
+
* ``archive_reconciled`` → ``_trim_dir`` → ``_log_archive_trim`` →
|
|
4422
|
+
``_append_ledger`` — and ``archive_reconciled`` is called from the
|
|
4423
|
+
drain OUTSIDE the phase guard, so it reaches ``__main__`` and
|
|
4424
|
+
exits 2;
|
|
4425
|
+
* ``enqueue`` → ``_evict_to_fit`` → ``_log_eviction`` → the same —
|
|
4426
|
+
i.e. a failed retain could no longer even be queued.
|
|
4427
|
+
|
|
4428
|
+
On the drain sidecar that is a SILENT permanent stall: the wrapper's
|
|
4429
|
+
``|| true`` swallows the exit code and the loop aborts identically on
|
|
4430
|
+
every cycle, forever, which is precisely what these queue-hardening
|
|
4431
|
+
changes exist to prevent. The trigger needs no exotic state — one
|
|
4432
|
+
non-UTF-8 byte in a >1 MB ledger plus any archive over its cap. The
|
|
4433
|
+
read-side surface is NOT new to the rotation, either: the pre-#3688
|
|
4434
|
+
tail-rotate already called ``readlines()`` under ``except OSError``
|
|
4435
|
+
(``lib/pending.py`` at 63182ce5), so this is a live latent bug on
|
|
4436
|
+
``main``, not one this change introduces.
|
|
4437
|
+
|
|
4438
|
+
The write side is the same bug through the other door: a filename
|
|
4439
|
+
carrying a surrogate makes the ``print`` raise ``UnicodeEncodeError``,
|
|
4440
|
+
also a ``ValueError``. ``except (OSError, ValueError)`` closes both.
|
|
4441
|
+
"""
|
|
4442
|
+
|
|
4443
|
+
def _corrupt_ledger(self):
|
|
4444
|
+
"""A >``EVICTIONS_LOG_MAX_BYTES`` ledger that is not valid UTF-8."""
|
|
4445
|
+
log = pending.evictions_log_path()
|
|
4446
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
4447
|
+
with open(log, "wb") as f:
|
|
4448
|
+
f.write(b"\xff" * (pending.EVICTIONS_LOG_MAX_BYTES + 4096))
|
|
4449
|
+
return log
|
|
4450
|
+
|
|
4451
|
+
def _fill_archive_over_cap(self, d, n):
|
|
4452
|
+
os.makedirs(d, mode=0o700, exist_ok=True)
|
|
4453
|
+
for i in range(n):
|
|
4454
|
+
with open(os.path.join(d, f"{1000 + i:013d}-k-x.json"), "w") as f:
|
|
4455
|
+
f.write("{}")
|
|
4456
|
+
|
|
4457
|
+
def test_archive_reconciled_survives_a_non_utf8_ledger(self):
|
|
4458
|
+
"""The drain path. Measured raising ``UnicodeDecodeError`` unfixed."""
|
|
4459
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
4460
|
+
self._fill_archive_over_cap(
|
|
4461
|
+
pending.reconciled_dir(), pending.RECONCILED_MAX_ENTRIES + 2
|
|
4462
|
+
)
|
|
4463
|
+
self._corrupt_ledger()
|
|
4464
|
+
entry = os.path.join(self._dir, "9999999999999-k-v.json")
|
|
4465
|
+
with open(entry, "w", encoding="utf-8") as f:
|
|
4466
|
+
json.dump(_payload(content="a real turn"), f)
|
|
4467
|
+
|
|
4468
|
+
with redirect_stderr(io.StringIO()):
|
|
4469
|
+
dest = pending.archive_reconciled(entry)
|
|
4470
|
+
|
|
4471
|
+
self.assertIsNotNone(dest, "the retire itself must still succeed")
|
|
4472
|
+
self.assertFalse(os.path.exists(entry), "entry moved out of the queue")
|
|
4473
|
+
with open(dest, encoding="utf-8") as f:
|
|
4474
|
+
self.assertEqual(json.load(f)["content"], "a real turn")
|
|
4475
|
+
|
|
4476
|
+
def test_enqueue_survives_a_non_utf8_ledger(self):
|
|
4477
|
+
"""The eviction path. A corrupt ledger must not block QUEUEING."""
|
|
4478
|
+
pending.MAX_ENTRIES = 1
|
|
4479
|
+
first = pending.enqueue(_payload(content="m0", doc="d0"), RuntimeError("x"))
|
|
4480
|
+
self.assertIsNotNone(first, "fixture")
|
|
4481
|
+
self._corrupt_ledger()
|
|
4482
|
+
|
|
4483
|
+
with redirect_stderr(io.StringIO()):
|
|
4484
|
+
second = pending.enqueue(_payload(content="m1", doc="d1"), RuntimeError("x"))
|
|
4485
|
+
|
|
4486
|
+
self.assertIsNotNone(second, "a corrupt ledger must not refuse a memory")
|
|
4487
|
+
with open(second, encoding="utf-8") as f:
|
|
4488
|
+
self.assertEqual(json.load(f)["content"], "m1")
|
|
4489
|
+
|
|
4490
|
+
def test_a_surrogate_in_a_trimmed_name_does_not_raise(self):
|
|
4491
|
+
"""The WRITE-side twin: ``UnicodeEncodeError`` is a ``ValueError`` too."""
|
|
4492
|
+
os.makedirs(self._dir, mode=0o700, exist_ok=True)
|
|
4493
|
+
# A name straight off `os.listdir` on a filesystem holding
|
|
4494
|
+
# undecodable bytes carries surrogate escapes (PEP 383).
|
|
4495
|
+
pending._log_archive_trim(
|
|
4496
|
+
"0000000001000-k-\udcff.json", 10, "count", "pending-duplicate", 1, 10
|
|
4497
|
+
)
|
|
4498
|
+
|
|
4499
|
+
def test_a_corrupt_ledger_is_still_repaired_on_the_next_clean_write(self):
|
|
4500
|
+
"""Swallowing the codec error must not wedge the ledger forever.
|
|
4501
|
+
|
|
4502
|
+
The rotate is attempted again on every append, so once the file is
|
|
4503
|
+
replaced (or truncated) by anything the ledger resumes recording —
|
|
4504
|
+
the guard degrades observability for the corrupt file only, it does
|
|
4505
|
+
not permanently disable the eviction record.
|
|
4506
|
+
"""
|
|
4507
|
+
self._corrupt_ledger()
|
|
4508
|
+
with redirect_stderr(io.StringIO()):
|
|
4509
|
+
pending._log_eviction("aaaa.json", 10, "count", 1, 10)
|
|
4510
|
+
# Corrupt bytes are still there (we never destroy an operator's
|
|
4511
|
+
# file) but the new line was appended regardless.
|
|
4512
|
+
with open(pending.evictions_log_path(), "rb") as f:
|
|
4513
|
+
raw = f.read()
|
|
4514
|
+
self.assertIn(b"evicted=aaaa.json", raw)
|
|
4515
|
+
|
|
3707
4516
|
|
|
3708
4517
|
if __name__ == "__main__":
|
|
3709
4518
|
unittest.main()
|