switchroom 0.19.18 → 0.19.22
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 +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -0,0 +1,2275 @@
|
|
|
1
|
+
"""Queue eviction/dedupe + two-phase backlog drain (switchroom #3596).
|
|
2
|
+
|
|
3
|
+
These live under ``scripts/tests/`` deliberately: that is the ONLY python
|
|
4
|
+
test directory CI discovers (``ci-tests-python.yml:63`` and
|
|
5
|
+
``ci-full.yml:140`` both run ``python3 -m unittest discover tests/`` with
|
|
6
|
+
``working-directory: vendor/hindsight-memory/scripts``). The sibling suite
|
|
7
|
+
at ``vendor/hindsight-memory/tests/`` is never executed by CI, so a
|
|
8
|
+
regression test placed there would gate nothing.
|
|
9
|
+
|
|
10
|
+
The behaviours pinned here are the ones a well-meaning refactor would
|
|
11
|
+
otherwise undo:
|
|
12
|
+
* eviction sheds the OLDEST entry, never the incoming newest one;
|
|
13
|
+
* reconcile-before-retain, so an already-durable document is not
|
|
14
|
+
re-extracted at LLM cost;
|
|
15
|
+
* commit-before-delete -- a 200 is an ack, not proof;
|
|
16
|
+
* backlog concurrency defaults to 1 (the model pool is fleet-shared);
|
|
17
|
+
* the stall guard does not overshoot under concurrency.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
import unittest
|
|
28
|
+
import unittest.mock
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from contextlib import redirect_stderr
|
|
32
|
+
|
|
33
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
34
|
+
if SCRIPTS_DIR not in sys.path:
|
|
35
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
36
|
+
|
|
37
|
+
import drain_pending # noqa: E402
|
|
38
|
+
import lib.pending as pending # noqa: E402
|
|
39
|
+
import lib.retain_split as retain_split # noqa: E402
|
|
40
|
+
|
|
41
|
+
CONFIG = {"debug": False}
|
|
42
|
+
|
|
43
|
+
#: A session id of the shape ``retain.py`` sees (a plain uuid).
|
|
44
|
+
SESSION = "11111111-2222-4333-8444-555555555555"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _uuid(n: int) -> str:
|
|
48
|
+
h = f"{n:032x}"
|
|
49
|
+
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cd_doc(i: int = 1) -> str:
|
|
53
|
+
"""A POST-#3244 content-derived document_id.
|
|
54
|
+
|
|
55
|
+
``retain.slice_document_id`` → ``{session}-r{start_uuid}-{end_uuid}``.
|
|
56
|
+
Presence-only reconcile is gated on this shape, so the drain tests must
|
|
57
|
+
use it rather than a stand-in like ``doc-1``.
|
|
58
|
+
"""
|
|
59
|
+
return f"{SESSION}-r{_uuid(i)}-{_uuid(i + 1000)}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _payload(bank="bank-a", content="hello", doc=None):
|
|
63
|
+
if doc is None:
|
|
64
|
+
doc = _cd_doc(1)
|
|
65
|
+
return {
|
|
66
|
+
"api_url": "http://127.0.0.1:9/none",
|
|
67
|
+
"api_token": None,
|
|
68
|
+
"bank_id": bank,
|
|
69
|
+
"document_id": doc,
|
|
70
|
+
"content": content,
|
|
71
|
+
"context": None,
|
|
72
|
+
"metadata": {},
|
|
73
|
+
"tags": None,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _QueueTempDirMixin:
|
|
78
|
+
#: env knobs a case may set; restored in tearDown so none can leak.
|
|
79
|
+
ENV_KEYS = (
|
|
80
|
+
"HINDSIGHT_PENDING_DIR",
|
|
81
|
+
"HINDSIGHT_PENDING_EVICTED_DIR",
|
|
82
|
+
"HINDSIGHT_PENDING_RECONCILED_DIR",
|
|
83
|
+
# Absent from this tuple the budget/clamp cases ran against whatever
|
|
84
|
+
# the ambient env said — vacuously green in CI, where nothing sets it.
|
|
85
|
+
"HINDSIGHT_DRAIN_TIMEOUT",
|
|
86
|
+
"HINDSIGHT_DRAIN_BUDGET_S",
|
|
87
|
+
"HINDSIGHT_DRAIN_BACKLOG_BUDGET_S",
|
|
88
|
+
"HINDSIGHT_DRAIN_BACKLOG_TIMEOUT",
|
|
89
|
+
"HINDSIGHT_DRAIN_CONCURRENCY",
|
|
90
|
+
"HINDSIGHT_DRAIN_SLEEP_S",
|
|
91
|
+
"HINDSIGHT_DRAIN_P95_CMD",
|
|
92
|
+
"HINDSIGHT_DRAIN_P95_BACKOFF_MS",
|
|
93
|
+
# `_backlog_timeout` derives its default from this (#3610), so an
|
|
94
|
+
# ambient value would silently move the assertions below.
|
|
95
|
+
"HINDSIGHT_RETAIN_CLIENT_DEADLINE_S",
|
|
96
|
+
# The retain content bound, which decides whether `enqueue` splits.
|
|
97
|
+
"HINDSIGHT_RETAIN_MAX_CONTENT_CHARS",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def setUp(self):
|
|
101
|
+
self._env_prev = {k: os.environ.get(k) for k in self.ENV_KEYS}
|
|
102
|
+
self._tmp = tempfile.mkdtemp(prefix="hindsight-pending-test-")
|
|
103
|
+
self._dir = os.path.join(self._tmp, "pending-retains")
|
|
104
|
+
os.environ["HINDSIGHT_PENDING_DIR"] = self._dir
|
|
105
|
+
# Backlog replay paces itself by default; tests must not sleep.
|
|
106
|
+
os.environ["HINDSIGHT_DRAIN_SLEEP_S"] = "0"
|
|
107
|
+
# Hermeticity: a case that cares about the clamp sets this itself;
|
|
108
|
+
# nothing may inherit an ambient value from the caller's shell.
|
|
109
|
+
os.environ.pop("HINDSIGHT_DRAIN_TIMEOUT", None)
|
|
110
|
+
os.environ.pop("HINDSIGHT_DRAIN_BUDGET_S", None)
|
|
111
|
+
self._caps_prev = (pending.MAX_ENTRIES, pending.MAX_BYTES)
|
|
112
|
+
|
|
113
|
+
def tearDown(self):
|
|
114
|
+
pending.MAX_ENTRIES, pending.MAX_BYTES = self._caps_prev
|
|
115
|
+
for k, v in self._env_prev.items():
|
|
116
|
+
if v is None:
|
|
117
|
+
os.environ.pop(k, None)
|
|
118
|
+
else:
|
|
119
|
+
os.environ[k] = v
|
|
120
|
+
shutil.rmtree(self._tmp, ignore_errors=True)
|
|
121
|
+
|
|
122
|
+
def _names(self):
|
|
123
|
+
return sorted(n for n in os.listdir(self._dir) if n.endswith(".json"))
|
|
124
|
+
|
|
125
|
+
def _archive_names(self):
|
|
126
|
+
try:
|
|
127
|
+
return sorted(os.listdir(pending.evicted_dir()))
|
|
128
|
+
except OSError:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
def _reconciled_names(self):
|
|
132
|
+
try:
|
|
133
|
+
return sorted(os.listdir(pending.reconciled_dir()))
|
|
134
|
+
except OSError:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class EvictionTest(_QueueTempDirMixin, unittest.TestCase):
|
|
139
|
+
"""A full queue must shed the OLDEST entry, not refuse the newest.
|
|
140
|
+
|
|
141
|
+
The pre-fix behaviour returned ``None`` at the cap, i.e. it threw away
|
|
142
|
+
the turn that had just happened -- the one most likely to still matter
|
|
143
|
+
-- and kept a queue full of stale entries instead.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def test_full_queue_evicts_oldest_and_keeps_the_newest(self):
|
|
147
|
+
pending.MAX_ENTRIES = 3
|
|
148
|
+
# Entry filenames are `<unix-ms>-<uuid>.json` and FIFO order is the
|
|
149
|
+
# lexicographic sort of that name. Real entries are milliseconds
|
|
150
|
+
# apart; a test that enqueues four inside one millisecond would
|
|
151
|
+
# tie-break on the random uuid instead, so pin the clock.
|
|
152
|
+
clock = iter(1000.0 + i for i in range(10))
|
|
153
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
154
|
+
first = pending.enqueue(
|
|
155
|
+
_payload(content="oldest", doc="d0"), RuntimeError("x")
|
|
156
|
+
)
|
|
157
|
+
for i in range(1, 3):
|
|
158
|
+
pending.enqueue(
|
|
159
|
+
_payload(content=f"mid-{i}", doc=f"d{i}"), RuntimeError("x")
|
|
160
|
+
)
|
|
161
|
+
self.assertEqual(len(self._names()), 3)
|
|
162
|
+
|
|
163
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
164
|
+
newest = pending.enqueue(
|
|
165
|
+
_payload(content="NEWEST", doc="d9"), RuntimeError("x")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
self.assertIsNotNone(newest, "the incoming entry must never be refused")
|
|
169
|
+
self.assertEqual(len(self._names()), 3, "cap still honoured")
|
|
170
|
+
self.assertFalse(os.path.exists(first), "the OLDEST entry was evicted")
|
|
171
|
+
with open(newest, encoding="utf-8") as f:
|
|
172
|
+
self.assertEqual(json.load(f)["content"], "NEWEST")
|
|
173
|
+
self.assertIn("evicted OLDEST", err.getvalue())
|
|
174
|
+
|
|
175
|
+
def test_eviction_archives_rather_than_deletes(self):
|
|
176
|
+
pending.MAX_ENTRIES = 2
|
|
177
|
+
pending.enqueue(_payload(content="a", doc="d0"), RuntimeError("x"))
|
|
178
|
+
pending.enqueue(_payload(content="b", doc="d1"), RuntimeError("x"))
|
|
179
|
+
with redirect_stderr(io.StringIO()):
|
|
180
|
+
pending.enqueue(_payload(content="c", doc="d2"), RuntimeError("x"))
|
|
181
|
+
self.assertEqual(len(self._archive_names()), 1)
|
|
182
|
+
|
|
183
|
+
def test_a_full_disk_evicts_the_oldest_live_entry_outright_and_says_so(self):
|
|
184
|
+
"""The bound on "the entry is never deleted" (#3599 review R4-M1).
|
|
185
|
+
|
|
186
|
+
Three documents in this PR asserted a structural invariant the code
|
|
187
|
+
does not have — that no ``os.remove`` in ``pending.py`` can touch a
|
|
188
|
+
LIVE queue entry. ``_evict_to_fit``'s ``OSError`` fallback can, and
|
|
189
|
+
under sustained ENOSPC it does: ``archive_reconciled`` keeps entries
|
|
190
|
+
queued, the queue fills, this fires, its own archive move fails for
|
|
191
|
+
the same reason, and the oldest live entries are removed to keep
|
|
192
|
+
accepting the newest.
|
|
193
|
+
|
|
194
|
+
That behaviour is ACCEPTED — a queue must be bounded by something,
|
|
195
|
+
and the newest turn is the one most likely to still matter — but it
|
|
196
|
+
is loss, so what is pinned here is that it is loud and correctly
|
|
197
|
+
labelled, not that it doesn't happen. The ``+archive-failed`` reason
|
|
198
|
+
is the operator's only signal that the payload is GONE rather than
|
|
199
|
+
merely shed, so it is asserted, not just the eviction.
|
|
200
|
+
"""
|
|
201
|
+
pending.MAX_ENTRIES = 3
|
|
202
|
+
for i in range(3):
|
|
203
|
+
pending.enqueue(_payload(content=f"c{i}", doc=f"d{i}"), RuntimeError("x"))
|
|
204
|
+
before = self._names()
|
|
205
|
+
self.assertEqual(len(before), 3)
|
|
206
|
+
|
|
207
|
+
with unittest.mock.patch.object(
|
|
208
|
+
pending.shutil, "move", side_effect=OSError(28, "No space left on device")
|
|
209
|
+
):
|
|
210
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
211
|
+
got = pending.enqueue(
|
|
212
|
+
_payload(content="newest", doc="d-new"), RuntimeError("x")
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
self.assertIsNotNone(got, "the newest memory is still accepted")
|
|
216
|
+
names = self._names()
|
|
217
|
+
self.assertEqual(len(names), 3, "the queue stayed bounded")
|
|
218
|
+
self.assertNotIn(before[0], names, "the OLDEST live entry was removed")
|
|
219
|
+
# Set comparison, not slice: these are enqueued inside one
|
|
220
|
+
# millisecond, so their relative order tie-breaks on the dupe key
|
|
221
|
+
# (arbitrary but total — see enqueue()).
|
|
222
|
+
self.assertEqual(
|
|
223
|
+
set(names),
|
|
224
|
+
set(before[1:]) | {os.path.basename(got)},
|
|
225
|
+
"only the oldest went, and the newest arrived",
|
|
226
|
+
)
|
|
227
|
+
self.assertEqual(
|
|
228
|
+
self._archive_names(), [], "the archive move failed, so no copy was kept"
|
|
229
|
+
)
|
|
230
|
+
self.assertIn("evicted OLDEST entry", err.getvalue())
|
|
231
|
+
|
|
232
|
+
with open(pending.evictions_log_path(), encoding="utf-8") as f:
|
|
233
|
+
line = f.read().strip()
|
|
234
|
+
self.assertIn(f"evicted={before[0]}", line)
|
|
235
|
+
self.assertIn(
|
|
236
|
+
"+archive-failed",
|
|
237
|
+
line,
|
|
238
|
+
"the ledger must distinguish 'shed into the archive' from "
|
|
239
|
+
"'removed outright' — they are different categories of loss",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def test_byte_cap_also_triggers_eviction(self):
|
|
243
|
+
"""Content spans 499 B .. 744 KB, so a count cap alone bounds disk
|
|
244
|
+
only to within ~1500x. Both caps are load-bearing."""
|
|
245
|
+
pending.MAX_ENTRIES = 1000
|
|
246
|
+
pending.enqueue(_payload(content="x" * 4000, doc="d0"), RuntimeError("x"))
|
|
247
|
+
pending.MAX_BYTES = 5000
|
|
248
|
+
with redirect_stderr(io.StringIO()):
|
|
249
|
+
pending.enqueue(_payload(content="y" * 4000, doc="d1"), RuntimeError("x"))
|
|
250
|
+
self.assertEqual(len(self._names()), 1)
|
|
251
|
+
self.assertEqual(len(self._archive_names()), 1)
|
|
252
|
+
|
|
253
|
+
def test_eviction_is_logged_to_the_ledger(self):
|
|
254
|
+
"""Eviction is not silent loss, but it IS loss -- doctor reads this."""
|
|
255
|
+
pending.MAX_ENTRIES = 1
|
|
256
|
+
pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
|
|
257
|
+
with redirect_stderr(io.StringIO()):
|
|
258
|
+
pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
|
|
259
|
+
with open(pending.evictions_log_path(), encoding="utf-8") as f:
|
|
260
|
+
line = f.read().strip()
|
|
261
|
+
self.assertIn("evicted=", line)
|
|
262
|
+
self.assertIn("reason=count", line)
|
|
263
|
+
self.assertIn("queue_depth=", line)
|
|
264
|
+
|
|
265
|
+
def test_archive_is_itself_bounded(self):
|
|
266
|
+
"""Eviction must not merely relocate the disk problem."""
|
|
267
|
+
pending.MAX_ENTRIES = 1
|
|
268
|
+
prev = pending.ARCHIVE_MAX_ENTRIES
|
|
269
|
+
pending.ARCHIVE_MAX_ENTRIES = 2
|
|
270
|
+
try:
|
|
271
|
+
with redirect_stderr(io.StringIO()):
|
|
272
|
+
for i in range(6):
|
|
273
|
+
pending.enqueue(_payload(doc=f"d{i}"), RuntimeError("x"))
|
|
274
|
+
self.assertLessEqual(len(self._archive_names()), 2)
|
|
275
|
+
finally:
|
|
276
|
+
pending.ARCHIVE_MAX_ENTRIES = prev
|
|
277
|
+
|
|
278
|
+
def test_archive_trim_keeps_the_newest_evictions(self):
|
|
279
|
+
"""Direction matters: trimming the wrong end keeps the stale tail.
|
|
280
|
+
|
|
281
|
+
``test_archive_is_itself_bounded`` only pins the COUNT, so reversing
|
|
282
|
+
the trim direction leaves it green while the archive discards
|
|
283
|
+
exactly what was just shed.
|
|
284
|
+
"""
|
|
285
|
+
pending.MAX_ENTRIES = 1
|
|
286
|
+
prev = pending.ARCHIVE_MAX_ENTRIES
|
|
287
|
+
pending.ARCHIVE_MAX_ENTRIES = 2
|
|
288
|
+
try:
|
|
289
|
+
clock = iter(1000.0 + i for i in range(20))
|
|
290
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
291
|
+
with redirect_stderr(io.StringIO()):
|
|
292
|
+
paths = [
|
|
293
|
+
pending.enqueue(_payload(doc=f"d{i}"), RuntimeError("x"))
|
|
294
|
+
for i in range(5)
|
|
295
|
+
]
|
|
296
|
+
evicted_names = [os.path.basename(p) for p in paths[:-1]]
|
|
297
|
+
self.assertEqual(
|
|
298
|
+
self._archive_names(),
|
|
299
|
+
sorted(evicted_names[-2:]),
|
|
300
|
+
"the archive must keep the most recently evicted entries",
|
|
301
|
+
)
|
|
302
|
+
finally:
|
|
303
|
+
pending.ARCHIVE_MAX_ENTRIES = prev
|
|
304
|
+
|
|
305
|
+
def test_eviction_ledger_trim_keeps_the_NEWEST_lines(self):
|
|
306
|
+
"""The ledger is the operator's record of what was shed.
|
|
307
|
+
|
|
308
|
+
Trimming the head off (keeping the oldest lines) would leave doctor
|
|
309
|
+
reading a frozen prefix while every recent eviction fell out — and
|
|
310
|
+
the existing ledger test only greps for one line, so it stays green
|
|
311
|
+
either way.
|
|
312
|
+
"""
|
|
313
|
+
pending.MAX_ENTRIES = 1
|
|
314
|
+
log = pending.evictions_log_path()
|
|
315
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
316
|
+
with open(log, "w", encoding="utf-8") as f:
|
|
317
|
+
for i in range(500):
|
|
318
|
+
f.write(f"ANCIENT-{i:03d} evicted=old bytes=0 reason=count\n")
|
|
319
|
+
|
|
320
|
+
prev = (pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES)
|
|
321
|
+
pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES = 1, 3
|
|
322
|
+
try:
|
|
323
|
+
pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
|
|
324
|
+
with redirect_stderr(io.StringIO()):
|
|
325
|
+
pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
|
|
326
|
+
finally:
|
|
327
|
+
pending.EVICTIONS_LOG_MAX_BYTES, pending.EVICTIONS_LOG_KEEP_LINES = prev
|
|
328
|
+
|
|
329
|
+
with open(log, encoding="utf-8") as f:
|
|
330
|
+
kept = [ln.rstrip("\n") for ln in f]
|
|
331
|
+
self.assertEqual(len(kept), 3, "trimmed to KEEP_LINES")
|
|
332
|
+
self.assertIn("evicted=", kept[-1], "the newest line is the real eviction")
|
|
333
|
+
self.assertEqual(
|
|
334
|
+
[ln for ln in kept if ln.startswith("ANCIENT-000")],
|
|
335
|
+
[],
|
|
336
|
+
"the OLDEST lines must be the ones dropped",
|
|
337
|
+
)
|
|
338
|
+
self.assertTrue(
|
|
339
|
+
kept[0].startswith("ANCIENT-498"),
|
|
340
|
+
f"kept the wrong end of the ledger: {kept[0]!r}",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def test_the_byte_cap_is_a_boundary_not_an_approximation(self):
|
|
344
|
+
"""`nbytes + incoming > MAX_BYTES` evicts; `== MAX_BYTES` does not.
|
|
345
|
+
|
|
346
|
+
Off-by-one here is silent: it either sheds a memory that fit, or
|
|
347
|
+
overshoots the cap the operator set.
|
|
348
|
+
"""
|
|
349
|
+
pending.MAX_ENTRIES = 1000
|
|
350
|
+
first = pending.enqueue(_payload(content="x" * 100, doc="d0"), RuntimeError("x"))
|
|
351
|
+
second = pending.enqueue(_payload(content="y" * 100, doc="d1"), RuntimeError("x"))
|
|
352
|
+
exact = os.path.getsize(first) + os.path.getsize(second)
|
|
353
|
+
os.remove(second)
|
|
354
|
+
|
|
355
|
+
# Exactly at the cap: the incoming entry fits, nothing may be evicted.
|
|
356
|
+
pending.MAX_BYTES = exact
|
|
357
|
+
with redirect_stderr(io.StringIO()):
|
|
358
|
+
pending.enqueue(_payload(content="y" * 100, doc="d1"), RuntimeError("x"))
|
|
359
|
+
self.assertEqual(len(self._names()), 2, "an entry that fits exactly must fit")
|
|
360
|
+
self.assertEqual(self._archive_names(), [], "nothing was over the cap")
|
|
361
|
+
|
|
362
|
+
# One byte tighter: the same pair no longer fits, so the oldest sheds.
|
|
363
|
+
os.remove(sorted(os.path.join(self._dir, n) for n in self._names())[-1])
|
|
364
|
+
pending.MAX_BYTES = exact - 1
|
|
365
|
+
with redirect_stderr(io.StringIO()):
|
|
366
|
+
pending.enqueue(_payload(content="y" * 100, doc="d1"), RuntimeError("x"))
|
|
367
|
+
self.assertEqual(len(self._archive_names()), 1, "one byte over must evict")
|
|
368
|
+
self.assertEqual(len(self._names()), 1)
|
|
369
|
+
|
|
370
|
+
def test_ledgers_live_outside_the_queue_dir(self):
|
|
371
|
+
"""So they can never be listed as an entry, drained, or counted."""
|
|
372
|
+
pending.MAX_ENTRIES = 1
|
|
373
|
+
pending.enqueue(_payload(doc="d0"), RuntimeError("x"))
|
|
374
|
+
with redirect_stderr(io.StringIO()):
|
|
375
|
+
pending.enqueue(_payload(doc="d1"), RuntimeError("x"))
|
|
376
|
+
for p in (pending.evictions_log_path(), pending.drops_path()):
|
|
377
|
+
self.assertNotEqual(os.path.dirname(p), self._dir.rstrip("/"))
|
|
378
|
+
self.assertEqual(pending.count(), 1)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class QueueOrderTest(_QueueTempDirMixin, unittest.TestCase):
|
|
382
|
+
"""What the filename ordering does and does not promise.
|
|
383
|
+
|
|
384
|
+
FIFO is what eviction and the drain both rely on, and it is exact only
|
|
385
|
+
down to the millisecond: the name carries no finer age information, so
|
|
386
|
+
entries sharing a millisecond tie-break on the dupe key — stable and
|
|
387
|
+
total, but arbitrary. The comment used to claim plain oldest-first.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
def _enqueue_at(self, ms, content):
|
|
391
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: ms / 1000.0):
|
|
392
|
+
return pending.enqueue(_payload(content=content, doc=f"d-{content}"),
|
|
393
|
+
RuntimeError("x"))
|
|
394
|
+
|
|
395
|
+
def test_entries_are_ordered_by_enqueue_millisecond(self):
|
|
396
|
+
newest = self._enqueue_at(1_700_000_002_000, "third")
|
|
397
|
+
oldest = self._enqueue_at(1_700_000_000_000, "first")
|
|
398
|
+
middle = self._enqueue_at(1_700_000_001_000, "second")
|
|
399
|
+
self.assertEqual(
|
|
400
|
+
[p for p, _ in pending.iter_entries()],
|
|
401
|
+
[oldest, middle, newest],
|
|
402
|
+
"write order must not matter; the millisecond stamp orders them",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def test_same_millisecond_entries_are_ordered_stably_but_arbitrarily(self):
|
|
406
|
+
paths = [self._enqueue_at(1_700_000_000_000, f"c{i}") for i in range(4)]
|
|
407
|
+
got = [p for p, _ in pending.iter_entries()]
|
|
408
|
+
self.assertEqual(sorted(got), sorted(paths), "every entry is listed once")
|
|
409
|
+
self.assertEqual(got, sorted(got), "the order is the name sort, not enqueue order")
|
|
410
|
+
self.assertEqual(got, [p for p, _ in pending.iter_entries()], "and it is stable")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
class DedupeTest(_QueueTempDirMixin, unittest.TestCase):
|
|
414
|
+
"""``reconcile_tail`` re-enqueues the same slice on every boot until its
|
|
415
|
+
watermark is confirmed -- 63% of one measured fleet queue was dupes."""
|
|
416
|
+
|
|
417
|
+
def test_identical_entry_returns_the_existing_path(self):
|
|
418
|
+
a = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
|
|
419
|
+
b = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
|
|
420
|
+
self.assertEqual(a, b)
|
|
421
|
+
self.assertEqual(pending.count(), 1)
|
|
422
|
+
|
|
423
|
+
def test_different_content_is_not_deduped(self):
|
|
424
|
+
pending.enqueue(_payload(content="one", doc="d1"), RuntimeError("x"))
|
|
425
|
+
pending.enqueue(_payload(content="two", doc="d1"), RuntimeError("x"))
|
|
426
|
+
self.assertEqual(pending.count(), 2)
|
|
427
|
+
|
|
428
|
+
def test_dedupe_survives_an_attempted_entry(self):
|
|
429
|
+
"""The scenario the guard exists for, and the one it used to miss.
|
|
430
|
+
|
|
431
|
+
The queued copy is ALWAYS post-``update_attempt`` by the time
|
|
432
|
+
``reconcile_tail`` re-enqueues, because the SessionStart drain
|
|
433
|
+
attempts every entry on every boot. A size-indexed dedupe therefore
|
|
434
|
+
matched nothing in steady state and the queue grew by one duplicate
|
|
435
|
+
per boot.
|
|
436
|
+
"""
|
|
437
|
+
first = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
|
|
438
|
+
_, entry = pending.iter_entries()[0]
|
|
439
|
+
pending.update_attempt(first, entry, TimeoutError("upstream timed out"))
|
|
440
|
+
self.assertNotEqual(
|
|
441
|
+
os.path.getsize(first),
|
|
442
|
+
len(json.dumps(entry, ensure_ascii=False).encode("utf-8")) - 1,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
again = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
|
|
446
|
+
self.assertEqual(again, first, "attempted entry must still dedupe")
|
|
447
|
+
self.assertEqual(pending.count(), 1)
|
|
448
|
+
|
|
449
|
+
def test_dedupe_survives_a_different_error_message(self):
|
|
450
|
+
"""The error string is not part of the memory's identity."""
|
|
451
|
+
a = pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("short"))
|
|
452
|
+
b = pending.enqueue(
|
|
453
|
+
_payload(content="same", doc="d1"), ConnectionError("a much longer error")
|
|
454
|
+
)
|
|
455
|
+
self.assertEqual(a, b)
|
|
456
|
+
self.assertEqual(pending.count(), 1)
|
|
457
|
+
|
|
458
|
+
def test_dedupe_reads_no_files(self):
|
|
459
|
+
"""The key is in the filename, so a lookup is a listing scan only."""
|
|
460
|
+
pending.enqueue(_payload(content="same", doc="d1"), RuntimeError("x"))
|
|
461
|
+
real_open = open
|
|
462
|
+
queue_dir = self._dir.rstrip("/")
|
|
463
|
+
|
|
464
|
+
def no_entry_reads(path, *a, **kw):
|
|
465
|
+
if os.path.dirname(str(path)) == queue_dir:
|
|
466
|
+
raise AssertionError(f"dedupe opened a queue entry: {path}")
|
|
467
|
+
return real_open(path, *a, **kw)
|
|
468
|
+
|
|
469
|
+
with unittest.mock.patch("builtins.open", no_entry_reads):
|
|
470
|
+
self.assertIsNotNone(pending._find_duplicate(self._dir, pending._dupe_key(
|
|
471
|
+
{"bank_id": "bank-a", "document_id": "d1", "content": "same"}
|
|
472
|
+
)))
|
|
473
|
+
|
|
474
|
+
def test_different_banks_do_not_collide(self):
|
|
475
|
+
pending.enqueue(_payload(bank="bank-a", content="same"), RuntimeError("x"))
|
|
476
|
+
pending.enqueue(_payload(bank="bank-b", content="same"), RuntimeError("x"))
|
|
477
|
+
self.assertEqual(pending.count(), 2)
|
|
478
|
+
|
|
479
|
+
def test_entry_without_document_id_is_always_kept(self):
|
|
480
|
+
"""No document_id => identity cannot be established => never merge."""
|
|
481
|
+
p = _payload()
|
|
482
|
+
p.pop("document_id")
|
|
483
|
+
pending.enqueue(dict(p), RuntimeError("x"))
|
|
484
|
+
pending.enqueue(dict(p), RuntimeError("x"))
|
|
485
|
+
self.assertEqual(pending.count(), 2)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class DropLedgerTest(_QueueTempDirMixin, unittest.TestCase):
|
|
489
|
+
"""Residual drops -- the entry could not be written even after eviction."""
|
|
490
|
+
|
|
491
|
+
def _fail_queue_writes(self):
|
|
492
|
+
real_open = open
|
|
493
|
+
queue_dir = self._dir.rstrip("/")
|
|
494
|
+
|
|
495
|
+
def boom(path, *a, **kw):
|
|
496
|
+
p = str(path)
|
|
497
|
+
if p.endswith(".tmp") and os.path.dirname(p) == queue_dir:
|
|
498
|
+
raise OSError(28, "No space left on device")
|
|
499
|
+
return real_open(path, *a, **kw)
|
|
500
|
+
|
|
501
|
+
return unittest.mock.patch("builtins.open", boom)
|
|
502
|
+
|
|
503
|
+
def test_unwritable_queue_records_a_drop_and_returns_none(self):
|
|
504
|
+
with self._fail_queue_writes():
|
|
505
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
506
|
+
got = pending.enqueue(_payload(), RuntimeError("upstream down"))
|
|
507
|
+
|
|
508
|
+
self.assertIsNone(got, "callers handle None; they must still get it")
|
|
509
|
+
self.assertIn("permanently lost", err.getvalue())
|
|
510
|
+
ledger = pending.read_drops()
|
|
511
|
+
self.assertEqual(ledger["count"], 1)
|
|
512
|
+
self.assertEqual(ledger["last_error_class"], "OSError")
|
|
513
|
+
self.assertEqual(ledger["last_bank_id"], "bank-a")
|
|
514
|
+
|
|
515
|
+
def test_first_dropped_at_is_not_overwritten_by_later_drops(self):
|
|
516
|
+
with self._fail_queue_writes():
|
|
517
|
+
with redirect_stderr(io.StringIO()):
|
|
518
|
+
pending.enqueue(_payload(content="a"), RuntimeError("x"))
|
|
519
|
+
first = pending.read_drops()["first_dropped_at"]
|
|
520
|
+
pending.enqueue(_payload(content="b"), RuntimeError("x"))
|
|
521
|
+
ledger = pending.read_drops()
|
|
522
|
+
self.assertEqual(ledger["count"], 2)
|
|
523
|
+
self.assertEqual(ledger["first_dropped_at"], first)
|
|
524
|
+
|
|
525
|
+
def test_read_drops_survives_a_non_utf8_ledger(self):
|
|
526
|
+
"""The narrow-catch bug: UnicodeDecodeError is NOT a JSONDecodeError.
|
|
527
|
+
|
|
528
|
+
``record_drop()`` reads the ledger first, so a corrupt ledger used
|
|
529
|
+
to turn ``enqueue()`` from documented-returns-None into a raiser at
|
|
530
|
+
exactly the moment the queue is under stress -- breaking
|
|
531
|
+
session_end.py / subagent_retain.py, which handle None.
|
|
532
|
+
"""
|
|
533
|
+
os.makedirs(os.path.dirname(pending.drops_path()), exist_ok=True)
|
|
534
|
+
with open(pending.drops_path(), "wb") as f:
|
|
535
|
+
f.write(b"\xff\xfe\x00not utf-8 at all")
|
|
536
|
+
self.assertEqual(pending.read_drops(), {})
|
|
537
|
+
with redirect_stderr(io.StringIO()):
|
|
538
|
+
self.assertEqual(pending.record_drop(_payload(), RuntimeError("x")), 1)
|
|
539
|
+
|
|
540
|
+
def test_read_drops_survives_malformed_json(self):
|
|
541
|
+
os.makedirs(os.path.dirname(pending.drops_path()), exist_ok=True)
|
|
542
|
+
with open(pending.drops_path(), "w", encoding="utf-8") as f:
|
|
543
|
+
f.write("{not json")
|
|
544
|
+
self.assertEqual(pending.read_drops(), {})
|
|
545
|
+
|
|
546
|
+
def test_error_messages_are_truncated(self):
|
|
547
|
+
"""An unbounded upstream error body inflates the queue against
|
|
548
|
+
MAX_BYTES for no diagnostic gain."""
|
|
549
|
+
path = pending.enqueue(_payload(), RuntimeError("E" * 5000))
|
|
550
|
+
with open(path, encoding="utf-8") as f:
|
|
551
|
+
stored = json.load(f)["error_message"]
|
|
552
|
+
self.assertLessEqual(len(stored), pending.MAX_ERROR_MESSAGE_CHARS + 20)
|
|
553
|
+
self.assertTrue(stored.endswith("[truncated]"))
|
|
554
|
+
|
|
555
|
+
def test_oversized_entry_is_refused_without_wiping_the_queue(self):
|
|
556
|
+
"""One entry must never cost the whole queue.
|
|
557
|
+
|
|
558
|
+
With no guard the eviction loop can never satisfy its condition, so
|
|
559
|
+
it evicts EVERY entry and writes the oversized one anyway -- and
|
|
560
|
+
the bounded archive then discards most of what it just shed.
|
|
561
|
+
"""
|
|
562
|
+
pending.MAX_BYTES = 100_000
|
|
563
|
+
for i in range(20):
|
|
564
|
+
pending.enqueue(_payload(content=f"turn-{i}", doc=f"d{i}"), RuntimeError("x"))
|
|
565
|
+
self.assertEqual(pending.count(), 20)
|
|
566
|
+
|
|
567
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
568
|
+
got = pending.enqueue(
|
|
569
|
+
_payload(content="z" * 200_000, doc="huge"), RuntimeError("x")
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
self.assertIsNone(got, "the oversized entry is refused")
|
|
573
|
+
self.assertEqual(pending.count(), 20, "every other entry survived")
|
|
574
|
+
self.assertIn("larger than the whole", err.getvalue())
|
|
575
|
+
self.assertEqual(pending.read_drops()["count"], 1)
|
|
576
|
+
|
|
577
|
+
def test_a_memory_with_more_parts_than_the_count_cap_is_refused(self):
|
|
578
|
+
"""The count-cap sibling of the byte-cap guard (#3610).
|
|
579
|
+
|
|
580
|
+
Splitting turns one memory into N entries, so the count cap can be
|
|
581
|
+
blown the same way the byte cap can — and with a satisfiable eviction
|
|
582
|
+
loop the later parts shed the earlier ones plus everything already
|
|
583
|
+
queued, leaving a tail fragment and nothing else. Refuse instead.
|
|
584
|
+
"""
|
|
585
|
+
pending.MAX_ENTRIES = 4
|
|
586
|
+
pending.MAX_BYTES = 10**9 # only the COUNT cap may fire here
|
|
587
|
+
for i in range(3):
|
|
588
|
+
pending.enqueue(_payload(content=f"turn-{i}", doc=f"d{i}"), RuntimeError("x"))
|
|
589
|
+
self.assertEqual(pending.count(), 3)
|
|
590
|
+
|
|
591
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
592
|
+
try:
|
|
593
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
594
|
+
got = pending.enqueue(
|
|
595
|
+
_payload(content="z" * 30_000, doc=_cd_doc(9)), RuntimeError("x")
|
|
596
|
+
)
|
|
597
|
+
finally:
|
|
598
|
+
os.environ.pop("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS", None)
|
|
599
|
+
|
|
600
|
+
self.assertIsNone(got, "a 10-part memory under a 4-entry cap is refused")
|
|
601
|
+
self.assertEqual(pending.count(), 3, "every other entry survived")
|
|
602
|
+
self.assertIn("more than the whole", err.getvalue())
|
|
603
|
+
self.assertEqual(pending.read_drops()["count"], 1)
|
|
604
|
+
|
|
605
|
+
def test_a_split_that_fits_the_count_cap_is_still_queued_per_part(self):
|
|
606
|
+
"""The other side of that boundary: refusal must not swallow a
|
|
607
|
+
memory the queue can actually hold."""
|
|
608
|
+
pending.MAX_ENTRIES = 20
|
|
609
|
+
pending.MAX_BYTES = 10**9
|
|
610
|
+
os.environ["HINDSIGHT_RETAIN_MAX_CONTENT_CHARS"] = "3000"
|
|
611
|
+
try:
|
|
612
|
+
got = pending.enqueue(
|
|
613
|
+
_payload(content="z" * 30_000, doc=_cd_doc(9)), RuntimeError("x")
|
|
614
|
+
)
|
|
615
|
+
finally:
|
|
616
|
+
os.environ.pop("HINDSIGHT_RETAIN_MAX_CONTENT_CHARS", None)
|
|
617
|
+
|
|
618
|
+
self.assertIsNotNone(got)
|
|
619
|
+
self.assertEqual(pending.count(), 10, "one entry per part")
|
|
620
|
+
self.assertEqual(pending.read_drops(), {}, "nothing was dropped")
|
|
621
|
+
docs = sorted(e["document_id"] for _p, e in pending.iter_entries())
|
|
622
|
+
self.assertEqual(docs[0], f"{_cd_doc(9)}-p10of10")
|
|
623
|
+
for _p, entry in pending.iter_entries():
|
|
624
|
+
self.assertLessEqual(len(entry["content"]), 3000)
|
|
625
|
+
self.assertTrue(
|
|
626
|
+
pending.is_content_derived_document_id(entry["document_id"]),
|
|
627
|
+
"every queued part stays inside the free presence reconcile",
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def test_an_entry_exactly_at_the_byte_cap_is_enqueued_not_dropped(self):
|
|
631
|
+
"""``blob_bytes > MAX_BYTES``, not ``>=`` (#3599 review R4-La).
|
|
632
|
+
|
|
633
|
+
The boundary was pinned in ``_trim_dir``, ``_evict_to_fit``,
|
|
634
|
+
``_clamp`` and ``_env_num`` and missed here, so ``>`` → ``>=``
|
|
635
|
+
survived the suite. It is not equivalent and it is not cosmetic: an
|
|
636
|
+
entry whose serialised size lands exactly ON the cap DOES fit —
|
|
637
|
+
``_evict_to_fit``'s own condition is ``nbytes + incoming >
|
|
638
|
+
MAX_BYTES``, which an empty queue satisfies — so under the mutant a
|
|
639
|
+
writable, storable turn becomes a permanent RESIDUAL DROP. That is
|
|
640
|
+
the one outcome in this module that loses a memory outright, and it
|
|
641
|
+
would be reached by an entry the queue could have held.
|
|
642
|
+
"""
|
|
643
|
+
payload = _payload(content="q" * 500, doc="exact-fit")
|
|
644
|
+
probe = pending.enqueue(payload, RuntimeError("x"))
|
|
645
|
+
exact = os.path.getsize(probe)
|
|
646
|
+
os.remove(probe)
|
|
647
|
+
|
|
648
|
+
pending.MAX_BYTES = exact
|
|
649
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
650
|
+
got = pending.enqueue(payload, RuntimeError("x"))
|
|
651
|
+
|
|
652
|
+
self.assertIsNotNone(got, "an entry exactly at the cap fits within it")
|
|
653
|
+
self.assertEqual(os.path.getsize(got), exact)
|
|
654
|
+
self.assertEqual(pending.count(), 1)
|
|
655
|
+
self.assertNotIn("larger than the whole", err.getvalue())
|
|
656
|
+
self.assertEqual(pending.read_drops(), {}, "nothing was dropped")
|
|
657
|
+
|
|
658
|
+
def test_one_byte_over_the_byte_cap_is_refused(self):
|
|
659
|
+
"""The other side of the same boundary, so no single comparison
|
|
660
|
+
satisfies both cases."""
|
|
661
|
+
payload = _payload(content="q" * 500, doc="one-over")
|
|
662
|
+
probe = pending.enqueue(payload, RuntimeError("x"))
|
|
663
|
+
exact = os.path.getsize(probe)
|
|
664
|
+
os.remove(probe)
|
|
665
|
+
|
|
666
|
+
pending.MAX_BYTES = exact - 1
|
|
667
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
668
|
+
self.assertIsNone(pending.enqueue(payload, RuntimeError("x")))
|
|
669
|
+
self.assertIn("larger than the whole", err.getvalue())
|
|
670
|
+
self.assertEqual(pending.read_drops()["count"], 1)
|
|
671
|
+
|
|
672
|
+
def test_corrupt_entry_is_quarantined_not_skipped_forever(self):
|
|
673
|
+
"""A skipped corrupt entry is immortal: never reconciled, never
|
|
674
|
+
drained, never aged to .dead, but still counted in the depth."""
|
|
675
|
+
good = pending.enqueue(_payload(), RuntimeError("x"))
|
|
676
|
+
bad = os.path.join(self._dir, "1700000000000-deadbeefdead.json")
|
|
677
|
+
with open(bad, "w", encoding="utf-8") as f:
|
|
678
|
+
f.write("{ not json at all")
|
|
679
|
+
|
|
680
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
681
|
+
entries = pending.iter_entries()
|
|
682
|
+
|
|
683
|
+
self.assertEqual([p for p, _ in entries], [good])
|
|
684
|
+
self.assertFalse(os.path.exists(bad), "no longer occupying a queue slot")
|
|
685
|
+
self.assertEqual(pending.count(), 1, "no longer inflating the depth")
|
|
686
|
+
self.assertIn("quarantined", err.getvalue())
|
|
687
|
+
quarantined = os.path.join(
|
|
688
|
+
os.path.dirname(self._dir.rstrip("/")), "pending-corrupt"
|
|
689
|
+
)
|
|
690
|
+
self.assertEqual(os.listdir(quarantined), [os.path.basename(bad)])
|
|
691
|
+
|
|
692
|
+
def test_transient_read_error_is_skipped_not_quarantined(self):
|
|
693
|
+
"""An OSError is not evidence the payload is bad."""
|
|
694
|
+
pending.enqueue(_payload(), RuntimeError("x"))
|
|
695
|
+
real_open = open
|
|
696
|
+
|
|
697
|
+
def flaky(path, *a, **kw):
|
|
698
|
+
if str(path).endswith(".json"):
|
|
699
|
+
raise OSError(11, "Resource temporarily unavailable")
|
|
700
|
+
return real_open(path, *a, **kw)
|
|
701
|
+
|
|
702
|
+
with unittest.mock.patch("builtins.open", flaky):
|
|
703
|
+
self.assertEqual(pending.iter_entries(), [])
|
|
704
|
+
self.assertEqual(pending.count(), 1, "entry left intact for the next pass")
|
|
705
|
+
|
|
706
|
+
def test_healthy_enqueue_writes_no_drop_ledger(self):
|
|
707
|
+
self.assertIsNotNone(pending.enqueue(_payload(), RuntimeError("x")))
|
|
708
|
+
self.assertEqual(pending.read_drops(), {})
|
|
709
|
+
self.assertFalse(os.path.exists(pending.drops_path()))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
class BacklogDrainTest(_QueueTempDirMixin, unittest.TestCase):
|
|
713
|
+
def _queue(self, n):
|
|
714
|
+
"""Queue ``n`` entries with POST-#3244 content-derived document_ids."""
|
|
715
|
+
for i in range(n):
|
|
716
|
+
pending.enqueue(
|
|
717
|
+
_payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
|
|
718
|
+
)
|
|
719
|
+
self.assertEqual(pending.count(), n)
|
|
720
|
+
|
|
721
|
+
def test_session_start_drain_cannot_clear_a_backlog(self):
|
|
722
|
+
"""Regression witness for the BUG, not just for the fix.
|
|
723
|
+
|
|
724
|
+
The in-hook drain clamps each entry to the remaining hook budget
|
|
725
|
+
while the retain is synchronous, so the queue barely moves. That
|
|
726
|
+
loop is what produced the backlog in the first place.
|
|
727
|
+
"""
|
|
728
|
+
self._queue(20)
|
|
729
|
+
os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = "2"
|
|
730
|
+
|
|
731
|
+
def slow_ok(entry, timeout):
|
|
732
|
+
import time as _t
|
|
733
|
+
|
|
734
|
+
_t.sleep(0.5)
|
|
735
|
+
|
|
736
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", slow_ok):
|
|
737
|
+
with unittest.mock.patch.object(
|
|
738
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
739
|
+
):
|
|
740
|
+
summary = drain_pending.drain(CONFIG)
|
|
741
|
+
|
|
742
|
+
self.assertTrue(summary["budget_exceeded"])
|
|
743
|
+
self.assertLess(summary["drained"], 20)
|
|
744
|
+
self.assertGreater(pending.count(), 0)
|
|
745
|
+
|
|
746
|
+
# The path that ACTUALLY runs on every boot is the sequential drain, not
|
|
747
|
+
# --backlog. Fixing the re-post loop only in the operator-invoked mode
|
|
748
|
+
# would change nothing operationally: every agent boot would go on
|
|
749
|
+
# re-POSTing its already-durable backlog exactly as before.
|
|
750
|
+
def test_session_start_drain_reconciles_before_re_posting(self):
|
|
751
|
+
self._queue(6)
|
|
752
|
+
posted = []
|
|
753
|
+
durable = {_cd_doc(i) for i in range(4)}
|
|
754
|
+
|
|
755
|
+
def exists(entry, timeout=30):
|
|
756
|
+
return entry["document_id"] in durable
|
|
757
|
+
|
|
758
|
+
with unittest.mock.patch.object(drain_pending, "_document_state", exists):
|
|
759
|
+
with unittest.mock.patch.object(
|
|
760
|
+
drain_pending, "_retry_one", lambda e, timeout: posted.append(e["document_id"])
|
|
761
|
+
):
|
|
762
|
+
summary = drain_pending.drain(CONFIG)
|
|
763
|
+
|
|
764
|
+
self.assertEqual(summary["reconciled"], 4)
|
|
765
|
+
self.assertEqual(
|
|
766
|
+
sorted(posted),
|
|
767
|
+
sorted([_cd_doc(4), _cd_doc(5)]),
|
|
768
|
+
"already-durable entries must not be re-POSTed inside the hook",
|
|
769
|
+
)
|
|
770
|
+
self.assertEqual(pending.count(), 0)
|
|
771
|
+
|
|
772
|
+
def test_session_start_reconcile_never_deletes_on_an_unknown_result(self):
|
|
773
|
+
"""Unknown must fall through to the retry, never to a delete."""
|
|
774
|
+
self._queue(3)
|
|
775
|
+
posted = []
|
|
776
|
+
with unittest.mock.patch.object(
|
|
777
|
+
drain_pending, "_document_state", lambda e, timeout=30: None
|
|
778
|
+
):
|
|
779
|
+
with unittest.mock.patch.object(
|
|
780
|
+
drain_pending, "_retry_one", lambda e, timeout: posted.append(e)
|
|
781
|
+
):
|
|
782
|
+
summary = drain_pending.drain(CONFIG)
|
|
783
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
784
|
+
self.assertEqual(len(posted), 3, "unknown falls through to the retry")
|
|
785
|
+
|
|
786
|
+
def _stalled_upstream(self, budget, timeout):
|
|
787
|
+
"""Simulate an upstream that never answers, on a virtual clock.
|
|
788
|
+
|
|
789
|
+
Every request consumes exactly the timeout it was given — the shape
|
|
790
|
+
of a hung upstream, and the shape that made the fleet's 9s budget
|
|
791
|
+
take 16.0s. The clock is virtual so the assertion is about the
|
|
792
|
+
drain's arithmetic, not about how fast the test box happens to be.
|
|
793
|
+
|
|
794
|
+
Returns ``(seen_get, seen_post, elapsed_fn, patch_contexts)``.
|
|
795
|
+
"""
|
|
796
|
+
os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = str(budget)
|
|
797
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = str(timeout)
|
|
798
|
+
clock = {"t": 1000.0}
|
|
799
|
+
start = clock["t"]
|
|
800
|
+
seen_get, seen_post = [], []
|
|
801
|
+
|
|
802
|
+
def fake_get(entry, timeout=30):
|
|
803
|
+
seen_get.append(timeout)
|
|
804
|
+
clock["t"] += timeout
|
|
805
|
+
return False # absent -> falls through to the POST
|
|
806
|
+
|
|
807
|
+
def fake_post(entry, timeout):
|
|
808
|
+
seen_post.append(timeout)
|
|
809
|
+
clock["t"] += timeout
|
|
810
|
+
raise TimeoutError("upstream never answered")
|
|
811
|
+
|
|
812
|
+
ctx = (
|
|
813
|
+
unittest.mock.patch.object(
|
|
814
|
+
drain_pending.time, "monotonic", lambda: clock["t"]
|
|
815
|
+
),
|
|
816
|
+
unittest.mock.patch.object(drain_pending, "_document_state", fake_get),
|
|
817
|
+
unittest.mock.patch.object(drain_pending, "_retry_one", fake_post),
|
|
818
|
+
)
|
|
819
|
+
return seen_get, seen_post, (lambda: clock["t"] - start), ctx
|
|
820
|
+
|
|
821
|
+
def test_the_per_entry_timeout_is_not_spent_twice(self):
|
|
822
|
+
"""One entry = one budget, not one per request.
|
|
823
|
+
|
|
824
|
+
The clamp used to be computed ONCE per entry and then spent on the
|
|
825
|
+
presence GET *and* the POST. With the fleet's own settings (budget
|
|
826
|
+
9s, timeout 8s) that is 8 + 8 = 16.0s against a 9s hook budget,
|
|
827
|
+
while the docstring promised an overshoot of at most the 1s clamp
|
|
828
|
+
floor. The POST's timeout must be recomputed against what is left.
|
|
829
|
+
"""
|
|
830
|
+
self._queue(1)
|
|
831
|
+
seen_get, seen_post, elapsed, ctx = self._stalled_upstream(budget=9, timeout=8)
|
|
832
|
+
with ctx[0], ctx[1], ctx[2]:
|
|
833
|
+
drain_pending.drain(CONFIG)
|
|
834
|
+
|
|
835
|
+
self.assertEqual(seen_get, [8], "the GET gets the full budgeted clamp")
|
|
836
|
+
self.assertEqual(
|
|
837
|
+
seen_post, [1], "the POST must re-clamp against the budget LEFT"
|
|
838
|
+
)
|
|
839
|
+
self.assertLessEqual(
|
|
840
|
+
elapsed(),
|
|
841
|
+
9,
|
|
842
|
+
f"one entry outspent the whole 9s budget "
|
|
843
|
+
f"(gets={seen_get} posts={seen_post})",
|
|
844
|
+
)
|
|
845
|
+
self.assertEqual(pending.count(), 1, "the failed entry stays queued")
|
|
846
|
+
|
|
847
|
+
def test_session_start_reconcile_respects_the_hook_budget(self):
|
|
848
|
+
"""The GET must not be an extra call bolted onto an unchanged POST.
|
|
849
|
+
|
|
850
|
+
Asserts against the BUDGET, with ``HINDSIGHT_DRAIN_TIMEOUT`` set
|
|
851
|
+
explicitly high — the previous form asserted ``t <= 5`` while
|
|
852
|
+
``_per_entry_timeout()`` defaults to 5, so it held whether or not
|
|
853
|
+
the clamp existed at all, and it never patched ``_retry_one``, so
|
|
854
|
+
the GET+POST path it is named for never ran.
|
|
855
|
+
"""
|
|
856
|
+
self._queue(10)
|
|
857
|
+
budget = 9
|
|
858
|
+
seen_get, seen_post, elapsed, ctx = self._stalled_upstream(
|
|
859
|
+
budget=budget, timeout=300
|
|
860
|
+
)
|
|
861
|
+
with ctx[0], ctx[1], ctx[2]:
|
|
862
|
+
summary = drain_pending.drain(CONFIG)
|
|
863
|
+
|
|
864
|
+
self.assertTrue(seen_get and seen_post, "the GET+POST path must run")
|
|
865
|
+
self.assertTrue(
|
|
866
|
+
all(t <= budget for t in seen_get + seen_post),
|
|
867
|
+
f"a single request outlived the whole budget: {seen_get} {seen_post}",
|
|
868
|
+
)
|
|
869
|
+
self.assertLessEqual(
|
|
870
|
+
elapsed(),
|
|
871
|
+
budget + 2,
|
|
872
|
+
f"drain overshot the {budget}s hook budget by more than one "
|
|
873
|
+
f"floored GET+POST (gets={seen_get} posts={seen_post})",
|
|
874
|
+
)
|
|
875
|
+
self.assertTrue(summary["budget_exceeded"])
|
|
876
|
+
self.assertGreater(pending.count(), 0)
|
|
877
|
+
|
|
878
|
+
def test_reconcile_phase_skips_already_durable_entries_without_posting(self):
|
|
879
|
+
"""The free pass: 70.4% of a measured fleet backlog was already
|
|
880
|
+
durable. Re-POSTing those is duplicated LLM extraction for zero new
|
|
881
|
+
memory; a GET costs nothing on the model pool."""
|
|
882
|
+
self._queue(6)
|
|
883
|
+
posted = []
|
|
884
|
+
durable = {_cd_doc(i) for i in range(3)}
|
|
885
|
+
|
|
886
|
+
def exists(entry, timeout=30):
|
|
887
|
+
return entry["document_id"] in durable
|
|
888
|
+
|
|
889
|
+
with unittest.mock.patch.object(drain_pending, "_document_state", exists):
|
|
890
|
+
with unittest.mock.patch.object(
|
|
891
|
+
drain_pending, "_retry_one", lambda e, timeout: posted.append(e)
|
|
892
|
+
):
|
|
893
|
+
with redirect_stderr(io.StringIO()):
|
|
894
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
|
|
895
|
+
|
|
896
|
+
self.assertEqual(summary["reconciled"], 3)
|
|
897
|
+
self.assertEqual(posted, [], "reconcile must issue no retains at all")
|
|
898
|
+
self.assertEqual(pending.count(), 3, "only absent documents remain queued")
|
|
899
|
+
|
|
900
|
+
def test_reconcile_never_drops_an_entry_on_an_unknown_result(self):
|
|
901
|
+
"""Unknown != present. Guessing here deletes the last copy of a turn."""
|
|
902
|
+
self._queue(3)
|
|
903
|
+
with unittest.mock.patch.object(
|
|
904
|
+
drain_pending, "_document_state", lambda e, timeout=30: None
|
|
905
|
+
):
|
|
906
|
+
with redirect_stderr(io.StringIO()):
|
|
907
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
|
|
908
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
909
|
+
self.assertEqual(summary["unknown"], 3)
|
|
910
|
+
self.assertEqual(pending.count(), 3)
|
|
911
|
+
|
|
912
|
+
def test_drain_confirms_the_document_before_deleting_the_entry(self):
|
|
913
|
+
"""Commit-before-delete: a 200 is an ack, not proof (#3244)."""
|
|
914
|
+
self._queue(2)
|
|
915
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", lambda e, timeout: None):
|
|
916
|
+
# POST succeeds, but the document is still not there.
|
|
917
|
+
with unittest.mock.patch.object(
|
|
918
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
919
|
+
):
|
|
920
|
+
with redirect_stderr(io.StringIO()):
|
|
921
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
922
|
+
|
|
923
|
+
self.assertEqual(summary["drained"], 0)
|
|
924
|
+
self.assertEqual(summary["unknown"], 2)
|
|
925
|
+
self.assertEqual(pending.count(), 2, "unconfirmed entries stay queued")
|
|
926
|
+
|
|
927
|
+
def test_drain_never_retires_an_entry_on_an_unknown_result(self):
|
|
928
|
+
"""Unknown != confirmed, on the PHASE-2 path too (#3599 review R3-B1).
|
|
929
|
+
|
|
930
|
+
The realistic shape: the POST returns 200, the confirming GET hits a
|
|
931
|
+
503 or times out, so ``_document_state`` is ``None``. Counting that
|
|
932
|
+
as durable archives the entry and it is never retried — a degraded
|
|
933
|
+
upstream is exactly the condition this PR exists for. Mirrors
|
|
934
|
+
``test_reconcile_never_drops_an_entry_on_an_unknown_result``; its
|
|
935
|
+
absence left ``is True`` at the phase-2 site mutable to
|
|
936
|
+
``is not False`` with both suites green.
|
|
937
|
+
"""
|
|
938
|
+
self._queue(4)
|
|
939
|
+
with unittest.mock.patch.object(
|
|
940
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
941
|
+
):
|
|
942
|
+
with unittest.mock.patch.object(
|
|
943
|
+
drain_pending, "_document_state", lambda e, timeout=30: None
|
|
944
|
+
):
|
|
945
|
+
with redirect_stderr(io.StringIO()):
|
|
946
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
947
|
+
|
|
948
|
+
self.assertEqual(summary["drained"], 0, "unknown is not a drain")
|
|
949
|
+
self.assertEqual(summary["unknown"], 4)
|
|
950
|
+
self.assertEqual(pending.count(), 4, "unconfirmed entries stay queued")
|
|
951
|
+
|
|
952
|
+
def test_drain_deletes_once_the_document_is_confirmed(self):
|
|
953
|
+
self._queue(5)
|
|
954
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", lambda e, timeout: None):
|
|
955
|
+
with unittest.mock.patch.object(
|
|
956
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
957
|
+
):
|
|
958
|
+
with redirect_stderr(io.StringIO()):
|
|
959
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
960
|
+
self.assertEqual(summary["drained"], 5)
|
|
961
|
+
self.assertEqual(pending.count(), 0)
|
|
962
|
+
|
|
963
|
+
def test_backlog_concurrency_defaults_to_one_lane(self):
|
|
964
|
+
"""The model pool is small and fleet-shared; width 4 consumes all
|
|
965
|
+
of it, and 11 agents at width 4 is 44 lanes of demand against 4."""
|
|
966
|
+
os.environ.pop("HINDSIGHT_DRAIN_CONCURRENCY", None)
|
|
967
|
+
self.assertEqual(drain_pending._backlog_concurrency(), 1)
|
|
968
|
+
os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "999"
|
|
969
|
+
self.assertEqual(drain_pending._backlog_concurrency(), 16, "clamped")
|
|
970
|
+
|
|
971
|
+
def test_backlog_uses_a_realistic_per_entry_timeout(self):
|
|
972
|
+
"""A 1-8s clamp guarantees a client timeout on a 30-90s sync retain
|
|
973
|
+
that the server then commits anyway -- the root cause."""
|
|
974
|
+
os.environ.pop("HINDSIGHT_DRAIN_BACKLOG_TIMEOUT", None)
|
|
975
|
+
self.assertGreaterEqual(drain_pending._backlog_timeout(), 120)
|
|
976
|
+
|
|
977
|
+
self._queue(2)
|
|
978
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "90"
|
|
979
|
+
seen = []
|
|
980
|
+
with unittest.mock.patch.object(
|
|
981
|
+
drain_pending, "_retry_one", lambda e, timeout: seen.append(timeout)
|
|
982
|
+
):
|
|
983
|
+
with unittest.mock.patch.object(
|
|
984
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
985
|
+
):
|
|
986
|
+
with redirect_stderr(io.StringIO()):
|
|
987
|
+
drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
988
|
+
self.assertEqual(seen, [90, 90])
|
|
989
|
+
|
|
990
|
+
def test_p95_backoff_pauses_the_drain(self):
|
|
991
|
+
"""Replay must never be the thing that trips the latency alarm."""
|
|
992
|
+
self._queue(1)
|
|
993
|
+
os.environ["HINDSIGHT_DRAIN_P95_BACKOFF_MS"] = "38000"
|
|
994
|
+
probes = [90000, 90000, 10000]
|
|
995
|
+
slept = []
|
|
996
|
+
|
|
997
|
+
with unittest.mock.patch.object(
|
|
998
|
+
drain_pending, "_p95_probe_ms", lambda: probes.pop(0)
|
|
999
|
+
):
|
|
1000
|
+
with unittest.mock.patch.object(drain_pending.time, "sleep", slept.append):
|
|
1001
|
+
with unittest.mock.patch.object(
|
|
1002
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
1003
|
+
):
|
|
1004
|
+
with unittest.mock.patch.object(
|
|
1005
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1006
|
+
):
|
|
1007
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1008
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1009
|
+
|
|
1010
|
+
self.assertEqual(slept.count(120), 2, "backed off until p95 recovered")
|
|
1011
|
+
self.assertIn("BACKOFF", err.getvalue())
|
|
1012
|
+
self.assertEqual(summary["drained"], 1)
|
|
1013
|
+
|
|
1014
|
+
def test_p95_backoff_is_bounded_by_the_drain_budget(self):
|
|
1015
|
+
"""A persistently slow upstream must not block past the budget.
|
|
1016
|
+
|
|
1017
|
+
An unbounded `while True: sleep(120)` would run indefinitely, past
|
|
1018
|
+
the one guarantee this mode makes about how long it will run.
|
|
1019
|
+
"""
|
|
1020
|
+
self._queue(2)
|
|
1021
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_BUDGET_S"] = "60"
|
|
1022
|
+
slept = []
|
|
1023
|
+
|
|
1024
|
+
with unittest.mock.patch.object(
|
|
1025
|
+
drain_pending, "_p95_probe_ms", lambda: 99000
|
|
1026
|
+
):
|
|
1027
|
+
with unittest.mock.patch.object(drain_pending.time, "sleep", slept.append):
|
|
1028
|
+
with unittest.mock.patch.object(
|
|
1029
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
1030
|
+
):
|
|
1031
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1032
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1033
|
+
|
|
1034
|
+
self.assertEqual(slept, [], "60s budget cannot absorb a 120s pause")
|
|
1035
|
+
self.assertTrue(summary["budget_exceeded"])
|
|
1036
|
+
self.assertIn("budget is exhausted", err.getvalue())
|
|
1037
|
+
self.assertEqual(pending.count(), 2, "entries stay queued")
|
|
1038
|
+
|
|
1039
|
+
def test_absent_p95_probe_does_not_block(self):
|
|
1040
|
+
os.environ.pop("HINDSIGHT_DRAIN_P95_CMD", None)
|
|
1041
|
+
self.assertEqual(drain_pending._p95_probe_ms(), -1)
|
|
1042
|
+
|
|
1043
|
+
def test_stall_guard_does_not_overshoot_under_concurrency(self):
|
|
1044
|
+
"""A wave of `width` identical timeouts must not age `width` entries.
|
|
1045
|
+
|
|
1046
|
+
Recording failures for the whole wave before breaking would bump
|
|
1047
|
+
attempt_count on up to `width - 1` extra entries per wave, pushing
|
|
1048
|
+
them toward .dead FASTER than the sequential drain -- the opposite
|
|
1049
|
+
of this change's purpose.
|
|
1050
|
+
"""
|
|
1051
|
+
self._queue(12)
|
|
1052
|
+
os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "4"
|
|
1053
|
+
|
|
1054
|
+
def always_fail(entry, timeout):
|
|
1055
|
+
raise ConnectionError("upstream down")
|
|
1056
|
+
|
|
1057
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
|
|
1058
|
+
with redirect_stderr(io.StringIO()):
|
|
1059
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1060
|
+
|
|
1061
|
+
self.assertTrue(summary["stalled"])
|
|
1062
|
+
self.assertEqual(
|
|
1063
|
+
summary["retried"],
|
|
1064
|
+
drain_pending.STALL_THRESHOLD,
|
|
1065
|
+
"exactly STALL_THRESHOLD entries aged, same as the sequential drain",
|
|
1066
|
+
)
|
|
1067
|
+
self.assertEqual(pending.count(), 12, "stalled entries stay queued")
|
|
1068
|
+
|
|
1069
|
+
def _exhaust_attempts(self):
|
|
1070
|
+
"""Queue one entry already sitting on the MAX_ATTEMPTS boundary."""
|
|
1071
|
+
self._queue(1)
|
|
1072
|
+
path, entry = pending.iter_entries()[0]
|
|
1073
|
+
entry["attempt_count"] = pending.MAX_ATTEMPTS
|
|
1074
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1075
|
+
json.dump(entry, f)
|
|
1076
|
+
return path
|
|
1077
|
+
|
|
1078
|
+
def _drain_failing_with(self, error):
|
|
1079
|
+
def always_fail(entry, timeout):
|
|
1080
|
+
raise error
|
|
1081
|
+
|
|
1082
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
|
|
1083
|
+
with redirect_stderr(io.StringIO()):
|
|
1084
|
+
return drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1085
|
+
|
|
1086
|
+
def test_backlog_ages_entries_toward_dead_like_the_sequential_drain(self):
|
|
1087
|
+
"""A PERMANENT failure past MAX_ATTEMPTS still retires the entry."""
|
|
1088
|
+
path = self._exhaust_attempts()
|
|
1089
|
+
summary = self._drain_failing_with(
|
|
1090
|
+
RuntimeError('HTTP 400 from http://h/v1/x: {"detail":"bad payload"}')
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
self.assertEqual(summary["dead"], 1)
|
|
1094
|
+
self.assertTrue(os.path.exists(path + ".dead"))
|
|
1095
|
+
self.assertFalse(os.path.exists(path))
|
|
1096
|
+
|
|
1097
|
+
def test_extraction_500_past_max_attempts_never_kills_the_memory(self):
|
|
1098
|
+
"""The regression this gate exists for.
|
|
1099
|
+
|
|
1100
|
+
An HTTP 500 "Fact extraction failed … JSONDecodeError" means the
|
|
1101
|
+
extraction model returned an empty or non-JSON completion on THAT
|
|
1102
|
+
sampling run. The identical content persists fine on a later attempt,
|
|
1103
|
+
so exhausting the attempt budget on it must NOT retire the memory.
|
|
1104
|
+
Before the ``is_permanent_failure`` gate this drain produced
|
|
1105
|
+
``dead=1`` and removed the queue entry.
|
|
1106
|
+
"""
|
|
1107
|
+
path = self._exhaust_attempts()
|
|
1108
|
+
summary = self._drain_failing_with(
|
|
1109
|
+
RuntimeError(
|
|
1110
|
+
"HTTP 500 from http://h/v1/x: "
|
|
1111
|
+
'{"detail":"Fact extraction failed: 1/1 chunks failed. '
|
|
1112
|
+
"First failures: chunk 0: JSONDecodeError: Expecting value: "
|
|
1113
|
+
'line 1 column 1 (char 0)"}'
|
|
1114
|
+
)
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
self.assertEqual(summary["dead"], 0, "a 5xx must never retire a memory")
|
|
1118
|
+
self.assertEqual(summary["retried"], 1)
|
|
1119
|
+
self.assertFalse(os.path.exists(path + ".dead"))
|
|
1120
|
+
self.assertTrue(os.path.exists(path), "the queued memory survives")
|
|
1121
|
+
self.assertEqual(pending.count(), 1)
|
|
1122
|
+
|
|
1123
|
+
def test_transient_transport_failure_past_max_attempts_stays_queued(self):
|
|
1124
|
+
"""Timeouts / connection errors are transient too — never ``.dead``."""
|
|
1125
|
+
for error in (ConnectionError("upstream down"), TimeoutError("timed out")):
|
|
1126
|
+
with self.subTest(error=type(error).__name__):
|
|
1127
|
+
path = self._exhaust_attempts()
|
|
1128
|
+
summary = self._drain_failing_with(error)
|
|
1129
|
+
self.assertEqual(summary["dead"], 0)
|
|
1130
|
+
self.assertTrue(os.path.exists(path))
|
|
1131
|
+
os.unlink(path)
|
|
1132
|
+
|
|
1133
|
+
def test_attempt_count_keeps_climbing_past_the_budget_on_transients(self):
|
|
1134
|
+
"""The counter still records reality; only ``.dead`` is gated."""
|
|
1135
|
+
path = self._exhaust_attempts()
|
|
1136
|
+
self._drain_failing_with(ConnectionError("upstream down"))
|
|
1137
|
+
with open(path, encoding="utf-8") as f:
|
|
1138
|
+
entry = json.load(f)
|
|
1139
|
+
self.assertEqual(entry["attempt_count"], pending.MAX_ATTEMPTS + 1)
|
|
1140
|
+
|
|
1141
|
+
def test_dry_run_issues_no_writes(self):
|
|
1142
|
+
self._queue(3)
|
|
1143
|
+
with unittest.mock.patch.object(
|
|
1144
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1145
|
+
):
|
|
1146
|
+
with redirect_stderr(io.StringIO()):
|
|
1147
|
+
summary = drain_pending.drain_backlog(
|
|
1148
|
+
CONFIG, phase="reconcile", dry_run=True
|
|
1149
|
+
)
|
|
1150
|
+
self.assertEqual(summary["reconciled"], 3)
|
|
1151
|
+
self.assertEqual(pending.count(), 3, "a dry run must not delete anything")
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
class TransportFailureEndToEndTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1155
|
+
"""The permanence gate over the REAL client wrapping (#3669 follow-up).
|
|
1156
|
+
|
|
1157
|
+
Every other permanence test injects a synthetic exception at
|
|
1158
|
+
``drain_pending._retry_one`` — i.e. ABOVE ``lib/client.py``. That skips
|
|
1159
|
+
the layer which actually decides what a fault LOOKS like to
|
|
1160
|
+
``pending.is_permanent_failure``: ``client._request`` catches only
|
|
1161
|
+
``HTTPError`` and re-raises it as ``RuntimeError("HTTP {code} from …")``
|
|
1162
|
+
(lib/client.py:91-97), while a bare ``URLError`` propagates untouched.
|
|
1163
|
+
So the gate's input shape is produced there and asserted nowhere.
|
|
1164
|
+
|
|
1165
|
+
These tests patch ``urllib.request.urlopen`` instead and drive the real
|
|
1166
|
+
drain, keeping that wrapping in the loop. #3669 added an equivalent
|
|
1167
|
+
end-to-end case, but to ``vendor/hindsight-memory/tests/`` — which no
|
|
1168
|
+
workflow discovers (ci-tests-python.yml runs ``unittest discover
|
|
1169
|
+
tests/`` from ``vendor/hindsight-memory/scripts``), so it never ran.
|
|
1170
|
+
"""
|
|
1171
|
+
|
|
1172
|
+
def _queue_one(self):
|
|
1173
|
+
pending.enqueue(_payload(content="turn-0", doc=_cd_doc(0)), RuntimeError("boom"))
|
|
1174
|
+
self.assertEqual(pending.count(), 1)
|
|
1175
|
+
|
|
1176
|
+
def _exhaust_attempts(self):
|
|
1177
|
+
"""One entry sitting on the MAX_ATTEMPTS boundary."""
|
|
1178
|
+
self._queue_one()
|
|
1179
|
+
path, entry = pending.iter_entries()[0]
|
|
1180
|
+
entry["attempt_count"] = pending.MAX_ATTEMPTS
|
|
1181
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
1182
|
+
json.dump(entry, f)
|
|
1183
|
+
return path
|
|
1184
|
+
|
|
1185
|
+
@staticmethod
|
|
1186
|
+
def _http_error(code):
|
|
1187
|
+
def raise_it(*_a, **_kw):
|
|
1188
|
+
raise urllib.error.HTTPError(
|
|
1189
|
+
"http://127.0.0.1:9/none/v1/retain",
|
|
1190
|
+
code,
|
|
1191
|
+
"rejected",
|
|
1192
|
+
{},
|
|
1193
|
+
io.BytesIO(b'{"detail":"rejected"}'),
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
return raise_it
|
|
1197
|
+
|
|
1198
|
+
def _drain_with_urlopen(self, side_effect):
|
|
1199
|
+
with unittest.mock.patch("urllib.request.urlopen", side_effect=side_effect):
|
|
1200
|
+
with redirect_stderr(io.StringIO()):
|
|
1201
|
+
return drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1202
|
+
|
|
1203
|
+
def test_outage_past_max_attempts_keeps_the_memory(self):
|
|
1204
|
+
"""A dead daemon raises bare ``URLError`` — no ``.code``, so transient.
|
|
1205
|
+
|
|
1206
|
+
Before the gate this retired the entry at MAX_ATTEMPTS: an outage
|
|
1207
|
+
destroyed memory that was perfectly saveable.
|
|
1208
|
+
"""
|
|
1209
|
+
path = self._exhaust_attempts()
|
|
1210
|
+
|
|
1211
|
+
def down(*_a, **_kw):
|
|
1212
|
+
raise urllib.error.URLError("still down")
|
|
1213
|
+
|
|
1214
|
+
summary = self._drain_with_urlopen(down)
|
|
1215
|
+
self.assertEqual(summary["dead"], 0, "an outage must never retire a memory")
|
|
1216
|
+
self.assertTrue(os.path.exists(path), "the queued memory survives")
|
|
1217
|
+
self.assertFalse(os.path.exists(path + ".dead"))
|
|
1218
|
+
|
|
1219
|
+
def test_rate_limit_past_max_attempts_keeps_the_memory(self):
|
|
1220
|
+
"""429 wears a 4xx code but is the most transient fault there is.
|
|
1221
|
+
|
|
1222
|
+
This is the case the raw ``400 <= code < 500`` rule would get
|
|
1223
|
+
wrong, asserted through the real ``HTTP {code} from …`` wrapping.
|
|
1224
|
+
"""
|
|
1225
|
+
path = self._exhaust_attempts()
|
|
1226
|
+
summary = self._drain_with_urlopen(self._http_error(429))
|
|
1227
|
+
self.assertEqual(summary["dead"], 0, "a rate limit must never retire a memory")
|
|
1228
|
+
self.assertTrue(os.path.exists(path))
|
|
1229
|
+
self.assertFalse(os.path.exists(path + ".dead"))
|
|
1230
|
+
|
|
1231
|
+
def test_server_rejection_past_max_attempts_still_retires(self):
|
|
1232
|
+
"""The other half of the gate: a fix that never retires is a leak.
|
|
1233
|
+
|
|
1234
|
+
A positively-rejected payload must still age to ``.dead``, or the
|
|
1235
|
+
queue grows without bound.
|
|
1236
|
+
"""
|
|
1237
|
+
path = self._exhaust_attempts()
|
|
1238
|
+
summary = self._drain_with_urlopen(self._http_error(400))
|
|
1239
|
+
self.assertEqual(summary["dead"], 1, "a 4xx rejection must still retire")
|
|
1240
|
+
self.assertTrue(os.path.exists(path + ".dead"))
|
|
1241
|
+
self.assertFalse(os.path.exists(path))
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
class ArchiveFailureNeverDeletesTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1245
|
+
"""A failure to ARCHIVE is not a licence to DELETE (#3599 review R3-M1).
|
|
1246
|
+
|
|
1247
|
+
The previous ``archive_reconciled`` fell back to ``delete_entry`` on
|
|
1248
|
+
``OSError``. Reproduced: ENOSPC from ``os.makedirs`` removed the entry,
|
|
1249
|
+
left ``pending-reconciled/`` empty, and emitted nothing at all — silent,
|
|
1250
|
+
unrecorded, irreversible loss of the last on-disk copy of a turn. That
|
|
1251
|
+
falsified, verbatim, four separate claims (``drain_pending``'s module
|
|
1252
|
+
docstring, ``switchroom doctor``'s backlog fix text, the CHANGELOG, and
|
|
1253
|
+
the PR body). The claims are now true because the code is.
|
|
1254
|
+
"""
|
|
1255
|
+
|
|
1256
|
+
def _queue(self, n):
|
|
1257
|
+
for i in range(n):
|
|
1258
|
+
pending.enqueue(
|
|
1259
|
+
_payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
|
|
1260
|
+
)
|
|
1261
|
+
self.assertEqual(pending.count(), n)
|
|
1262
|
+
|
|
1263
|
+
@staticmethod
|
|
1264
|
+
def _enospc(*_a, **_kw):
|
|
1265
|
+
raise OSError(28, "No space left on device")
|
|
1266
|
+
|
|
1267
|
+
def test_archive_reconciled_keeps_the_entry_when_the_archive_is_unwritable(self):
|
|
1268
|
+
self._queue(1)
|
|
1269
|
+
path = os.path.join(self._dir, self._names()[0])
|
|
1270
|
+
err = io.StringIO()
|
|
1271
|
+
with unittest.mock.patch.object(pending.os, "makedirs", self._enospc):
|
|
1272
|
+
with redirect_stderr(err):
|
|
1273
|
+
dest = pending.archive_reconciled(path)
|
|
1274
|
+
|
|
1275
|
+
self.assertIsNone(dest, "a failed archive reports failure, not a dest")
|
|
1276
|
+
self.assertTrue(
|
|
1277
|
+
os.path.exists(path),
|
|
1278
|
+
"the entry is the ONLY on-disk copy of the turn; a full disk "
|
|
1279
|
+
"must not destroy it",
|
|
1280
|
+
)
|
|
1281
|
+
self.assertEqual(pending.count(), 1)
|
|
1282
|
+
self.assertEqual(self._reconciled_names(), [])
|
|
1283
|
+
|
|
1284
|
+
def test_archive_failure_is_loud_on_stderr(self):
|
|
1285
|
+
"""Silent loss was the worst part of the old behaviour. Even now
|
|
1286
|
+
that nothing is lost, an unwritable archive must be visible."""
|
|
1287
|
+
self._queue(1)
|
|
1288
|
+
path = os.path.join(self._dir, self._names()[0])
|
|
1289
|
+
err = io.StringIO()
|
|
1290
|
+
with unittest.mock.patch.object(pending.os, "makedirs", self._enospc):
|
|
1291
|
+
with redirect_stderr(err):
|
|
1292
|
+
pending.archive_reconciled(path)
|
|
1293
|
+
msg = err.getvalue()
|
|
1294
|
+
self.assertIn(os.path.basename(path), msg)
|
|
1295
|
+
self.assertIn("No space left on device", msg)
|
|
1296
|
+
self.assertRegex(msg, r"STAYS QUEUED")
|
|
1297
|
+
|
|
1298
|
+
def test_the_queue_module_exposes_no_delete_primitive(self):
|
|
1299
|
+
"""Structural, not aspirational: with no ``delete_entry`` there is
|
|
1300
|
+
no branch by which the drain path can ``os.remove`` a live entry."""
|
|
1301
|
+
self.assertFalse(
|
|
1302
|
+
hasattr(pending, "delete_entry"),
|
|
1303
|
+
"re-adding a delete primitive re-opens the silent-loss path",
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
def test_backlog_drain_counts_an_unarchivable_entry_as_not_retired(self):
|
|
1307
|
+
"""The summary must not claim a retire that did not happen: the
|
|
1308
|
+
entry is still queued, so ``drained`` would be a lie."""
|
|
1309
|
+
self._queue(3)
|
|
1310
|
+
with unittest.mock.patch.object(
|
|
1311
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
1312
|
+
):
|
|
1313
|
+
with unittest.mock.patch.object(
|
|
1314
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1315
|
+
):
|
|
1316
|
+
with unittest.mock.patch.object(
|
|
1317
|
+
pending.os, "makedirs", self._enospc
|
|
1318
|
+
):
|
|
1319
|
+
with redirect_stderr(io.StringIO()):
|
|
1320
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1321
|
+
|
|
1322
|
+
self.assertEqual(summary["drained"], 0)
|
|
1323
|
+
self.assertEqual(summary["archive_failed"], 3)
|
|
1324
|
+
self.assertEqual(pending.count(), 3, "every entry survives a full disk")
|
|
1325
|
+
|
|
1326
|
+
def test_reconcile_phase_counts_an_unarchivable_entry_as_not_retired(self):
|
|
1327
|
+
self._queue(3)
|
|
1328
|
+
with unittest.mock.patch.object(
|
|
1329
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1330
|
+
):
|
|
1331
|
+
with unittest.mock.patch.object(pending.os, "makedirs", self._enospc):
|
|
1332
|
+
with redirect_stderr(io.StringIO()):
|
|
1333
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
|
|
1334
|
+
|
|
1335
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
1336
|
+
self.assertEqual(summary["archive_failed"], 3)
|
|
1337
|
+
self.assertEqual(pending.count(), 3)
|
|
1338
|
+
|
|
1339
|
+
def test_in_hook_drain_keeps_an_unarchivable_entry(self):
|
|
1340
|
+
"""The bounded SessionStart path retires on its POST's own
|
|
1341
|
+
commit-before-ack 200 (``async_processing=False``, no confirming
|
|
1342
|
+
GET) and is the one most likely to run on a nearly-full container
|
|
1343
|
+
filesystem."""
|
|
1344
|
+
self._queue(2)
|
|
1345
|
+
with unittest.mock.patch.object(
|
|
1346
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
1347
|
+
):
|
|
1348
|
+
with unittest.mock.patch.object(
|
|
1349
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
1350
|
+
):
|
|
1351
|
+
with unittest.mock.patch.object(
|
|
1352
|
+
pending.os, "makedirs", self._enospc
|
|
1353
|
+
):
|
|
1354
|
+
with redirect_stderr(io.StringIO()):
|
|
1355
|
+
summary = drain_pending.drain(CONFIG)
|
|
1356
|
+
|
|
1357
|
+
self.assertEqual(summary["drained"], 0)
|
|
1358
|
+
self.assertEqual(summary["archive_failed"], 2)
|
|
1359
|
+
self.assertEqual(pending.count(), 2)
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
class PresenceReconcileGateTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1363
|
+
"""A presence GET may only retire an entry whose id proves ITS content.
|
|
1364
|
+
|
|
1365
|
+
Post-#3244 (`retain.slice_document_id`) the id is
|
|
1366
|
+
``{session}-r{start_uuid}-{end_uuid}`` — a function of which turns the
|
|
1367
|
+
entry carries, so a 200 proves this content was committed. A pre-#3244
|
|
1368
|
+
entry carries a BARE SESSION ID, for which the bank answers 200 after
|
|
1369
|
+
ANY successful retain in that session. Reconciling on that deletes a
|
|
1370
|
+
turn that was never committed. Confirmed against a live 525 KB entry on
|
|
1371
|
+
this fleet whose ``document_id`` is a bare uuid and whose GET returns
|
|
1372
|
+
200.
|
|
1373
|
+
"""
|
|
1374
|
+
|
|
1375
|
+
BARE_SESSION_ID = "d52ae253-2d26-42e5-a86b-9a354cc0ace5"
|
|
1376
|
+
|
|
1377
|
+
def test_the_id_shape_predicate_separates_the_two_generations(self):
|
|
1378
|
+
self.assertTrue(pending.is_content_derived_document_id(_cd_doc(1)))
|
|
1379
|
+
# sha256 fallback for uuid-less legacy transcripts
|
|
1380
|
+
self.assertTrue(
|
|
1381
|
+
pending.is_content_derived_document_id(f"{SESSION}-r" + "a1" * 16)
|
|
1382
|
+
)
|
|
1383
|
+
# sub-agent namespace reuses the same recipe
|
|
1384
|
+
self.assertTrue(
|
|
1385
|
+
pending.is_content_derived_document_id(
|
|
1386
|
+
f"{SESSION}-sub-worker7-r{_uuid(1)}-{_uuid(2)}"
|
|
1387
|
+
)
|
|
1388
|
+
)
|
|
1389
|
+
for bad in (
|
|
1390
|
+
self.BARE_SESSION_ID,
|
|
1391
|
+
"conversation",
|
|
1392
|
+
"",
|
|
1393
|
+
None,
|
|
1394
|
+
123,
|
|
1395
|
+
f"{SESSION}-r{_uuid(1)}", # only one uuid: not the slice shape
|
|
1396
|
+
):
|
|
1397
|
+
self.assertFalse(
|
|
1398
|
+
pending.is_content_derived_document_id(bad), f"must not accept {bad!r}"
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
def test_a_split_part_inherits_the_verdict_of_its_core_id(self):
|
|
1402
|
+
"""#3610's part ids must stay inside #3599's free reconcile.
|
|
1403
|
+
|
|
1404
|
+
The gate is anchored at the end of the id, so ``-p{i}of{n}`` would
|
|
1405
|
+
otherwise push EVERY split part outside it — and split parts are, by
|
|
1406
|
+
construction, the largest and most expensive entries in the queue.
|
|
1407
|
+
Each would take a full re-POST on every drain instead of a sub-second
|
|
1408
|
+
presence GET: the re-post loop #3599 fixed, aimed at the worst case.
|
|
1409
|
+
|
|
1410
|
+
The suffix must not rescue a pre-#3244 id, which is the whole point
|
|
1411
|
+
of the gate, so both directions are asserted here.
|
|
1412
|
+
"""
|
|
1413
|
+
core = _cd_doc(1)
|
|
1414
|
+
for total in (2, 5, 12, 249):
|
|
1415
|
+
for index in range(min(total, 3)):
|
|
1416
|
+
part = retain_split.part_document_id(core, index, total)
|
|
1417
|
+
self.assertTrue(
|
|
1418
|
+
pending.is_content_derived_document_id(part),
|
|
1419
|
+
f"a split part of a content-derived id is content-derived: {part!r}",
|
|
1420
|
+
)
|
|
1421
|
+
# sha256 fallback core, and the sub-agent namespace, split too
|
|
1422
|
+
self.assertTrue(
|
|
1423
|
+
pending.is_content_derived_document_id(f"{SESSION}-r" + "a1" * 16 + "-p1of3")
|
|
1424
|
+
)
|
|
1425
|
+
self.assertTrue(
|
|
1426
|
+
pending.is_content_derived_document_id(
|
|
1427
|
+
f"{SESSION}-sub-worker7-r{_uuid(1)}-{_uuid(2)}-p2of4"
|
|
1428
|
+
)
|
|
1429
|
+
)
|
|
1430
|
+
# A part re-split under a lowered bound nests its suffix, and is still
|
|
1431
|
+
# a pure function of content.
|
|
1432
|
+
self.assertTrue(
|
|
1433
|
+
pending.is_content_derived_document_id(
|
|
1434
|
+
retain_split.part_document_id(
|
|
1435
|
+
retain_split.part_document_id(core, 1, 5), 0, 2
|
|
1436
|
+
)
|
|
1437
|
+
)
|
|
1438
|
+
)
|
|
1439
|
+
# A part suffix on a PRE-#3244 id still proves nothing.
|
|
1440
|
+
for bad in (
|
|
1441
|
+
f"{self.BARE_SESSION_ID}-p1of3",
|
|
1442
|
+
"conversation-p2of2",
|
|
1443
|
+
f"{SESSION}-r{_uuid(1)}-p1of2", # only one uuid: not the slice shape
|
|
1444
|
+
f"{core}-pXofY", # not the emitted suffix shape
|
|
1445
|
+
f"{core}-p1of2-trailing", # suffix must be terminal
|
|
1446
|
+
):
|
|
1447
|
+
self.assertFalse(
|
|
1448
|
+
pending.is_content_derived_document_id(bad), f"must not accept {bad!r}"
|
|
1449
|
+
)
|
|
1450
|
+
|
|
1451
|
+
def _queue_bare(self, n=2):
|
|
1452
|
+
for i in range(n):
|
|
1453
|
+
pending.enqueue(
|
|
1454
|
+
_payload(content=f"turn-{i}", doc=self.BARE_SESSION_ID),
|
|
1455
|
+
RuntimeError("boom"),
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
def test_a_bare_session_id_is_never_reconciled_on_presence_alone(self):
|
|
1459
|
+
"""The bug: a 200 for the session deletes an uncommitted turn."""
|
|
1460
|
+
self._queue_bare(2)
|
|
1461
|
+
posted = []
|
|
1462
|
+
|
|
1463
|
+
def post_fails(entry, timeout):
|
|
1464
|
+
posted.append(entry)
|
|
1465
|
+
raise TimeoutError("upstream slow")
|
|
1466
|
+
|
|
1467
|
+
with unittest.mock.patch.object(
|
|
1468
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1469
|
+
):
|
|
1470
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", post_fails):
|
|
1471
|
+
summary = drain_pending.drain(CONFIG)
|
|
1472
|
+
|
|
1473
|
+
self.assertEqual(summary["reconciled"], 0, "presence alone proves nothing here")
|
|
1474
|
+
self.assertEqual(len(posted), 2, "such entries must go to the POST instead")
|
|
1475
|
+
self.assertEqual(pending.count(), 2, "and stay queued when the POST fails")
|
|
1476
|
+
self.assertEqual(
|
|
1477
|
+
self._reconciled_names(),
|
|
1478
|
+
[],
|
|
1479
|
+
"nothing may be retired by the reconcile path on a bare session id",
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
def test_backlog_reconcile_phase_also_refuses_a_bare_session_id(self):
|
|
1483
|
+
self._queue_bare(2)
|
|
1484
|
+
with unittest.mock.patch.object(
|
|
1485
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1486
|
+
):
|
|
1487
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1488
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="reconcile")
|
|
1489
|
+
|
|
1490
|
+
self.assertEqual(summary["reconciled"], 0)
|
|
1491
|
+
self.assertEqual(pending.count(), 2, "left for phase 2, which can make them durable")
|
|
1492
|
+
self.assertIn("pre-#3244", err.getvalue())
|
|
1493
|
+
|
|
1494
|
+
def test_reconciled_entries_are_archived_not_deleted(self):
|
|
1495
|
+
"""The out-of-band tooling this design was ported from ARCHIVES.
|
|
1496
|
+
|
|
1497
|
+
Every retire decision rests on a 200, and a 200 is evidence, not
|
|
1498
|
+
proof (#3244). An irreversible ``os.remove`` on evidence is how the
|
|
1499
|
+
last on-disk copy of a turn disappears; ``pending-reconciled/`` is
|
|
1500
|
+
recoverable and bounded.
|
|
1501
|
+
"""
|
|
1502
|
+
pending.enqueue(_payload(doc=_cd_doc(7)), RuntimeError("x"))
|
|
1503
|
+
name = self._names()[0]
|
|
1504
|
+
with unittest.mock.patch.object(
|
|
1505
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1506
|
+
):
|
|
1507
|
+
summary = drain_pending.drain(CONFIG)
|
|
1508
|
+
|
|
1509
|
+
self.assertEqual(summary["reconciled"], 1)
|
|
1510
|
+
self.assertEqual(pending.count(), 0, "no longer queued")
|
|
1511
|
+
self.assertEqual(self._reconciled_names(), [name], "payload is recoverable")
|
|
1512
|
+
|
|
1513
|
+
def test_a_successful_in_hook_retain_also_archives_rather_than_deletes(self):
|
|
1514
|
+
"""One durability rule on one path.
|
|
1515
|
+
|
|
1516
|
+
The in-hook success path retires on its POST's own 200 (a
|
|
1517
|
+
commit-before-ack, see the test below — NOT a bare async ack, as
|
|
1518
|
+
this docstring used to say) while the backlog path additionally
|
|
1519
|
+
re-GETs. Archiving both makes the weaker evidence cost a
|
|
1520
|
+
recoverable file rather than a lost turn.
|
|
1521
|
+
"""
|
|
1522
|
+
pending.enqueue(_payload(doc=self.BARE_SESSION_ID), RuntimeError("x"))
|
|
1523
|
+
name = self._names()[0]
|
|
1524
|
+
with unittest.mock.patch.object(
|
|
1525
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
1526
|
+
):
|
|
1527
|
+
summary = drain_pending.drain(CONFIG)
|
|
1528
|
+
|
|
1529
|
+
self.assertEqual(summary["drained"], 1)
|
|
1530
|
+
self.assertEqual(pending.count(), 0)
|
|
1531
|
+
self.assertEqual(self._reconciled_names(), [name])
|
|
1532
|
+
|
|
1533
|
+
def test_the_in_hook_success_path_commits_synchronously_and_never_re_gets(
|
|
1534
|
+
self,
|
|
1535
|
+
):
|
|
1536
|
+
"""The evidence the in-hook retire ACTUALLY rests on (#3599 R4-B3).
|
|
1537
|
+
|
|
1538
|
+
``pending._trim_dir``'s cap justification names this population by
|
|
1539
|
+
hand — "retires on the POST's own 200 with no confirming GET, and
|
|
1540
|
+
that 200 is a commit-before-ack" — so both halves are pinned here
|
|
1541
|
+
rather than left as prose. Nothing else in the suite fails if the
|
|
1542
|
+
in-hook path goes back to ``async_processing=True`` (making the 200
|
|
1543
|
+
a bare ack, which is what the old comment claimed it already was)
|
|
1544
|
+
or starts issuing a confirming re-GET it has no hook budget for.
|
|
1545
|
+
|
|
1546
|
+
The event log is ordered on purpose: ONE presence GET *before* the
|
|
1547
|
+
POST is the reconcile free pass; the assertion is that nothing
|
|
1548
|
+
follows the POST.
|
|
1549
|
+
"""
|
|
1550
|
+
pending.enqueue(_payload(doc=_cd_doc(42)), RuntimeError("x"))
|
|
1551
|
+
events = []
|
|
1552
|
+
|
|
1553
|
+
def fake_state(entry, timeout=30):
|
|
1554
|
+
events.append("GET")
|
|
1555
|
+
return False # absent → fall through to the POST
|
|
1556
|
+
|
|
1557
|
+
class _Client:
|
|
1558
|
+
def __init__(self, *_a, **_kw):
|
|
1559
|
+
pass
|
|
1560
|
+
|
|
1561
|
+
def retain(self, **kw):
|
|
1562
|
+
events.append(("POST", kw.get("async_processing")))
|
|
1563
|
+
return {}
|
|
1564
|
+
|
|
1565
|
+
with unittest.mock.patch.object(drain_pending, "HindsightClient", _Client):
|
|
1566
|
+
with unittest.mock.patch.object(
|
|
1567
|
+
drain_pending, "_document_state", fake_state
|
|
1568
|
+
):
|
|
1569
|
+
summary = drain_pending.drain(CONFIG)
|
|
1570
|
+
|
|
1571
|
+
self.assertEqual(summary["drained"], 1)
|
|
1572
|
+
self.assertEqual(
|
|
1573
|
+
events,
|
|
1574
|
+
["GET", ("POST", False)],
|
|
1575
|
+
"one presence GET before the POST, a SYNCHRONOUS post, and no "
|
|
1576
|
+
"confirming GET after it",
|
|
1577
|
+
)
|
|
1578
|
+
self.assertEqual(len(self._reconciled_names()), 1)
|
|
1579
|
+
|
|
1580
|
+
def test_the_backlog_confirmed_drain_archives_too(self):
|
|
1581
|
+
pending.enqueue(_payload(doc=_cd_doc(3)), RuntimeError("x"))
|
|
1582
|
+
name = self._names()[0]
|
|
1583
|
+
with unittest.mock.patch.object(
|
|
1584
|
+
drain_pending, "_retry_one", lambda e, timeout: None
|
|
1585
|
+
):
|
|
1586
|
+
with unittest.mock.patch.object(
|
|
1587
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1588
|
+
):
|
|
1589
|
+
with redirect_stderr(io.StringIO()):
|
|
1590
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
1591
|
+
self.assertEqual(summary["drained"], 1)
|
|
1592
|
+
self.assertEqual(self._reconciled_names(), [name])
|
|
1593
|
+
|
|
1594
|
+
def test_the_reconciled_archive_is_bounded_and_sheds_oldest_first(self):
|
|
1595
|
+
"""An unbounded archive is just a slower disk problem."""
|
|
1596
|
+
prev = pending.RECONCILED_MAX_ENTRIES
|
|
1597
|
+
pending.RECONCILED_MAX_ENTRIES = 2
|
|
1598
|
+
try:
|
|
1599
|
+
clock = iter(1000.0 + i for i in range(20))
|
|
1600
|
+
with unittest.mock.patch.object(pending.time, "time", lambda: next(clock)):
|
|
1601
|
+
for i in range(5):
|
|
1602
|
+
pending.enqueue(
|
|
1603
|
+
_payload(content=f"c{i}", doc=_cd_doc(i)), RuntimeError("x")
|
|
1604
|
+
)
|
|
1605
|
+
queued = self._names()
|
|
1606
|
+
with unittest.mock.patch.object(
|
|
1607
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1608
|
+
):
|
|
1609
|
+
drain_pending.drain(CONFIG)
|
|
1610
|
+
self.assertEqual(
|
|
1611
|
+
self._reconciled_names(),
|
|
1612
|
+
queued[-2:],
|
|
1613
|
+
"the archive keeps the NEWEST retirements, trimming oldest-first",
|
|
1614
|
+
)
|
|
1615
|
+
finally:
|
|
1616
|
+
pending.RECONCILED_MAX_ENTRIES = prev
|
|
1617
|
+
|
|
1618
|
+
def test_an_unwritable_archive_keeps_the_entry_rather_than_deleting_it(self):
|
|
1619
|
+
"""REVERSED from the original assertion, deliberately (#3599 R3-M1).
|
|
1620
|
+
|
|
1621
|
+
This case used to assert ``pending.count() == 0`` — i.e. that a
|
|
1622
|
+
failed archive deleted the entry — on the reasoning that a queue
|
|
1623
|
+
which cannot retire re-POSTs forever. That trade is backwards: the
|
|
1624
|
+
re-POST loop costs duplicated LLM extraction on a disk-full box,
|
|
1625
|
+
and the delete costs the last on-disk copy of the turn, silently.
|
|
1626
|
+
Sheds the cheaper failure; ``archive_failed`` keeps the summary
|
|
1627
|
+
honest about the entry still being queued.
|
|
1628
|
+
"""
|
|
1629
|
+
pending.enqueue(_payload(doc=_cd_doc(9)), RuntimeError("x"))
|
|
1630
|
+
with unittest.mock.patch.object(
|
|
1631
|
+
pending.shutil, "move", side_effect=OSError(28, "No space left")
|
|
1632
|
+
):
|
|
1633
|
+
with unittest.mock.patch.object(
|
|
1634
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
1635
|
+
):
|
|
1636
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1637
|
+
summary = drain_pending.drain(CONFIG)
|
|
1638
|
+
self.assertEqual(summary["reconciled"], 0, "nothing was retired")
|
|
1639
|
+
self.assertEqual(summary["archive_failed"], 1)
|
|
1640
|
+
self.assertEqual(pending.count(), 1, "the only copy of the turn survives")
|
|
1641
|
+
self.assertIn("No space left", err.getvalue())
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
class TrimDirBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1645
|
+
"""``_trim_dir``'s caps, at the boundary (#3599 review R3-L2).
|
|
1646
|
+
|
|
1647
|
+
A separate site from the enqueue cap in ``_evict_to_fit`` (pinned by
|
|
1648
|
+
``EvictionTest``): ``>`` → ``>=`` here survived the whole sweep. Off by
|
|
1649
|
+
one in this direction sheds one archived copy on every call forever,
|
|
1650
|
+
including on a directory that is exactly at its cap.
|
|
1651
|
+
"""
|
|
1652
|
+
|
|
1653
|
+
def _archive(self, sizes):
|
|
1654
|
+
"""Write ``len(sizes)`` archive files of the given byte sizes."""
|
|
1655
|
+
d = os.path.join(self._tmp, "arch")
|
|
1656
|
+
os.makedirs(d, exist_ok=True)
|
|
1657
|
+
for i, n in enumerate(sizes):
|
|
1658
|
+
with open(os.path.join(d, f"{1000 + i:013d}-x.json"), "w") as f:
|
|
1659
|
+
f.write("x" * n)
|
|
1660
|
+
return d
|
|
1661
|
+
|
|
1662
|
+
def _names_in(self, d):
|
|
1663
|
+
return sorted(n for n in os.listdir(d) if n.endswith(".json"))
|
|
1664
|
+
|
|
1665
|
+
def test_exactly_at_the_byte_cap_is_within_it(self):
|
|
1666
|
+
d = self._archive([10, 10, 10])
|
|
1667
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1668
|
+
dropped = pending._trim_dir(d, max_entries=99, max_bytes=30)
|
|
1669
|
+
self.assertEqual(dropped, 0, "30 bytes under a 30-byte cap fits")
|
|
1670
|
+
self.assertEqual(len(self._names_in(d)), 3)
|
|
1671
|
+
self.assertEqual(err.getvalue(), "", "nothing dropped, nothing logged")
|
|
1672
|
+
|
|
1673
|
+
def test_one_byte_over_the_cap_sheds_the_oldest_only(self):
|
|
1674
|
+
d = self._archive([10, 10, 11])
|
|
1675
|
+
with redirect_stderr(io.StringIO()):
|
|
1676
|
+
dropped = pending._trim_dir(d, max_entries=99, max_bytes=30)
|
|
1677
|
+
self.assertEqual(dropped, 1)
|
|
1678
|
+
self.assertEqual(
|
|
1679
|
+
self._names_in(d),
|
|
1680
|
+
[f"{1001:013d}-x.json", f"{1002:013d}-x.json"],
|
|
1681
|
+
"oldest first — the newest archived copy is the one worth keeping",
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
def test_exactly_at_the_count_cap_is_within_it(self):
|
|
1685
|
+
d = self._archive([1, 1, 1])
|
|
1686
|
+
self.assertEqual(pending._trim_dir(d, max_entries=3, max_bytes=10**9), 0)
|
|
1687
|
+
self.assertEqual(len(self._names_in(d)), 3)
|
|
1688
|
+
|
|
1689
|
+
def test_one_over_the_count_cap_sheds_exactly_one(self):
|
|
1690
|
+
d = self._archive([1, 1, 1, 1])
|
|
1691
|
+
with redirect_stderr(io.StringIO()):
|
|
1692
|
+
self.assertEqual(pending._trim_dir(d, max_entries=3, max_bytes=10**9), 1)
|
|
1693
|
+
self.assertEqual(len(self._names_in(d)), 3)
|
|
1694
|
+
|
|
1695
|
+
def test_a_trim_names_what_it_dropped_on_stderr(self):
|
|
1696
|
+
"""The archive caps are 4x smaller than the queue caps, so a
|
|
1697
|
+
full-queue drain trims. Silent trimming makes "archived, never
|
|
1698
|
+
deleted" a half-truth; the log line is the other half."""
|
|
1699
|
+
d = self._archive([1] * 6)
|
|
1700
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1701
|
+
pending._trim_dir(d, max_entries=2, max_bytes=10**9)
|
|
1702
|
+
msg = err.getvalue()
|
|
1703
|
+
self.assertIn("trimmed 4 archived copies", msg)
|
|
1704
|
+
self.assertIn(f"{1000:013d}-x.json", msg, "names the oldest dropped")
|
|
1705
|
+
self.assertIn("arch", msg, "names the directory")
|
|
1706
|
+
|
|
1707
|
+
def test_exactly_ten_dropped_names_are_all_listed(self):
|
|
1708
|
+
"""The name list truncates ABOVE ten, not at ten.
|
|
1709
|
+
|
|
1710
|
+
``len(dropped) > 10`` vs ``>=`` differ only on a trim of exactly
|
|
1711
|
+
ten, where the mutant appends a nonsense "+0 more".
|
|
1712
|
+
"""
|
|
1713
|
+
d = self._archive([1] * 12)
|
|
1714
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1715
|
+
pending._trim_dir(d, max_entries=2, max_bytes=10**9)
|
|
1716
|
+
msg = err.getvalue()
|
|
1717
|
+
self.assertIn("trimmed 10 archived copies", msg)
|
|
1718
|
+
self.assertNotIn("more", msg, "ten names fit; there is no remainder")
|
|
1719
|
+
self.assertEqual(msg.count("-x.json"), 10)
|
|
1720
|
+
|
|
1721
|
+
def test_eleven_dropped_names_truncate_to_ten_plus_a_remainder(self):
|
|
1722
|
+
d = self._archive([1] * 13)
|
|
1723
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1724
|
+
pending._trim_dir(d, max_entries=2, max_bytes=10**9)
|
|
1725
|
+
msg = err.getvalue()
|
|
1726
|
+
self.assertIn("trimmed 11 archived copies", msg)
|
|
1727
|
+
self.assertIn("+1 more", msg)
|
|
1728
|
+
self.assertEqual(msg.count("-x.json"), 10)
|
|
1729
|
+
|
|
1730
|
+
def test_a_large_trim_caps_the_name_list(self):
|
|
1731
|
+
"""A 1500-entry trim must not emit a 1500-name line."""
|
|
1732
|
+
d = self._archive([1] * 20)
|
|
1733
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1734
|
+
pending._trim_dir(d, max_entries=2, max_bytes=10**9)
|
|
1735
|
+
msg = err.getvalue()
|
|
1736
|
+
self.assertIn("trimmed 18 archived copies", msg)
|
|
1737
|
+
self.assertIn("+8 more", msg)
|
|
1738
|
+
self.assertLessEqual(msg.count("-x.json"), 10)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
class ClampAndEnvKnobBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1742
|
+
"""``_clamp``'s floor and ``_env_num``'s clamps (#3599 review R3-L4)."""
|
|
1743
|
+
|
|
1744
|
+
def test_clamp_floors_at_one_second_never_zero(self):
|
|
1745
|
+
"""A 0s timeout fails instantly, turning a near-exhausted budget
|
|
1746
|
+
into a guaranteed failure rather than one last bounded shot."""
|
|
1747
|
+
started = time.monotonic()
|
|
1748
|
+
# Budget already fully spent: remaining <= 0.
|
|
1749
|
+
self.assertEqual(drain_pending._clamp(8, budget=0.0, started=started), 1)
|
|
1750
|
+
# Remaining just under the floor.
|
|
1751
|
+
self.assertEqual(drain_pending._clamp(8, budget=0.9, started=started), 1)
|
|
1752
|
+
|
|
1753
|
+
def test_the_per_entry_timeout_is_floored_at_one_second(self):
|
|
1754
|
+
"""The invariant ``_clamp``'s equivalence proof rests on (R4-Lb).
|
|
1755
|
+
|
|
1756
|
+
``_clamp``'s docstring argues its own ``max(1, ...)`` mutants are
|
|
1757
|
+
equivalent. They are — but only while ``timeout >= 1``: with
|
|
1758
|
+
``timeout == 0`` and a healthy budget, ``max(0, min(0, n))`` returns
|
|
1759
|
+
0, an instant-fail request on every entry. Nothing in ``_clamp``
|
|
1760
|
+
rules that input out; ``_per_entry_timeout``'s floor does, and it is
|
|
1761
|
+
the ONLY thing that does, because ``drain`` takes its ``timeout``
|
|
1762
|
+
from here and passes it to both ``_clamp`` callsites. So the proof
|
|
1763
|
+
lives in one function and its precondition lives in another — pin
|
|
1764
|
+
the precondition, or the proof rots the day someone "simplifies"
|
|
1765
|
+
this ``max``.
|
|
1766
|
+
"""
|
|
1767
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "0"
|
|
1768
|
+
self.assertEqual(drain_pending._per_entry_timeout(), 1)
|
|
1769
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "-30"
|
|
1770
|
+
self.assertEqual(drain_pending._per_entry_timeout(), 1)
|
|
1771
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "garbage"
|
|
1772
|
+
self.assertEqual(drain_pending._per_entry_timeout(), 5, "default on junk")
|
|
1773
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "9"
|
|
1774
|
+
self.assertEqual(drain_pending._per_entry_timeout(), 9, "a real value passes")
|
|
1775
|
+
|
|
1776
|
+
# And the consequence the floor buys, stated at the clamp: the
|
|
1777
|
+
# smallest timeout _clamp can ever be handed still yields a
|
|
1778
|
+
# non-zero, bounded request.
|
|
1779
|
+
started = time.monotonic()
|
|
1780
|
+
self.assertEqual(
|
|
1781
|
+
drain_pending._clamp(
|
|
1782
|
+
drain_pending._per_entry_timeout(), budget=100.0, started=started
|
|
1783
|
+
),
|
|
1784
|
+
9,
|
|
1785
|
+
)
|
|
1786
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "0"
|
|
1787
|
+
self.assertEqual(
|
|
1788
|
+
drain_pending._clamp(
|
|
1789
|
+
drain_pending._per_entry_timeout(), budget=100.0, started=started
|
|
1790
|
+
),
|
|
1791
|
+
1,
|
|
1792
|
+
"a 0s knob must not reach urlopen as a 0s timeout",
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1795
|
+
def test_clamp_hands_out_the_remaining_budget_once_past_the_floor(self):
|
|
1796
|
+
started = time.monotonic()
|
|
1797
|
+
self.assertEqual(drain_pending._clamp(8, budget=3.9, started=started), 3)
|
|
1798
|
+
self.assertEqual(
|
|
1799
|
+
drain_pending._clamp(2, budget=100.0, started=started),
|
|
1800
|
+
2,
|
|
1801
|
+
"the caller's timeout still wins when the budget is ample",
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
def test_env_num_lo_clamp_raises_a_too_small_value(self):
|
|
1805
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "0"
|
|
1806
|
+
self.assertEqual(
|
|
1807
|
+
drain_pending._backlog_timeout(), 1, "lo=1 must raise 0 to 1"
|
|
1808
|
+
)
|
|
1809
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "-5"
|
|
1810
|
+
self.assertEqual(drain_pending._backlog_timeout(), 1)
|
|
1811
|
+
|
|
1812
|
+
def test_env_num_lo_is_inclusive_and_leaves_larger_values_alone(self):
|
|
1813
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "1"
|
|
1814
|
+
self.assertEqual(drain_pending._backlog_timeout(), 1)
|
|
1815
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "45"
|
|
1816
|
+
self.assertEqual(drain_pending._backlog_timeout(), 45)
|
|
1817
|
+
|
|
1818
|
+
def test_env_num_hi_clamp_lowers_a_too_large_value(self):
|
|
1819
|
+
os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "17"
|
|
1820
|
+
self.assertEqual(drain_pending._backlog_concurrency(), 16)
|
|
1821
|
+
os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "16"
|
|
1822
|
+
self.assertEqual(drain_pending._backlog_concurrency(), 16)
|
|
1823
|
+
|
|
1824
|
+
def test_env_num_falls_back_to_the_default_on_garbage(self):
|
|
1825
|
+
default = int(retain_split.retain_client_deadline())
|
|
1826
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "not-a-number"
|
|
1827
|
+
self.assertEqual(drain_pending._backlog_timeout(), default)
|
|
1828
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = ""
|
|
1829
|
+
self.assertEqual(
|
|
1830
|
+
drain_pending._backlog_timeout(), default, "empty is unset, not 0"
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
def test_the_backlog_timeout_defaults_to_the_retain_client_deadline(self):
|
|
1834
|
+
"""The drain deadline and the content bound are ONE number (#3610).
|
|
1835
|
+
|
|
1836
|
+
A maximally-sized part is sized to just fit inside
|
|
1837
|
+
``retain_client_deadline()``. If the drainer's own deadline were
|
|
1838
|
+
shorter — as the ``180`` literal #3599 shipped was — it would abandon a
|
|
1839
|
+
correctly-sized part mid-extraction, leave the entry queued, and
|
|
1840
|
+
rebuild the re-post loop #3599 fixed. Moving the deadline must move
|
|
1841
|
+
both, so this asserts the derivation, not the number.
|
|
1842
|
+
"""
|
|
1843
|
+
os.environ.pop("HINDSIGHT_DRAIN_BACKLOG_TIMEOUT", None)
|
|
1844
|
+
os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
|
|
1845
|
+
# Assert the DERIVATION, per this test's own docstring: the drain
|
|
1846
|
+
# deadline is retain_client_deadline(), whatever that currently is.
|
|
1847
|
+
# It was pinned to the literal 280 here, which meant the test went
|
|
1848
|
+
# stale the moment the deadline became derived from the litellm
|
|
1849
|
+
# routing chain (280 -> 310) instead of being hand-set.
|
|
1850
|
+
self.assertEqual(
|
|
1851
|
+
drain_pending._backlog_timeout(),
|
|
1852
|
+
int(retain_split.retain_client_deadline()),
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "600"
|
|
1856
|
+
try:
|
|
1857
|
+
self.assertEqual(
|
|
1858
|
+
drain_pending._backlog_timeout(),
|
|
1859
|
+
600,
|
|
1860
|
+
"moving the client deadline must move the drain deadline",
|
|
1861
|
+
)
|
|
1862
|
+
self.assertEqual(
|
|
1863
|
+
retain_split.retain_content_limit(),
|
|
1864
|
+
3000 * int(600 // 18.4),
|
|
1865
|
+
"and the content bound, from the same input",
|
|
1866
|
+
)
|
|
1867
|
+
finally:
|
|
1868
|
+
os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
|
|
1869
|
+
|
|
1870
|
+
def test_an_explicit_backlog_timeout_still_overrides_the_derivation(self):
|
|
1871
|
+
os.environ["HINDSIGHT_RETAIN_CLIENT_DEADLINE_S"] = "600"
|
|
1872
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_TIMEOUT"] = "42"
|
|
1873
|
+
try:
|
|
1874
|
+
self.assertEqual(drain_pending._backlog_timeout(), 42)
|
|
1875
|
+
finally:
|
|
1876
|
+
os.environ.pop("HINDSIGHT_RETAIN_CLIENT_DEADLINE_S", None)
|
|
1877
|
+
|
|
1878
|
+
def test_no_clamp_is_applied_when_lo_and_hi_are_absent(self):
|
|
1879
|
+
"""``lo``/``hi`` default to ``None``; that branch must be a no-op,
|
|
1880
|
+
not an accidental floor at 0."""
|
|
1881
|
+
os.environ["X_TEST_KNOB"] = "-42"
|
|
1882
|
+
try:
|
|
1883
|
+
self.assertEqual(
|
|
1884
|
+
drain_pending._env_num("X_TEST_KNOB", 1, int), -42
|
|
1885
|
+
)
|
|
1886
|
+
finally:
|
|
1887
|
+
os.environ.pop("X_TEST_KNOB", None)
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
class P95ProbeTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1891
|
+
"""A configured-but-broken probe is not the same as an unset one."""
|
|
1892
|
+
|
|
1893
|
+
def test_a_failing_probe_is_not_read_as_a_latency_figure(self):
|
|
1894
|
+
"""Exit status was ignored, so a typo'd probe's stdout was trusted."""
|
|
1895
|
+
os.environ["HINDSIGHT_DRAIN_P95_CMD"] = "echo 12345; exit 3"
|
|
1896
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1897
|
+
self.assertEqual(drain_pending._p95_probe_ms(), -1)
|
|
1898
|
+
self.assertIn("exited 3", err.getvalue())
|
|
1899
|
+
self.assertIn("DISABLED", err.getvalue())
|
|
1900
|
+
|
|
1901
|
+
def test_a_probe_that_prints_nothing_says_so(self):
|
|
1902
|
+
os.environ["HINDSIGHT_DRAIN_P95_CMD"] = "true"
|
|
1903
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1904
|
+
self.assertEqual(drain_pending._p95_probe_ms(), -1)
|
|
1905
|
+
self.assertIn("DISABLED", err.getvalue())
|
|
1906
|
+
|
|
1907
|
+
def test_a_healthy_probe_is_read_silently(self):
|
|
1908
|
+
os.environ["HINDSIGHT_DRAIN_P95_CMD"] = "echo 41000"
|
|
1909
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
1910
|
+
self.assertEqual(drain_pending._p95_probe_ms(), 41000)
|
|
1911
|
+
self.assertEqual(err.getvalue(), "")
|
|
1912
|
+
|
|
1913
|
+
|
|
1914
|
+
class CliTest(unittest.TestCase):
|
|
1915
|
+
"""A membership test on argv silently ran the WRONG drain on a typo."""
|
|
1916
|
+
|
|
1917
|
+
def _run(self, argv):
|
|
1918
|
+
seen = {}
|
|
1919
|
+
|
|
1920
|
+
def fake_drain(config=None, backlog=False, phase="both", dry_run=False):
|
|
1921
|
+
seen.update(backlog=backlog, phase=phase, dry_run=dry_run)
|
|
1922
|
+
return drain_pending._new_summary()
|
|
1923
|
+
|
|
1924
|
+
with unittest.mock.patch.object(drain_pending, "drain", fake_drain):
|
|
1925
|
+
with unittest.mock.patch.object(drain_pending, "load_config", lambda: {}):
|
|
1926
|
+
rc = drain_pending.main(argv)
|
|
1927
|
+
return rc, seen
|
|
1928
|
+
|
|
1929
|
+
def test_backlog_flag_selects_backlog_mode(self):
|
|
1930
|
+
_, seen = self._run(["--backlog"])
|
|
1931
|
+
self.assertTrue(seen["backlog"])
|
|
1932
|
+
_, seen = self._run([])
|
|
1933
|
+
self.assertFalse(seen["backlog"])
|
|
1934
|
+
|
|
1935
|
+
def test_typo_is_rejected_rather_than_silently_running_the_hook_drain(self):
|
|
1936
|
+
with redirect_stderr(io.StringIO()):
|
|
1937
|
+
with self.assertRaises(SystemExit) as cm:
|
|
1938
|
+
self._run(["--backlogg"])
|
|
1939
|
+
self.assertNotEqual(cm.exception.code, 0)
|
|
1940
|
+
|
|
1941
|
+
def test_phase_and_dry_run_are_plumbed(self):
|
|
1942
|
+
_, seen = self._run(["--backlog", "--phase", "reconcile", "--dry-run"])
|
|
1943
|
+
self.assertEqual(seen["phase"], "reconcile")
|
|
1944
|
+
self.assertTrue(seen["dry_run"])
|
|
1945
|
+
|
|
1946
|
+
def test_phase_without_backlog_is_refused(self):
|
|
1947
|
+
with redirect_stderr(io.StringIO()):
|
|
1948
|
+
rc, _ = self._run(["--phase", "reconcile"])
|
|
1949
|
+
self.assertEqual(rc, 2)
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
class StallGuardBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
1953
|
+
"""The stall guard counts CONSECUTIVE failures of the SAME class.
|
|
1954
|
+
|
|
1955
|
+
Every prior stall test drove a homogeneous error stream, which cannot
|
|
1956
|
+
tell ``err_class == last_error_class`` from ``!=``: with one class the
|
|
1957
|
+
mutant takes the increment branch every time and the reset branch is
|
|
1958
|
+
never reached, so the drain stalls identically. Distinguishing them
|
|
1959
|
+
needs an ALTERNATING stream, where the real guard resets and the
|
|
1960
|
+
mutant stalls. Same false-green shape as Blocker 1 — the assertion
|
|
1961
|
+
exercised the code without pinning the invariant.
|
|
1962
|
+
"""
|
|
1963
|
+
|
|
1964
|
+
def _queue(self, n):
|
|
1965
|
+
for i in range(n):
|
|
1966
|
+
pending.enqueue(
|
|
1967
|
+
_payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
|
|
1968
|
+
)
|
|
1969
|
+
self.assertEqual(pending.count(), n)
|
|
1970
|
+
|
|
1971
|
+
def _alternating(self):
|
|
1972
|
+
"""A failure stream whose error CLASS changes every entry."""
|
|
1973
|
+
classes = [ConnectionError, TimeoutError]
|
|
1974
|
+
n = {"i": 0}
|
|
1975
|
+
|
|
1976
|
+
def fail(entry, timeout):
|
|
1977
|
+
cls = classes[n["i"] % 2]
|
|
1978
|
+
n["i"] += 1
|
|
1979
|
+
raise cls("upstream flapping")
|
|
1980
|
+
|
|
1981
|
+
return fail
|
|
1982
|
+
|
|
1983
|
+
def test_alternating_error_classes_never_stall_the_drain(self):
|
|
1984
|
+
self._queue(6)
|
|
1985
|
+
with unittest.mock.patch.object(
|
|
1986
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
1987
|
+
):
|
|
1988
|
+
with unittest.mock.patch.object(
|
|
1989
|
+
drain_pending, "_retry_one", self._alternating()
|
|
1990
|
+
):
|
|
1991
|
+
with redirect_stderr(io.StringIO()):
|
|
1992
|
+
summary = drain_pending.drain(CONFIG)
|
|
1993
|
+
|
|
1994
|
+
self.assertFalse(
|
|
1995
|
+
summary["stalled"],
|
|
1996
|
+
"a flapping upstream is not a stall — the counter must RESET "
|
|
1997
|
+
"when the error class changes",
|
|
1998
|
+
)
|
|
1999
|
+
self.assertEqual(summary["retried"], 6, "every entry got its attempt")
|
|
2000
|
+
self.assertEqual(pending.count(), 6, "failed entries stay queued")
|
|
2001
|
+
|
|
2002
|
+
def test_alternating_error_classes_never_stall_the_backlog_drain(self):
|
|
2003
|
+
self._queue(6)
|
|
2004
|
+
os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "1"
|
|
2005
|
+
with unittest.mock.patch.object(
|
|
2006
|
+
drain_pending, "_retry_one", self._alternating()
|
|
2007
|
+
):
|
|
2008
|
+
with redirect_stderr(io.StringIO()):
|
|
2009
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
2010
|
+
|
|
2011
|
+
self.assertFalse(summary["stalled"])
|
|
2012
|
+
self.assertEqual(summary["retried"], 6)
|
|
2013
|
+
self.assertEqual(pending.count(), 6)
|
|
2014
|
+
|
|
2015
|
+
def test_the_drain_stalls_at_exactly_stall_threshold(self):
|
|
2016
|
+
"""AT the threshold, not one past it.
|
|
2017
|
+
|
|
2018
|
+
Queueing exactly ``STALL_THRESHOLD`` entries is what separates
|
|
2019
|
+
``>=`` from ``>``: with ``>`` the run ends by exhausting the queue
|
|
2020
|
+
and reports no stall at all.
|
|
2021
|
+
"""
|
|
2022
|
+
self._queue(drain_pending.STALL_THRESHOLD)
|
|
2023
|
+
|
|
2024
|
+
def always_fail(entry, timeout):
|
|
2025
|
+
raise ConnectionError("upstream down")
|
|
2026
|
+
|
|
2027
|
+
with unittest.mock.patch.object(
|
|
2028
|
+
drain_pending, "_document_state", lambda e, timeout=30: False
|
|
2029
|
+
):
|
|
2030
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", always_fail):
|
|
2031
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2032
|
+
summary = drain_pending.drain(CONFIG)
|
|
2033
|
+
|
|
2034
|
+
self.assertTrue(summary["stalled"])
|
|
2035
|
+
self.assertIn("stalling drain", err.getvalue())
|
|
2036
|
+
self.assertEqual(pending.count(), drain_pending.STALL_THRESHOLD)
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
class BudgetBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2040
|
+
"""The budget is spent DOWN TO the tick, not one tick short.
|
|
2041
|
+
|
|
2042
|
+
``elapsed > budget`` vs ``>=`` is invisible to every wall-clock test
|
|
2043
|
+
(real elapsed never lands exactly on the budget), so both loops are
|
|
2044
|
+
driven here on a virtual clock that lands on it exactly.
|
|
2045
|
+
"""
|
|
2046
|
+
|
|
2047
|
+
def _queue(self, n):
|
|
2048
|
+
for i in range(n):
|
|
2049
|
+
pending.enqueue(
|
|
2050
|
+
_payload(content=f"turn-{i}", doc=_cd_doc(i)), RuntimeError("boom")
|
|
2051
|
+
)
|
|
2052
|
+
|
|
2053
|
+
def test_the_sequential_drain_uses_the_last_tick_of_its_budget(self):
|
|
2054
|
+
self._queue(2)
|
|
2055
|
+
os.environ["HINDSIGHT_DRAIN_BUDGET_S"] = "10"
|
|
2056
|
+
os.environ["HINDSIGHT_DRAIN_TIMEOUT"] = "10"
|
|
2057
|
+
start = 1000.0
|
|
2058
|
+
clock = {"t": start}
|
|
2059
|
+
gets = []
|
|
2060
|
+
|
|
2061
|
+
def fake_get(entry, timeout=30):
|
|
2062
|
+
gets.append(timeout)
|
|
2063
|
+
clock["t"] = start + 10.0 # entry 1 consumes the budget EXACTLY
|
|
2064
|
+
return True
|
|
2065
|
+
|
|
2066
|
+
with unittest.mock.patch.object(
|
|
2067
|
+
drain_pending.time, "monotonic", lambda: clock["t"]
|
|
2068
|
+
):
|
|
2069
|
+
with unittest.mock.patch.object(
|
|
2070
|
+
drain_pending, "_document_state", fake_get
|
|
2071
|
+
):
|
|
2072
|
+
summary = drain_pending.drain(CONFIG)
|
|
2073
|
+
|
|
2074
|
+
self.assertEqual(len(gets), 2, "entry 2 is still inside the budget at t==B")
|
|
2075
|
+
self.assertEqual(summary["reconciled"], 2)
|
|
2076
|
+
self.assertFalse(summary["budget_exceeded"])
|
|
2077
|
+
self.assertEqual(pending.count(), 0)
|
|
2078
|
+
|
|
2079
|
+
def test_the_backlog_wave_loop_uses_the_last_tick_of_its_budget(self):
|
|
2080
|
+
self._queue(2)
|
|
2081
|
+
os.environ["HINDSIGHT_DRAIN_BACKLOG_BUDGET_S"] = "10"
|
|
2082
|
+
os.environ["HINDSIGHT_DRAIN_CONCURRENCY"] = "1"
|
|
2083
|
+
# Keep _wait_for_upstream off the clock; its own boundary is pinned
|
|
2084
|
+
# by WaitForUpstreamBoundaryTest.
|
|
2085
|
+
os.environ["HINDSIGHT_DRAIN_P95_BACKOFF_MS"] = "0"
|
|
2086
|
+
start = 1000.0
|
|
2087
|
+
clock = {"t": start}
|
|
2088
|
+
|
|
2089
|
+
def fake_post(entry, timeout):
|
|
2090
|
+
clock["t"] = start + 10.0 # wave 1 consumes the budget EXACTLY
|
|
2091
|
+
|
|
2092
|
+
with unittest.mock.patch.object(
|
|
2093
|
+
drain_pending.time, "monotonic", lambda: clock["t"]
|
|
2094
|
+
):
|
|
2095
|
+
with unittest.mock.patch.object(drain_pending, "_retry_one", fake_post):
|
|
2096
|
+
with unittest.mock.patch.object(
|
|
2097
|
+
drain_pending, "_document_state", lambda e, timeout=30: True
|
|
2098
|
+
):
|
|
2099
|
+
with redirect_stderr(io.StringIO()):
|
|
2100
|
+
summary = drain_pending.drain_backlog(CONFIG, phase="drain")
|
|
2101
|
+
|
|
2102
|
+
self.assertEqual(summary["drained"], 2, "wave 2 starts at exactly t==B")
|
|
2103
|
+
self.assertFalse(summary["budget_exceeded"])
|
|
2104
|
+
self.assertEqual(pending.count(), 0)
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
class WaitForUpstreamBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2108
|
+
"""``_wait_for_upstream`` called directly — its three comparisons.
|
|
2109
|
+
|
|
2110
|
+
Driving it through ``drain_backlog`` only ever exercised the
|
|
2111
|
+
disabled/slow/recovered cases; the boundaries below all survived a
|
|
2112
|
+
mutation sweep because no test pinned them.
|
|
2113
|
+
"""
|
|
2114
|
+
|
|
2115
|
+
def _wait(self, backoff_ms, probes, now=1000.0, started=1000.0, budget=3600.0):
|
|
2116
|
+
slept = []
|
|
2117
|
+
seq = list(probes)
|
|
2118
|
+
with unittest.mock.patch.object(
|
|
2119
|
+
drain_pending.time, "monotonic", lambda: now
|
|
2120
|
+
):
|
|
2121
|
+
with unittest.mock.patch.object(
|
|
2122
|
+
drain_pending, "_p95_probe_ms", lambda: seq.pop(0)
|
|
2123
|
+
):
|
|
2124
|
+
with unittest.mock.patch.object(
|
|
2125
|
+
drain_pending.time, "sleep", slept.append
|
|
2126
|
+
):
|
|
2127
|
+
with redirect_stderr(io.StringIO()) as err:
|
|
2128
|
+
got = drain_pending._wait_for_upstream(
|
|
2129
|
+
backoff_ms, started, budget
|
|
2130
|
+
)
|
|
2131
|
+
return got, slept, err.getvalue()
|
|
2132
|
+
|
|
2133
|
+
def test_backoff_disabled_never_pauses_even_when_the_probe_says_slow(self):
|
|
2134
|
+
"""``backoff_ms <= 0`` means OFF — the probe is not consulted.
|
|
2135
|
+
|
|
2136
|
+
With ``<`` instead of ``<=``, a 0 (explicitly disabled) falls into
|
|
2137
|
+
the wait loop and a configured probe reporting 99s of p95 pauses
|
|
2138
|
+
the drain for two minutes a wave. Disabled must mean disabled.
|
|
2139
|
+
"""
|
|
2140
|
+
got, slept, _ = self._wait(0, [99000, 99000, 99000])
|
|
2141
|
+
self.assertTrue(got)
|
|
2142
|
+
self.assertEqual(slept, [], "a disabled backoff must never sleep")
|
|
2143
|
+
|
|
2144
|
+
def test_a_p95_exactly_at_the_threshold_is_not_slow(self):
|
|
2145
|
+
"""The knob is the tolerated ceiling, inclusive."""
|
|
2146
|
+
got, slept, out = self._wait(38000, [38000])
|
|
2147
|
+
self.assertTrue(got)
|
|
2148
|
+
self.assertEqual(slept, [])
|
|
2149
|
+
self.assertNotIn("BACKOFF", out)
|
|
2150
|
+
|
|
2151
|
+
def test_one_ms_over_the_threshold_is_slow(self):
|
|
2152
|
+
got, slept, out = self._wait(38000, [38001, 38000])
|
|
2153
|
+
self.assertTrue(got)
|
|
2154
|
+
self.assertEqual(slept, [120], "paused once, then p95 came back in")
|
|
2155
|
+
self.assertIn("BACKOFF", out)
|
|
2156
|
+
|
|
2157
|
+
def test_it_still_waits_when_the_pause_exactly_fits_the_budget(self):
|
|
2158
|
+
"""120s of pause with exactly 120s left is affordable, not a give-up.
|
|
2159
|
+
|
|
2160
|
+
``elapsed + 120 > budget`` vs ``>=``: only a clock that lands
|
|
2161
|
+
exactly on the budget separates them, so this one is virtual.
|
|
2162
|
+
"""
|
|
2163
|
+
got, slept, out = self._wait(
|
|
2164
|
+
38000, [99000, 1000], now=1120.0, started=1000.0, budget=240.0
|
|
2165
|
+
)
|
|
2166
|
+
self.assertTrue(got, "the wait fit the budget, so it must be taken")
|
|
2167
|
+
self.assertEqual(slept, [120])
|
|
2168
|
+
self.assertNotIn("budget is exhausted", out)
|
|
2169
|
+
|
|
2170
|
+
def test_it_gives_up_when_the_pause_would_overrun_the_budget(self):
|
|
2171
|
+
got, slept, out = self._wait(
|
|
2172
|
+
38000, [99000], now=1121.0, started=1000.0, budget=240.0
|
|
2173
|
+
)
|
|
2174
|
+
self.assertFalse(got)
|
|
2175
|
+
self.assertEqual(slept, [])
|
|
2176
|
+
self.assertIn("budget is exhausted", out)
|
|
2177
|
+
|
|
2178
|
+
def test_a_zero_p95_is_fast_not_unknown(self):
|
|
2179
|
+
"""``p95 < 0`` is the UNKNOWN sentinel; 0 is a real, fast reading.
|
|
2180
|
+
|
|
2181
|
+
(``< 0`` vs ``<= 0`` is an equivalent mutant here — a 0 reading
|
|
2182
|
+
returns True down either branch, since it is also ``<= backoff_ms``
|
|
2183
|
+
for any enabled backoff. Pinned anyway: the OUTCOME is what the
|
|
2184
|
+
drain depends on, and it must not become a pause.)
|
|
2185
|
+
"""
|
|
2186
|
+
got, slept, _ = self._wait(38000, [0])
|
|
2187
|
+
self.assertTrue(got)
|
|
2188
|
+
self.assertEqual(slept, [])
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
class ClipErrorBoundaryTest(unittest.TestCase):
|
|
2192
|
+
"""``_clip_error`` clips ABOVE the cap and leaves the cap itself alone.
|
|
2193
|
+
|
|
2194
|
+
The ledger and every queued entry carry this string; the cap exists so
|
|
2195
|
+
one pathological upstream message cannot bloat the queue. ``<=`` vs
|
|
2196
|
+
``<`` is invisible unless a message lands exactly on the cap, so that
|
|
2197
|
+
is the case pinned here.
|
|
2198
|
+
"""
|
|
2199
|
+
|
|
2200
|
+
CAP = pending.MAX_ERROR_MESSAGE_CHARS
|
|
2201
|
+
|
|
2202
|
+
def test_a_message_exactly_at_the_cap_is_untouched(self):
|
|
2203
|
+
msg = "x" * self.CAP
|
|
2204
|
+
out = pending._clip_error(RuntimeError(msg))
|
|
2205
|
+
self.assertEqual(out, msg)
|
|
2206
|
+
self.assertNotIn("truncated", out)
|
|
2207
|
+
|
|
2208
|
+
def test_one_character_over_the_cap_is_clipped(self):
|
|
2209
|
+
out = pending._clip_error(RuntimeError("x" * (self.CAP + 1)))
|
|
2210
|
+
self.assertTrue(out.endswith("…[truncated]"))
|
|
2211
|
+
self.assertEqual(out[: self.CAP], "x" * self.CAP)
|
|
2212
|
+
|
|
2213
|
+
def test_a_short_message_is_untouched(self):
|
|
2214
|
+
self.assertEqual(pending._clip_error(RuntimeError("boom")), "boom")
|
|
2215
|
+
|
|
2216
|
+
|
|
2217
|
+
class EvictionsLogRotationBoundaryTest(_QueueTempDirMixin, unittest.TestCase):
|
|
2218
|
+
"""The eviction ledger rotates ABOVE its cap, not AT it.
|
|
2219
|
+
|
|
2220
|
+
``switchroom doctor`` reads this file, and rotation drops all but
|
|
2221
|
+
``EVICTIONS_LOG_KEEP_LINES``. ``>`` vs ``>=`` costs a whole ledger's
|
|
2222
|
+
history on a file that is exactly at its ceiling — invisible to the
|
|
2223
|
+
existing rotation test, which sets the cap to 1 byte and so can never
|
|
2224
|
+
land on the boundary.
|
|
2225
|
+
"""
|
|
2226
|
+
|
|
2227
|
+
def setUp(self):
|
|
2228
|
+
super().setUp()
|
|
2229
|
+
self._prev = (
|
|
2230
|
+
pending.EVICTIONS_LOG_MAX_BYTES,
|
|
2231
|
+
pending.EVICTIONS_LOG_KEEP_LINES,
|
|
2232
|
+
)
|
|
2233
|
+
self.addCleanup(self._restore)
|
|
2234
|
+
log = pending.evictions_log_path()
|
|
2235
|
+
os.makedirs(os.path.dirname(log), exist_ok=True)
|
|
2236
|
+
self.log = log
|
|
2237
|
+
|
|
2238
|
+
def _restore(self):
|
|
2239
|
+
(
|
|
2240
|
+
pending.EVICTIONS_LOG_MAX_BYTES,
|
|
2241
|
+
pending.EVICTIONS_LOG_KEEP_LINES,
|
|
2242
|
+
) = self._prev
|
|
2243
|
+
|
|
2244
|
+
def _one_line_bytes(self):
|
|
2245
|
+
"""Write one ledger line with rotation effectively off; return its size."""
|
|
2246
|
+
pending.EVICTIONS_LOG_MAX_BYTES = 10**9
|
|
2247
|
+
pending.EVICTIONS_LOG_KEEP_LINES = 1
|
|
2248
|
+
with redirect_stderr(io.StringIO()):
|
|
2249
|
+
pending._log_eviction("aaaa.json", 10, "count", 1, 10)
|
|
2250
|
+
return os.path.getsize(self.log)
|
|
2251
|
+
|
|
2252
|
+
def _lines(self):
|
|
2253
|
+
with open(self.log, encoding="utf-8") as f:
|
|
2254
|
+
return [ln for ln in f.read().splitlines() if ln]
|
|
2255
|
+
|
|
2256
|
+
def test_a_ledger_exactly_at_the_cap_is_not_rotated(self):
|
|
2257
|
+
one = self._one_line_bytes()
|
|
2258
|
+
# Both lines are the same fixed-width shape, so two of them land
|
|
2259
|
+
# exactly on a 2*one cap.
|
|
2260
|
+
pending.EVICTIONS_LOG_MAX_BYTES = 2 * one
|
|
2261
|
+
with redirect_stderr(io.StringIO()):
|
|
2262
|
+
pending._log_eviction("aaaa.json", 10, "count", 1, 10)
|
|
2263
|
+
self.assertEqual(os.path.getsize(self.log), 2 * one, "fixture assumption")
|
|
2264
|
+
self.assertEqual(len(self._lines()), 2, "a ledger AT the cap is within it")
|
|
2265
|
+
|
|
2266
|
+
def test_one_byte_over_the_cap_rotates_to_the_keep_window(self):
|
|
2267
|
+
one = self._one_line_bytes()
|
|
2268
|
+
pending.EVICTIONS_LOG_MAX_BYTES = 2 * one - 1
|
|
2269
|
+
with redirect_stderr(io.StringIO()):
|
|
2270
|
+
pending._log_eviction("aaaa.json", 10, "count", 1, 10)
|
|
2271
|
+
self.assertEqual(len(self._lines()), 1, "over the cap, rotation runs")
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
if __name__ == "__main__":
|
|
2275
|
+
unittest.main()
|