feat(failure_auto_filer): close PARTIAL gaps — dichotomy wave
This commit is contained in:
1 parent
643060d7ff
commit
5cadd40157
9 files changed
+2480
-4
No files matched your search
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze oracle truth for failure_auto_filer (gap-closing wave).
|
||||
|
||||
Two artifacts, both consumed VERBATIM by the Go battery:
|
||||
1. src/go/failure-filer/frozen_failure_auto_filer_truth.json — pure
|
||||
module-level functions via the shared freeze_truth harness (every
|
||||
public function and JSON-expressible branch).
|
||||
2. src/go/failure-filer/frozen_filer_scenarios.json — deterministic
|
||||
FailureAutoFiler behavior traces (wait=True, fake client + fake
|
||||
journal, pinned ``_now``); wall-clock fields normalized so both
|
||||
sides agree. Exception-only branches (error_class_of on an
|
||||
exception instance) are covered by Go unit tests — exceptions are
|
||||
not JSON-expressible, so they cannot ride the frozen truth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
sys.path.insert(0, str(REPO / "scripts"))
|
||||
|
||||
from freeze_truth import freeze, write # noqa: E402
|
||||
|
||||
from rune import failure_auto_filer as faf # noqa: E402
|
||||
|
||||
TRUTH_PATH = REPO / "src/go/failure-filer/frozen_failure_auto_filer_truth.json"
|
||||
SCENARIOS_PATH = REPO / "src/go/failure-filer/frozen_filer_scenarios.json"
|
||||
|
||||
LONG = "A" * 70 # > _ERROR_CLASS_MAX token
|
||||
CRED_URL = "postgres://admin:hunter2@db.example.com:5432/rune?password=sekret&token=abc123&host=x"
|
||||
|
||||
|
||||
# ── deterministic fakes (mirrored 1:1 by the Go battery) ────────────────────
|
||||
|
||||
_TS = re.compile(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ")
|
||||
|
||||
|
||||
def _norm(text: str) -> str:
|
||||
return _TS.sub("<TS>", text)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, issues=None, fail=False):
|
||||
self.calls = []
|
||||
self.issues = [dict(i) for i in (issues or [])]
|
||||
self.fail = fail
|
||||
|
||||
def search_issues(self, marker):
|
||||
self.calls.append(["search_issues", marker])
|
||||
if self.fail:
|
||||
raise Exception("forge down")
|
||||
return [dict(i) for i in self.issues]
|
||||
|
||||
def add_comment(self, number, body):
|
||||
self.calls.append(["add_comment", number, _norm(body)])
|
||||
if self.fail:
|
||||
raise Exception("forge down")
|
||||
|
||||
def create_issue(self, title, body):
|
||||
self.calls.append(["create_issue", title, _norm(body)])
|
||||
if self.fail:
|
||||
raise Exception("forge down")
|
||||
self.calls[-1] = ["create_issue", title, _norm(body)]
|
||||
return {"number": len(self.issues) + 1, "created": True}
|
||||
|
||||
|
||||
class FakeJournal:
|
||||
def __init__(self, fail_unconsumed=False, fail_append=False):
|
||||
self.records = []
|
||||
self.consumed = []
|
||||
self.fail_unconsumed = fail_unconsumed
|
||||
self.fail_append = fail_append
|
||||
|
||||
def append(self, record):
|
||||
if self.fail_append:
|
||||
raise Exception("journal append failed")
|
||||
record = dict(record)
|
||||
record["spool_id"] = f"spool-det-{len(self.records)}"
|
||||
record["timestamp"] = "<TS>"
|
||||
self.records.append(record)
|
||||
|
||||
def unconsumed(self):
|
||||
if self.fail_unconsumed:
|
||||
raise Exception("journal read failed")
|
||||
return [dict(r) for r in self.records if r["spool_id"] not in self.consumed]
|
||||
|
||||
def mark_consumed(self, spool_id):
|
||||
self.consumed.append(spool_id)
|
||||
|
||||
|
||||
# ── scenarios: each returns a deterministic trace dict ──────────────────────
|
||||
|
||||
def sc_submit_t2_gate():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f.submit("sub", "ValueError: boom", 2, 100.0)
|
||||
f.drain()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": []}
|
||||
|
||||
|
||||
def sc_submit_coalesce_create():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
for i in range(3):
|
||||
f.submit("api", "TimeoutError: t", 1, 100.0 + i)
|
||||
f.drain()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": []}
|
||||
|
||||
|
||||
def sc_file_first_only():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
returns = [
|
||||
f.file("s", "E: a", 1, 100.0),
|
||||
f.file("s", "E: b", 1, 101.0),
|
||||
f.file("s", "E: c", 1, 102.0),
|
||||
]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_batch_full():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=3, wait=True)
|
||||
returns = [
|
||||
f.file("s", "E: a", 1, 100.0),
|
||||
f.file("s", "E: b", 1, 101.0),
|
||||
f.file("s", "E: c", 1, 102.0),
|
||||
]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_window_expiry():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
returns = [f.file("s", "E: a", 1, 100.0), f.file("s", "E: b", 1, 100.0 + 600)]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_comment_existing():
|
||||
client = FakeClient(issues=[{"number": 7, "title": "[auto-failure:s:E] s"}])
|
||||
journal = FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
returns = [f.file("s", "E: latest text", 1, 100.0)]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_t2():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
returns = [f.file("s", "E: cron noise", 2, 100.0)]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_client_failure_spools():
|
||||
client, journal = FakeClient(fail=True), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
returns = [f.file("api", "TimeoutError: down", 1, 100.0)]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_single_flight():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f._inflight.add(("s", "E"))
|
||||
returns = [f.file("s", "E: x", 1, 100.0)]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_file_spool_journal_failure_never_raises():
|
||||
client, journal = FakeClient(fail=True), FakeJournal(fail_append=True)
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
returns = [f.file("api", "TimeoutError: down", 1, 100.0)]
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": returns}
|
||||
|
||||
|
||||
def sc_call_dunder_is_submit():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f("api", "ValueError: boom", 1, 100.0)
|
||||
f.drain()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": []}
|
||||
|
||||
|
||||
def sc_reconcile_groups_and_marks_consumed():
|
||||
client, journal = FakeClient(fail=True), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f.file("api", "TimeoutError: t1", 1, 100.0)
|
||||
f.file("api", "TimeoutError: t2", 1, 101.0)
|
||||
client.fail = False
|
||||
report = f.reconcile()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_reconcile_comment_existing():
|
||||
client, journal = FakeClient(fail=True), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f.file("api", "TimeoutError: t1", 1, 100.0)
|
||||
client.fail = False
|
||||
client.issues = [{"number": 9, "title": "[auto-failure:api:TimeoutError] api"}]
|
||||
report = f.reconcile()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_reconcile_t2_skipped():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
journal.append({"kind": faf.SPOOL_KIND, "spool_id": "spool-cron", "timestamp": "<TS>",
|
||||
"signature": ["cron", "Noise"], "tier": 2, "count": 1,
|
||||
"error": "noise", "client_error": "-"})
|
||||
report = f.reconcile()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_reconcile_malformed_line():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
journal.append({"kind": faf.SPOOL_KIND, "spool_id": "spool-bad", "timestamp": "<TS>",
|
||||
"signature": ["only-one"], "tier": 1, "count": 1,
|
||||
"error": "x", "client_error": "-"})
|
||||
report = f.reconcile()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_reconcile_failing_client_keeps_spooled():
|
||||
client, journal = FakeClient(fail=True), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f.file("api", "TimeoutError: t1", 1, 100.0)
|
||||
report = f.reconcile()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_reconcile_journal_read_failure():
|
||||
client, journal = FakeClient(), FakeJournal(fail_unconsumed=True)
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
report = f.reconcile()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_reconcile_null_adapter_result():
|
||||
"""create_issue returning created=False must count as failed, not filed."""
|
||||
client, journal = FakeClient(fail=True), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f.file("api", "TimeoutError: t1", 1, 100.0)
|
||||
# swap in a NullAdapter-shaped client: create_issue reports created=False
|
||||
client2 = FakeClient()
|
||||
|
||||
def null_create(title, body):
|
||||
client2.calls.append(["create_issue", title, _norm(body)])
|
||||
return {"created": False}
|
||||
|
||||
client2.create_issue = null_create # type: ignore[method-assign]
|
||||
f._client = client2 # type: ignore[attr-defined]
|
||||
report = f.reconcile()
|
||||
return {"calls": client2.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"report": report}
|
||||
|
||||
|
||||
def sc_queue_full_spools_overflow():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f._queue = __import__("queue").Queue(maxsize=2) # type: ignore[assignment]
|
||||
for i in range(4):
|
||||
f.submit("api", "TimeoutError: t", 1, 100.0)
|
||||
f.drain()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": []}
|
||||
|
||||
|
||||
def sc_drain_twice_idempotent():
|
||||
client, journal = FakeClient(), FakeJournal()
|
||||
f = faf.FailureAutoFiler(client, journal, batch_size=10, wait=True)
|
||||
f.submit("api", "TimeoutError: t", 1, 100.0)
|
||||
f.drain()
|
||||
f.drain()
|
||||
return {"calls": client.calls, "records": journal.records, "consumed": journal.consumed,
|
||||
"returns": []}
|
||||
|
||||
|
||||
SCENARIOS = [
|
||||
sc_submit_t2_gate, sc_submit_coalesce_create, sc_file_first_only,
|
||||
sc_file_batch_full, sc_file_window_expiry, sc_file_comment_existing,
|
||||
sc_file_t2, sc_file_client_failure_spools, sc_file_single_flight,
|
||||
sc_file_spool_journal_failure_never_raises, sc_call_dunder_is_submit,
|
||||
sc_reconcile_groups_and_marks_consumed, sc_reconcile_comment_existing,
|
||||
sc_reconcile_t2_skipped, sc_reconcile_malformed_line,
|
||||
sc_reconcile_failing_client_keeps_spooled, sc_reconcile_journal_read_failure,
|
||||
sc_reconcile_null_adapter_result, sc_queue_full_spools_overflow,
|
||||
sc_drain_twice_idempotent,
|
||||
]
|
||||
|
||||
|
||||
# ── pure-function case matrix (every JSON-expressible branch) ───────────────
|
||||
|
||||
def pure_cases():
|
||||
cases = []
|
||||
add = cases.append
|
||||
# error_class_of — string branches
|
||||
add(("error_class_of", ("RuntimeError: boom",), {}))
|
||||
add(("error_class_of", ("plain token error",), {}))
|
||||
add(("error_class_of", ("",), {})) # empty → Unknown
|
||||
add(("error_class_of", (" ",), {})) # whitespace-only → Unknown
|
||||
add(("error_class_of", ("ValueError:",), {})) # trailing colon stripped
|
||||
add(("error_class_of", ("ValueError::",), {})) # rstrip removes ALL colons
|
||||
add(("error_class_of", (LONG + ": tail",), {})) # capped at 64
|
||||
add(("error_class_of", (LONG,), {})) # capped at 64, no tail
|
||||
add(("error_class_of", ("::weird::",), {})) # colons both sides
|
||||
add(("error_class_of", ("BrokerError: postgres://u:p@h",), {}))
|
||||
# signature_of
|
||||
add(("signature_of", ("api", "TimeoutError: down"), {}))
|
||||
add(("signature_of", ("api", ""), {}))
|
||||
# tier_gated — the gate boundaries
|
||||
for t in (0, 1, 2, 3):
|
||||
add(("tier_gated", (t,), {}))
|
||||
# marker_for
|
||||
add(("marker_for", ("api", "TimeoutError"), {}))
|
||||
add(("marker_for", ("", ""), {}))
|
||||
# excerpt — default limit and custom
|
||||
add(("excerpt", (CRED_URL,), {}))
|
||||
add(("excerpt", (CRED_URL,), {"limit": 40}))
|
||||
add(("excerpt", ("short",), {"limit": 200}))
|
||||
add(("excerpt", ("x" * 300,), {})) # truncation
|
||||
add(("excerpt", ("x" * 300,), {"limit": 5}))
|
||||
# redact — each pattern family + clean + combined
|
||||
add(("redact", ("RuntimeError: boom",), {})) # clean: no-op
|
||||
add(("redact", ("GET https://h/x?token=tok123&ok=1 done",), {}))
|
||||
add(("redact", ("GET https://h/x?API_KEY=ABC&sig=s1&access_token=at9 done",), {}))
|
||||
add(("redact", (CRED_URL,), {}))
|
||||
add(("redact", ("redis://:s3cret@cache:6379",), {})) # empty user
|
||||
add(("redact", ("https://user:pass@host/path",), {}))
|
||||
add(("redact", ("Authorization: Bearer abc.def.ghi expired",), {}))
|
||||
add(("redact", ("bearer tok-here trailing",), {}))
|
||||
add(("redact", ("password=hunter2 and secret: 'quoted'",), {}))
|
||||
add(("redact", ("api_key='abcd1234' auth: short",), {}))
|
||||
add(("redact", ("password=short",), {})) # <4 chars: NOT redacted
|
||||
add(("redact", ("auth=x1 auth=abcd1234",), {}))
|
||||
add(("redact", ("connect postgres://u:p@h then ?token=t&x=2",), {})) # combined
|
||||
# _error_text — string branch (exception branch covered by Go unit tests)
|
||||
add(("_error_text", ("plain string",), {}))
|
||||
return cases
|
||||
|
||||
|
||||
def main() -> int:
|
||||
truth = freeze("failure_auto_filer", pure_cases(), oracle_module="rune.failure_auto_filer")
|
||||
write(truth, TRUTH_PATH)
|
||||
print(f"frozen truth: {len(truth)} cases -> {TRUTH_PATH.relative_to(REPO)}")
|
||||
|
||||
scenarios = [{"name": fn.__name__.removeprefix("sc_"), "trace": fn()} for fn in SCENARIOS]
|
||||
SCENARIOS_PATH.write_text(
|
||||
json.dumps(scenarios, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
|
||||
)
|
||||
print(f"frozen scenarios: {len(scenarios)} -> {SCENARIOS_PATH.relative_to(REPO)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Open + walk the #2479 gap-closing construction contract for
|
||||
failure_auto_filer to verifying. Full lifecycle in ONE process."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from rune.construction_interface import ConstructionInterface
|
||||
|
||||
iface = ConstructionInterface(builder="factory-gap-faf")
|
||||
|
||||
r = iface.submit_need(
|
||||
"Close the failure_auto_filer dichotomy gap (#2479 tranche): the Go "
|
||||
"port is PARTIAL (11 fns) — port the missing worker/pipeline half "
|
||||
"(_error_text, JsonlSpool append/unconsumed/mark_consumed, "
|
||||
"FailureAutoFiler __call__/submit/drain/file/reconcile/_write/"
|
||||
"_write_once/_spool/_run_worker/_process_batch/_flush_group/"
|
||||
"_drain_once) name-preserving with logic 1:1, forge IO + journal IO "
|
||||
"+ clock behind injectable seams, no goroutine spawns in test paths "
|
||||
"(wait=True synchronous mode mirrors the oracle). Freeze oracle "
|
||||
"truth via the shared scripts/freeze_truth.py harness (every public "
|
||||
"function and branch) and embed it VERBATIM in the Go battery; live "
|
||||
"parity 100% on the new fns; all existing tests stay green.",
|
||||
origin="builder",
|
||||
)
|
||||
print("submit_need:", json.dumps(r)[:300])
|
||||
if not r.get("success"):
|
||||
print("NEED FAILED — aborting")
|
||||
sys.exit(1)
|
||||
cid = r["ids"]["contract_id"]
|
||||
print("contract_id:", cid)
|
||||
for phase in ("approved", "building", "verifying"):
|
||||
t = iface.transition(cid, phase)
|
||||
print(f"transition->{phase}:", json.dumps(t)[:150])
|
||||
if not t.get("success"):
|
||||
sys.exit(1)
|
||||
print("CONTRACT", cid, "verifying")
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
// battery_test.go — frozen-oracle battery for the pure pipeline fns
|
||||
// (frozen_failure_auto_filer_truth.json, generated by
|
||||
// scripts/gen_failure_auto_filer_truth.py via the shared
|
||||
// scripts/freeze_truth.py harness; VERIFIED byte-identical against the
|
||||
// live Python oracle rune.failure_auto_filer with
|
||||
// PYTHONPATH=src python3 scripts/freeze_truth.py --verify \
|
||||
// src/go/failure-filer/frozen_failure_auto_filer_truth.json \
|
||||
// --module rune.failure_auto_filer
|
||||
// ). Replayed here VERBATIM — no hand-transcribed expectations.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFrozenTruthBattery(t *testing.T) {
|
||||
raw, err := os.ReadFile("frozen_failure_auto_filer_truth.json")
|
||||
if err != nil {
|
||||
t.Fatalf("frozen truth missing: %v", err)
|
||||
}
|
||||
var cases []struct {
|
||||
Fn string `json:"fn"`
|
||||
Args []any `json:"args"`
|
||||
Kwargs map[string]any `json:"kwargs"`
|
||||
Expected any `json:"expected"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatalf("unparseable: %v", err)
|
||||
}
|
||||
for i, c := range cases {
|
||||
got := replayPure(t, c.Fn, c.Args, c.Kwargs)
|
||||
want, _ := json.Marshal(c.Expected)
|
||||
gotJSON, _ := json.Marshal(got)
|
||||
if string(gotJSON) != string(want) {
|
||||
t.Errorf("case %d %s%v: got %s want %s", i, c.Fn, c.Args, gotJSON, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// replayPure dispatches a frozen case to the Go implementation. Name map
|
||||
// is the one documented in filer.go.
|
||||
func replayPure(t *testing.T, fn string, args []any, kwargs map[string]any) any {
|
||||
t.Helper()
|
||||
arg := func(i int) string { return args[i].(string) }
|
||||
switch fn {
|
||||
case "error_class_of":
|
||||
return errorClassOf(arg(0))
|
||||
case "signature_of":
|
||||
sub, cls := signatureOf(arg(0), arg(1))
|
||||
return []string{sub, cls}
|
||||
case "tier_gated":
|
||||
return tierGated(int(args[0].(float64)))
|
||||
case "marker_for":
|
||||
return markerFor(arg(0), arg(1))
|
||||
case "redact":
|
||||
return redact(arg(0))
|
||||
case "excerpt":
|
||||
limit := 200 // oracle _EXCERPT_LEN default
|
||||
if v, ok := kwargs["limit"]; ok {
|
||||
limit = int(v.(float64))
|
||||
}
|
||||
return excerpt(arg(0), limit)
|
||||
case "_error_text":
|
||||
return errorText(arg(0))
|
||||
default:
|
||||
t.Fatalf("unknown frozen fn: %s", fn)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// errorText on a Go error value (the oracle's BaseException branch —
|
||||
// exceptions are not JSON-expressible so they cannot ride the frozen
|
||||
// truth; covered here instead).
|
||||
type batteryErr struct{}
|
||||
|
||||
func (batteryErr) Error() string { return "boom" }
|
||||
|
||||
func TestErrorTextExceptionBranch(t *testing.T) {
|
||||
if got, want := errorText(batteryErr{}), "batteryErr: boom"; got != want {
|
||||
t.Errorf("errorText(error): got %q want %q", got, want)
|
||||
}
|
||||
if got, want := errorText("plain"), "plain"; got != want {
|
||||
t.Errorf("errorText(string): got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
package main
|
||||
|
||||
// filer.go — the FailureAutoFiler pipeline (oracle failure_auto_filer.py,
|
||||
// FailureAutoFiler class, 1:1).
|
||||
//
|
||||
// Failure path only Submit()s (enqueue, no network); a single worker
|
||||
// drains the queue (worker goroutine spawned ONLY when wait=false —
|
||||
// production mode; tests use wait=true + Drain, so no goroutine spawns
|
||||
// in test paths). Bursts coalesce: everything already queued for a
|
||||
// signature rides ONE client write. File() is the synchronous
|
||||
// single-occurrence pipeline step; the tier gate lives in tierGated and
|
||||
// nowhere else. The filer NEVER raises into the failure path.
|
||||
//
|
||||
// Injectable seams (oracle passes a ForgeClient + SpoolJournal and uses
|
||||
// the wall clock; here all three are interfaces / vars so tests pin them):
|
||||
// - ForgeClient interface (search_issues/add_comment/create_issue)
|
||||
// - SpoolJournal interface (append/unconsumed/mark_consumed)
|
||||
// - monotonic / nowUTC / timeNS vars (clock)
|
||||
//
|
||||
// Name map (oracle → here, existing camelCase style of main.go):
|
||||
// _error_text→errorText, FailureAutoFiler.__call__→Call,
|
||||
// submit→Submit, drain→Drain, file→File, reconcile→Reconcile,
|
||||
// _write→write, _write_once→writeOnce, _spool→spool,
|
||||
// _run_worker→runWorker, _process_batch→processBatch,
|
||||
// _flush_group→flushGroup, _drain_once→drainOnce,
|
||||
// _SigState→sigState, _inflight→inflight, _batch_size→batchSize.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
batchIntervalSeconds = 600 // one comment per 10 occurrences OR 10 minutes
|
||||
excerptLen = 200 // oracle _EXCERPT_LEN
|
||||
queueMaxsize = 1000 // bounded: an outage must not balloon failure-path memory
|
||||
)
|
||||
|
||||
// ForgeClient is the forge-write seam (the oracle's ForgeClient contract
|
||||
// as used by writeOnce). Mirrors ForgeClient.search_issues /
|
||||
// add_comment / create_issue.
|
||||
type ForgeClient interface {
|
||||
SearchIssues(marker string) ([]map[string]any, error)
|
||||
AddComment(number int, body string) error
|
||||
CreateIssue(title, body string) (map[string]any, error)
|
||||
}
|
||||
|
||||
// SpoolJournal is the durable-journal seam (oracle SpoolJournal protocol,
|
||||
// satisfied by JsonlSpool).
|
||||
type SpoolJournal interface {
|
||||
Append(record map[string]any) error
|
||||
Unconsumed() ([]map[string]any, error)
|
||||
MarkConsumed(spoolID string)
|
||||
}
|
||||
|
||||
// errorText is oracle _error_text: strings pass through; an error (the
|
||||
// Go analogue of a BaseException) renders "Type: message".
|
||||
func errorText(err any) string {
|
||||
switch t := err.(type) {
|
||||
case string:
|
||||
return t
|
||||
case error:
|
||||
return fmt.Sprintf("%s: %v", errClassName(t), t)
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func errClassName(err error) string {
|
||||
name := fmt.Sprintf("%T", err)
|
||||
if i := strings.LastIndex(name, "."); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
return strings.TrimPrefix(name, "*")
|
||||
}
|
||||
|
||||
// sigKey is the dedup signature (subsystem, error_class) — the oracle's
|
||||
// (str, str) tuple key.
|
||||
type sigKey struct {
|
||||
subsystem string
|
||||
class string
|
||||
}
|
||||
|
||||
func signatureKey(subsystem, errMsg string) sigKey {
|
||||
_, class := signatureOf(subsystem, errMsg)
|
||||
return sigKey{subsystem: subsystem, class: class}
|
||||
}
|
||||
|
||||
// sigState is oracle _SigState: in-memory, process-lifetime coalescing
|
||||
// state per signature.
|
||||
type sigState struct {
|
||||
count int // total occurrences seen this process
|
||||
pending int // occurrences since the last client write
|
||||
windowStart float64 // monotonic time of the last client write
|
||||
}
|
||||
|
||||
// filerItem is one queued occurrence (oracle queue tuple
|
||||
// (subsystem, error_text, tier, now)).
|
||||
type filerItem struct {
|
||||
subsystem string
|
||||
text string
|
||||
tier int
|
||||
now float64
|
||||
}
|
||||
|
||||
// filerQueue mirrors queue.Queue(maxsize): put_nowait drops on full,
|
||||
// get blocks, unfinished_tasks/task_done gate Drain.
|
||||
type filerQueue struct {
|
||||
mu sync.Mutex
|
||||
notEmpty sync.Cond
|
||||
items []filerItem
|
||||
maxsize int
|
||||
unfinished int
|
||||
sentinel bool
|
||||
}
|
||||
|
||||
func newFilerQueue(maxsize int) *filerQueue {
|
||||
q := &filerQueue{maxsize: maxsize}
|
||||
q.notEmpty.L = &q.mu
|
||||
return q
|
||||
}
|
||||
|
||||
func (q *filerQueue) putNowait(it filerItem) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.items) >= q.maxsize {
|
||||
return false // oracle queue.Full
|
||||
}
|
||||
q.items = append(q.items, it)
|
||||
q.unfinished++
|
||||
q.notEmpty.Signal()
|
||||
return true
|
||||
}
|
||||
|
||||
// get blocks until an item or the shutdown sentinel; ok=false → sentinel.
|
||||
func (q *filerQueue) get() (filerItem, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
for len(q.items) == 0 && !q.sentinel {
|
||||
q.notEmpty.Wait()
|
||||
}
|
||||
if len(q.items) == 0 {
|
||||
return filerItem{}, false
|
||||
}
|
||||
it := q.items[0]
|
||||
q.items = q.items[1:]
|
||||
return it, true
|
||||
}
|
||||
|
||||
func (q *filerQueue) getNowait() (filerItem, bool) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if len(q.items) == 0 {
|
||||
return filerItem{}, false
|
||||
}
|
||||
it := q.items[0]
|
||||
q.items = q.items[1:]
|
||||
return it, true
|
||||
}
|
||||
|
||||
func (q *filerQueue) putSentinel() {
|
||||
q.mu.Lock()
|
||||
q.sentinel = true
|
||||
q.mu.Unlock()
|
||||
q.notEmpty.Broadcast()
|
||||
}
|
||||
|
||||
func (q *filerQueue) taskDone() {
|
||||
q.mu.Lock()
|
||||
if q.unfinished > 0 {
|
||||
q.unfinished--
|
||||
}
|
||||
q.mu.Unlock()
|
||||
}
|
||||
|
||||
func (q *filerQueue) unfinishedTasks() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.unfinished
|
||||
}
|
||||
|
||||
// Injectable clock seams — production wires the wall clock; tests pin
|
||||
// them. NaN `now` sentinels mean "ask the clock" (the oracle's _now=None).
|
||||
var (
|
||||
monotonic = func() float64 { return float64(time.Now().UnixNano()) / 1e9 }
|
||||
nowUTC = func() string { return time.Now().UTC().Format("2006-01-02T15:04:05Z") }
|
||||
timeNS = func() int64 { return time.Now().UnixNano() }
|
||||
)
|
||||
|
||||
func clockNow(now float64) float64 {
|
||||
if math.IsNaN(now) {
|
||||
return monotonic()
|
||||
}
|
||||
return now
|
||||
}
|
||||
|
||||
// FailureAutoFiler — one issue per failure signature; tier gate + spool
|
||||
// + worker drain. The tier gate (tier in (0, 1) → file/comment,
|
||||
// tier >= 2 → journal only) is enforced in tierGated and nowhere else.
|
||||
type FailureAutoFiler struct {
|
||||
client ForgeClient
|
||||
journal SpoolJournal
|
||||
queue *filerQueue
|
||||
mu sync.Mutex
|
||||
state map[sigKey]*sigState
|
||||
batchSize int
|
||||
inflight map[sigKey]bool // single-flight per signature
|
||||
waitMode bool // oracle: worker thread is None
|
||||
}
|
||||
|
||||
func NewFailureAutoFiler(client ForgeClient, journal SpoolJournal, batchSize int, wait bool) *FailureAutoFiler {
|
||||
if batchSize < 1 {
|
||||
batchSize = 1
|
||||
}
|
||||
f := &FailureAutoFiler{
|
||||
client: client,
|
||||
journal: journal,
|
||||
queue: newFilerQueue(queueMaxsize),
|
||||
state: map[sigKey]*sigState{},
|
||||
batchSize: batchSize,
|
||||
inflight: map[sigKey]bool{},
|
||||
waitMode: wait,
|
||||
}
|
||||
if !wait {
|
||||
go f.runWorker()
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ failure-path API
|
||||
|
||||
// Call is oracle __call__ (the callable-filer contract): exactly Submit.
|
||||
func (f *FailureAutoFiler) Call(subsystem, errMsg string, tier int, now float64) {
|
||||
f.Submit(subsystem, errMsg, tier, now)
|
||||
}
|
||||
|
||||
// Submit is the failure-path entry: enqueue and return immediately.
|
||||
// Never blocks on the network, never raises (swallowed + logged). With
|
||||
// wait=true nothing is written until Drain — the whole backlog
|
||||
// coalesces into one write per signature.
|
||||
func (f *FailureAutoFiler) Submit(subsystem, errMsg string, tier int, now float64) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil { // never raise into the failure path
|
||||
log.Printf("[failure_auto_filer] submit failed for subsystem=%s: %v", subsystem, r)
|
||||
}
|
||||
}()
|
||||
if tierGated(tier) {
|
||||
return // THE tier gate — T2 journal-only, zero queue traffic
|
||||
}
|
||||
item := filerItem{subsystem: subsystem, text: errMsg, tier: tier, now: clockNow(now)}
|
||||
if !f.queue.putNowait(item) {
|
||||
// Bounded queue (#122 gate): an overflow occurrence is dropped
|
||||
// from the queue but SPOOLED — nothing is lost — and the drop
|
||||
// is logged. Never blocks the failure path.
|
||||
log.Printf("[failure_auto_filer] auto-filer queue full; spooling dropped occurrence for subsystem=%s", subsystem)
|
||||
f.spool(subsystem, errMsg, tier, errors.New("auto-filer queue full"), 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Drain blocks until the queue is empty (tests + operator shutdown).
|
||||
// timeout < 0 waits forever (the oracle's timeout=None).
|
||||
func (f *FailureAutoFiler) Drain(timeout float64) {
|
||||
if f.waitMode {
|
||||
f.drainOnce()
|
||||
return
|
||||
}
|
||||
hasDeadline := timeout >= 0
|
||||
deadline := monotonic() + timeout
|
||||
for f.queue.unfinishedTasks() > 0 {
|
||||
if hasDeadline && monotonic() > deadline {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------- synchronous pipeline
|
||||
|
||||
// File is the synchronous single-occurrence pipeline step. Tier gate →
|
||||
// coalescing state → (first occurrence, batch full, or window expired)
|
||||
// → search → create-or-comment. ANY client exception is spooled and
|
||||
// swallowed; this method never raises. Returns true when a forge write
|
||||
// was issued.
|
||||
func (f *FailureAutoFiler) File(subsystem, errMsg string, tier int, now float64) (ret bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil { // degrade, never propagate
|
||||
f.spool(subsystem, errMsg, tier, fmt.Errorf("%v", r), 1)
|
||||
ret = false
|
||||
}
|
||||
}()
|
||||
if tierGated(tier) {
|
||||
return false // THE tier gate — one place, T2 journal-only
|
||||
}
|
||||
now = clockNow(now)
|
||||
sig := signatureKey(subsystem, errMsg)
|
||||
text := errMsg
|
||||
f.mu.Lock()
|
||||
st := f.state[sig]
|
||||
if st == nil {
|
||||
st = &sigState{}
|
||||
f.state[sig] = st
|
||||
}
|
||||
st.count++
|
||||
st.pending++
|
||||
due := st.count == 1 ||
|
||||
st.pending >= f.batchSize ||
|
||||
(st.pending > 0 && now-st.windowStart >= batchIntervalSeconds)
|
||||
countAtFlush := st.count
|
||||
if due {
|
||||
st.pending = 0
|
||||
st.windowStart = now
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !due {
|
||||
return false // coalesced: no client call for this occurrence
|
||||
}
|
||||
ok, err := f.write(sig, text, tier, countAtFlush, 0)
|
||||
if err != nil {
|
||||
f.spool(subsystem, errMsg, tier, err, 1)
|
||||
return false
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- reconcile
|
||||
|
||||
// Reconcile replays unconsumed spool lines through the real pipeline.
|
||||
// Lines are GROUPED by signature first (#122 gate): one client write per
|
||||
// signature per reconcile pass, with the aggregated offline count. Dedup
|
||||
// re-applies via SearchIssues (a line whose issue already exists becomes
|
||||
// one comment, never a duplicate issue). Confirmed-filed lines are
|
||||
// marked consumed; failures leave the line spooled. Never raises.
|
||||
func (f *FailureAutoFiler) Reconcile() map[string]int {
|
||||
report := map[string]int{"replayed": 0, "filed": 0, "failed": 0, "skipped": 0}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
report["failed"]++
|
||||
log.Printf("[failure_auto_filer] reconcile failed: %v", r)
|
||||
}
|
||||
}()
|
||||
pending, err := f.journal.Unconsumed()
|
||||
if err != nil {
|
||||
log.Printf("[failure_auto_filer] reconcile could not read the spool journal: %v", err)
|
||||
report["failed"]++
|
||||
return report
|
||||
}
|
||||
groups := map[sigKey][]map[string]any{}
|
||||
order := []sigKey{}
|
||||
for _, record := range pending {
|
||||
report["replayed"]++
|
||||
sigList, _ := record["signature"].([]any)
|
||||
if len(sigList) != 2 {
|
||||
report["failed"]++ // malformed line: leave it, never crash
|
||||
continue
|
||||
}
|
||||
tier := int(asNumber(record["tier"], 1))
|
||||
if tierGated(tier) {
|
||||
// THE tier gate on replay — the same predicate as the live
|
||||
// path (one place). A T2 line resolves journal-only:
|
||||
// consumed, never written.
|
||||
f.journal.MarkConsumed(asStr(record["spool_id"]))
|
||||
report["skipped"]++
|
||||
continue
|
||||
}
|
||||
sig := sigKey{subsystem: asStr(sigList[0]), class: asStr(sigList[1])}
|
||||
if _, ok := groups[sig]; !ok {
|
||||
order = append(order, sig)
|
||||
}
|
||||
groups[sig] = append(groups[sig], record)
|
||||
}
|
||||
for _, sig := range order {
|
||||
group := groups[sig]
|
||||
last := group[len(group)-1]
|
||||
total := 0
|
||||
for _, r := range group {
|
||||
c := int(asNumber(r["count"], 1))
|
||||
if c < 1 {
|
||||
c = 1
|
||||
}
|
||||
total += c
|
||||
}
|
||||
filed, err := f.write(
|
||||
sig,
|
||||
asStr(last["error"]),
|
||||
int(asNumber(last["tier"], 1)),
|
||||
total,
|
||||
len(group),
|
||||
)
|
||||
if err == nil && filed {
|
||||
for _, record := range group {
|
||||
f.journal.MarkConsumed(asStr(record["spool_id"]))
|
||||
}
|
||||
report["filed"] += len(group)
|
||||
} else {
|
||||
report["failed"] += len(group) // e.g. NullAdapter: nothing was filed
|
||||
}
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ internals
|
||||
|
||||
// write is oracle _write: one client write for a signature. Single-flight
|
||||
// per signature (#122 gate): the reconcile path and the worker share an
|
||||
// in-flight set guarded by the class lock; a racing caller for a
|
||||
// signature already being written does NOT write (returns false, nil) —
|
||||
// its spool line stays spooled and dedup re-applies on the next replay.
|
||||
func (f *FailureAutoFiler) write(sig sigKey, text string, tier, count, offline int) (bool, error) {
|
||||
f.mu.Lock()
|
||||
if f.inflight[sig] {
|
||||
f.mu.Unlock()
|
||||
return false, nil // single-flight: another goroutine owns this write
|
||||
}
|
||||
f.inflight[sig] = true
|
||||
f.mu.Unlock()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
delete(f.inflight, sig)
|
||||
f.mu.Unlock()
|
||||
}()
|
||||
return f.writeOnce(sig, text, tier, count, offline)
|
||||
}
|
||||
|
||||
// writeOnce is oracle _write_once. Raises on client failure — callers
|
||||
// spool. (Cross-PROCESS dedup remains best-effort TOCTOU between search
|
||||
// and create; documented oracle limitation.)
|
||||
func (f *FailureAutoFiler) writeOnce(sig sigKey, text string, tier, count, offline int) (bool, error) {
|
||||
marker := markerFor(sig.subsystem, sig.class)
|
||||
existing, err := f.client.SearchIssues(marker)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var match map[string]any
|
||||
for _, issue := range existing {
|
||||
if strings.Contains(asStr(issue["title"]), marker) {
|
||||
match = issue
|
||||
break
|
||||
}
|
||||
}
|
||||
ts := nowUTC()
|
||||
if match != nil {
|
||||
var body string
|
||||
if offline > 0 {
|
||||
body = fmt.Sprintf("+%d occurrences while offline (last: %s @ %s)",
|
||||
count, excerpt(text, excerptLen), ts)
|
||||
} else {
|
||||
body = fmt.Sprintf("+%d occurrences (last: %s @ %s)",
|
||||
max(1, count-1), excerpt(text, excerptLen), ts)
|
||||
}
|
||||
number := int(asNumber(match["number"], 0))
|
||||
if err := f.client.AddComment(number, body); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
title := fmt.Sprintf("%s %s", marker, sig.subsystem)
|
||||
body := fmt.Sprintf(
|
||||
"Auto-filed failure signature `%s:%s`\n\n- tier=%d\n- occurrences=%d\n- last: %s\n\n(detected by the failure auto-filer, #122)",
|
||||
sig.subsystem, sig.class, tier, count, excerpt(text, excerptLen))
|
||||
result, err := f.client.CreateIssue(title, body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if result != nil && result["created"] == false {
|
||||
return false, nil // e.g. NullAdapter: nothing was filed
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// spool is oracle _spool: persist the failure for Reconcile. Never raises.
|
||||
func (f *FailureAutoFiler) spool(subsystem, errMsg string, tier int, exc error, count int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil { // even the spool must not raise
|
||||
log.Printf("[failure_auto_filer] failed to spool auto-file record for subsystem=%s: %v", subsystem, r)
|
||||
}
|
||||
}()
|
||||
sub, class := signatureOf(subsystem, errMsg)
|
||||
record := map[string]any{
|
||||
"kind": SPOOL_KIND,
|
||||
"spool_id": fmt.Sprintf("spool-%d", timeNS()),
|
||||
"timestamp": nowUTC(),
|
||||
"signature": []any{sub, class}, // list dialect (JSON round-trips as []any)
|
||||
"tier": tier,
|
||||
"count": count,
|
||||
"error": excerpt(errMsg, excerptLen),
|
||||
"client_error": excerpt(exc.Error(), excerptLen),
|
||||
}
|
||||
_ = f.journal.Append(record)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- worker
|
||||
|
||||
// runWorker is oracle _run_worker: drain the queue forever, coalescing
|
||||
// everything already queued into one batch. Never exits except on the
|
||||
// shutdown sentinel.
|
||||
func (f *FailureAutoFiler) runWorker() {
|
||||
for {
|
||||
item, ok := f.queue.get()
|
||||
if !ok { // shutdown sentinel
|
||||
f.queue.taskDone()
|
||||
return
|
||||
}
|
||||
batch := []filerItem{item}
|
||||
for { // coalesce: everything already queued rides one write
|
||||
it, ok := f.queue.getNowait()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
batch = append(batch, it)
|
||||
}
|
||||
f.runBatchSafely(batch)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FailureAutoFiler) runBatchSafely(batch []filerItem) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil { // worker must never die
|
||||
log.Printf("[failure_auto_filer] auto-filer worker batch failed: %v", r)
|
||||
}
|
||||
for range batch {
|
||||
f.queue.taskDone()
|
||||
}
|
||||
}()
|
||||
f.processBatch(batch)
|
||||
}
|
||||
|
||||
// processBatch is oracle _process_batch: group by signature, then
|
||||
// flushGroup each; a flush failure spools the whole group.
|
||||
func (f *FailureAutoFiler) processBatch(batch []filerItem) {
|
||||
groups := map[sigKey][]filerItem{}
|
||||
order := []sigKey{}
|
||||
for _, it := range batch {
|
||||
if tierGated(it.tier) {
|
||||
continue // THE tier gate (defense in depth; Submit gates too)
|
||||
}
|
||||
sig := signatureKey(it.subsystem, it.text)
|
||||
if _, ok := groups[sig]; !ok {
|
||||
order = append(order, sig)
|
||||
}
|
||||
groups[sig] = append(groups[sig], it)
|
||||
}
|
||||
for _, sig := range order {
|
||||
group := groups[sig]
|
||||
err := f.flushGroup(sig, group)
|
||||
if err != nil {
|
||||
for _, it := range group {
|
||||
f.spool(it.subsystem, it.text, it.tier, err, len(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushGroup is oracle _flush_group: coalescing state for a whole batch
|
||||
// slice, then one write when due. Errors propagate (the oracle raises;
|
||||
// processBatch spools).
|
||||
func (f *FailureAutoFiler) flushGroup(sig sigKey, group []filerItem) error {
|
||||
last := group[len(group)-1]
|
||||
now := last.now
|
||||
text := last.text // last error of the batch
|
||||
tier := last.tier
|
||||
f.mu.Lock()
|
||||
st := f.state[sig]
|
||||
if st == nil {
|
||||
st = &sigState{}
|
||||
f.state[sig] = st
|
||||
}
|
||||
st.count += len(group)
|
||||
st.pending += len(group)
|
||||
due := st.count == len(group) ||
|
||||
st.pending >= f.batchSize ||
|
||||
(st.pending > 0 && now-st.windowStart >= batchIntervalSeconds)
|
||||
countAtFlush := st.count
|
||||
if due {
|
||||
st.pending = 0
|
||||
st.windowStart = now
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if due {
|
||||
if _, err := f.write(sig, text, tier, countAtFlush, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drainOnce is oracle _drain_once: inline drain for synchronous
|
||||
// (wait=true) mode.
|
||||
func (f *FailureAutoFiler) drainOnce() {
|
||||
batch := []filerItem{}
|
||||
for {
|
||||
it, ok := f.queue.getNowait()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
batch = append(batch, it)
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil { // must never escape
|
||||
log.Printf("[failure_auto_filer] auto-filer inline drain failed: %v", r)
|
||||
}
|
||||
for range batch {
|
||||
f.queue.taskDone()
|
||||
}
|
||||
}()
|
||||
f.processBatch(batch)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
[
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"RuntimeError: boom"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "RuntimeError"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"plain token error"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "plain"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
""
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "Unknown"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
" "
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "Unknown"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"ValueError:"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "ValueError"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"ValueError::"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "ValueError"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA: tail"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"::weird::"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "::weird"
|
||||
},
|
||||
{
|
||||
"fn": "error_class_of",
|
||||
"args": [
|
||||
"BrokerError: postgres://u:p@h"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "BrokerError"
|
||||
},
|
||||
{
|
||||
"fn": "signature_of",
|
||||
"args": [
|
||||
"api",
|
||||
"TimeoutError: down"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "signature_of",
|
||||
"args": [
|
||||
"api",
|
||||
""
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": [
|
||||
"api",
|
||||
"Unknown"
|
||||
]
|
||||
},
|
||||
{
|
||||
"fn": "tier_gated",
|
||||
"args": [
|
||||
0
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"fn": "tier_gated",
|
||||
"args": [
|
||||
1
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": false
|
||||
},
|
||||
{
|
||||
"fn": "tier_gated",
|
||||
"args": [
|
||||
2
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"fn": "tier_gated",
|
||||
"args": [
|
||||
3
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": true
|
||||
},
|
||||
{
|
||||
"fn": "marker_for",
|
||||
"args": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "[auto-failure:api:TimeoutError]"
|
||||
},
|
||||
{
|
||||
"fn": "marker_for",
|
||||
"args": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "[auto-failure::]"
|
||||
},
|
||||
{
|
||||
"fn": "excerpt",
|
||||
"args": [
|
||||
"postgres://admin:hunter2@db.example.com:5432/rune?password=sekret&token=abc123&host=x"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "postgres://<redacted>:<redacted>@db.example.com:5432/rune?password=<redacted>"
|
||||
},
|
||||
{
|
||||
"fn": "excerpt",
|
||||
"args": [
|
||||
"postgres://admin:hunter2@db.example.com:5432/rune?password=sekret&token=abc123&host=x"
|
||||
],
|
||||
"kwargs": {
|
||||
"limit": 40
|
||||
},
|
||||
"expected": "postgres://<redacted>:<redacted>@db.exam"
|
||||
},
|
||||
{
|
||||
"fn": "excerpt",
|
||||
"args": [
|
||||
"short"
|
||||
],
|
||||
"kwargs": {
|
||||
"limit": 200
|
||||
},
|
||||
"expected": "short"
|
||||
},
|
||||
{
|
||||
"fn": "excerpt",
|
||||
"args": [
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
},
|
||||
{
|
||||
"fn": "excerpt",
|
||||
"args": [
|
||||
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
],
|
||||
"kwargs": {
|
||||
"limit": 5
|
||||
},
|
||||
"expected": "xxxxx"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"RuntimeError: boom"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "RuntimeError: boom"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"GET https://h/x?token=tok123&ok=1 done"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "GET https://h/x?token=<redacted>&ok=1 done"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"GET https://h/x?API_KEY=ABC&sig=s1&access_token=at9 done"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "GET https://h/x?API_KEY=<redacted> done"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"postgres://admin:hunter2@db.example.com:5432/rune?password=sekret&token=abc123&host=x"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "postgres://<redacted>:<redacted>@db.example.com:5432/rune?password=<redacted>"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"redis://:s3cret@cache:6379"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "redis://<redacted>:<redacted>@cache:6379"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"https://user:pass@host/path"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "https://<redacted>:<redacted>@host/path"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"Authorization: Bearer abc.def.ghi expired"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "Authorization: Bearer <redacted> expired"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"bearer tok-here trailing"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "bearer <redacted> trailing"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"password=hunter2 and secret: 'quoted'"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "password=<redacted> and secret: <redacted>'"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"api_key='abcd1234' auth: short"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "api_key=<redacted>' auth: <redacted>"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"password=short"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "password=<redacted>"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"auth=x1 auth=abcd1234"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "auth=x1 auth=<redacted>"
|
||||
},
|
||||
{
|
||||
"fn": "redact",
|
||||
"args": [
|
||||
"connect postgres://u:p@h then ?token=t&x=2"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "connect postgres://<redacted>:<redacted>@h then ?token=<redacted>&x=2"
|
||||
},
|
||||
{
|
||||
"fn": "_error_text",
|
||||
"args": [
|
||||
"plain string"
|
||||
],
|
||||
"kwargs": {},
|
||||
"expected": "plain string"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,514 @@
|
||||
[
|
||||
{
|
||||
"name": "submit_t2_gate",
|
||||
"trace": {
|
||||
"calls": [],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "submit_coalesce_create",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:api:TimeoutError] api",
|
||||
"Auto-filed failure signature `api:TimeoutError`\n\n- tier=1\n- occurrences=3\n- last: TimeoutError: t\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_first_only",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:s:E]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:s:E] s",
|
||||
"Auto-filed failure signature `s:E`\n\n- tier=1\n- occurrences=1\n- last: E: a\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_batch_full",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:s:E]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:s:E] s",
|
||||
"Auto-filed failure signature `s:E`\n\n- tier=1\n- occurrences=1\n- last: E: a\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_window_expiry",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:s:E]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:s:E] s",
|
||||
"Auto-filed failure signature `s:E`\n\n- tier=1\n- occurrences=1\n- last: E: a\n\n(detected by the failure auto-filer, #122)"
|
||||
],
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:s:E]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:s:E] s",
|
||||
"Auto-filed failure signature `s:E`\n\n- tier=1\n- occurrences=2\n- last: E: b\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_comment_existing",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:s:E]"
|
||||
],
|
||||
[
|
||||
"add_comment",
|
||||
7,
|
||||
"+1 occurrences (last: E: latest text @ <TS>)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_t2",
|
||||
"trace": {
|
||||
"calls": [],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_client_failure_spools",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
]
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: down",
|
||||
"client_error": "forge down"
|
||||
}
|
||||
],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_single_flight",
|
||||
"trace": {
|
||||
"calls": [],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "file_spool_journal_failure_never_raises",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": [
|
||||
false
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "call_dunder_is_submit",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:ValueError]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:api:ValueError] api",
|
||||
"Auto-filed failure signature `api:ValueError`\n\n- tier=1\n- occurrences=1\n- last: ValueError: boom\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_groups_and_marks_consumed",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:api:TimeoutError] api",
|
||||
"Auto-filed failure signature `api:TimeoutError`\n\n- tier=1\n- occurrences=1\n- last: TimeoutError: t1\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: t1",
|
||||
"client_error": "forge down"
|
||||
}
|
||||
],
|
||||
"consumed": [
|
||||
"spool-det-0"
|
||||
],
|
||||
"report": {
|
||||
"replayed": 1,
|
||||
"filed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_comment_existing",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"add_comment",
|
||||
9,
|
||||
"+1 occurrences while offline (last: TimeoutError: t1 @ <TS>)"
|
||||
]
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: t1",
|
||||
"client_error": "forge down"
|
||||
}
|
||||
],
|
||||
"consumed": [
|
||||
"spool-det-0"
|
||||
],
|
||||
"report": {
|
||||
"replayed": 1,
|
||||
"filed": 1,
|
||||
"failed": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_t2_skipped",
|
||||
"trace": {
|
||||
"calls": [],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"cron",
|
||||
"Noise"
|
||||
],
|
||||
"tier": 2,
|
||||
"count": 1,
|
||||
"error": "noise",
|
||||
"client_error": "-"
|
||||
}
|
||||
],
|
||||
"consumed": [
|
||||
"spool-det-0"
|
||||
],
|
||||
"report": {
|
||||
"replayed": 1,
|
||||
"filed": 0,
|
||||
"failed": 0,
|
||||
"skipped": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_malformed_line",
|
||||
"trace": {
|
||||
"calls": [],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"only-one"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "x",
|
||||
"client_error": "-"
|
||||
}
|
||||
],
|
||||
"consumed": [],
|
||||
"report": {
|
||||
"replayed": 1,
|
||||
"filed": 0,
|
||||
"failed": 1,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_failing_client_keeps_spooled",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
]
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: t1",
|
||||
"client_error": "forge down"
|
||||
}
|
||||
],
|
||||
"consumed": [],
|
||||
"report": {
|
||||
"replayed": 1,
|
||||
"filed": 0,
|
||||
"failed": 1,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_journal_read_failure",
|
||||
"trace": {
|
||||
"calls": [],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"report": {
|
||||
"replayed": 0,
|
||||
"filed": 0,
|
||||
"failed": 1,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "reconcile_null_adapter_result",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:api:TimeoutError] api",
|
||||
"Auto-filed failure signature `api:TimeoutError`\n\n- tier=1\n- occurrences=1\n- last: TimeoutError: t1\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: t1",
|
||||
"client_error": "forge down"
|
||||
}
|
||||
],
|
||||
"consumed": [],
|
||||
"report": {
|
||||
"replayed": 1,
|
||||
"filed": 0,
|
||||
"failed": 1,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "queue_full_spools_overflow",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:api:TimeoutError] api",
|
||||
"Auto-filed failure signature `api:TimeoutError`\n\n- tier=1\n- occurrences=2\n- last: TimeoutError: t\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-0",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: t",
|
||||
"client_error": "auto-filer queue full"
|
||||
},
|
||||
{
|
||||
"kind": "failure_auto_file_spool",
|
||||
"spool_id": "spool-det-1",
|
||||
"timestamp": "<TS>",
|
||||
"signature": [
|
||||
"api",
|
||||
"TimeoutError"
|
||||
],
|
||||
"tier": 1,
|
||||
"count": 1,
|
||||
"error": "TimeoutError: t",
|
||||
"client_error": "auto-filer queue full"
|
||||
}
|
||||
],
|
||||
"consumed": [],
|
||||
"returns": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drain_twice_idempotent",
|
||||
"trace": {
|
||||
"calls": [
|
||||
[
|
||||
"search_issues",
|
||||
"[auto-failure:api:TimeoutError]"
|
||||
],
|
||||
[
|
||||
"create_issue",
|
||||
"[auto-failure:api:TimeoutError] api",
|
||||
"Auto-filed failure signature `api:TimeoutError`\n\n- tier=1\n- occurrences=1\n- last: TimeoutError: t\n\n(detected by the failure auto-filer, #122)"
|
||||
]
|
||||
],
|
||||
"records": [],
|
||||
"consumed": [],
|
||||
"returns": []
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
)
|
||||
|
||||
const ERROR_CLASS_MAX = 64
|
||||
const EXCERPT_LEN = 256
|
||||
const EXCERPT_LEN = 200
|
||||
|
||||
type SpoolEntry struct {
|
||||
ID string `json:"spool_id"`
|
||||
@@ -81,7 +81,8 @@ func errorClassOf(errMsg string) string {
|
||||
if len(tokens) == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
class := strings.TrimSuffix(tokens[0], ":")
|
||||
// oracle: tokens[0].rstrip(":") — strips ALL trailing colons
|
||||
class := strings.TrimRight(tokens[0], ":")
|
||||
if len(class) > ERROR_CLASS_MAX {
|
||||
class = class[:ERROR_CLASS_MAX]
|
||||
}
|
||||
@@ -113,8 +114,10 @@ func redact(text string) string {
|
||||
|
||||
func excerpt(errMsg string, limit int) string {
|
||||
r := redact(errMsg)
|
||||
if len(r) > limit {
|
||||
r = r[:limit]
|
||||
// oracle slices the str (codepoints), not bytes
|
||||
runes := []rune(r)
|
||||
if len(runes) > limit {
|
||||
return string(runes[:limit])
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package main
|
||||
|
||||
// scenario_test.go — frozen-oracle scenario battery for the pipeline
|
||||
// fns (frozen_filer_scenarios.json, generated by
|
||||
// scripts/gen_failure_auto_filer_truth.py). Each scenario runs the
|
||||
// oracle's FailureAutoFiler(wait=True) with a fake client + fake
|
||||
// journal + pinned `_now`; the Go twin replays the identical steps with
|
||||
// the identical fakes (below, mirroring the generator 1:1) and the
|
||||
// captured trace must match VERBATIM. No goroutine spawns: wait=true.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var tsPattern = regexp.MustCompile(`\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ`)
|
||||
|
||||
func normTS(s string) string { return tsPattern.ReplaceAllString(s, "<TS>") }
|
||||
|
||||
// ---- fakes (mirror gen_failure_auto_filer_truth.FakeClient/FakeJournal) ----
|
||||
|
||||
type fakeClient struct {
|
||||
calls []any
|
||||
issues []map[string]any
|
||||
fail bool
|
||||
nullCreate bool
|
||||
}
|
||||
|
||||
func (c *fakeClient) SearchIssues(marker string) ([]map[string]any, error) {
|
||||
c.calls = append(c.calls, []any{"search_issues", marker})
|
||||
if c.fail {
|
||||
return nil, errors.New("forge down")
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, i := range c.issues {
|
||||
cp := map[string]any{}
|
||||
for k, v := range i {
|
||||
cp[k] = v
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) AddComment(number int, body string) error {
|
||||
c.calls = append(c.calls, []any{"add_comment", number, normTS(body)})
|
||||
if c.fail {
|
||||
return errors.New("forge down")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeClient) CreateIssue(title, body string) (map[string]any, error) {
|
||||
c.calls = append(c.calls, []any{"create_issue", title, normTS(body)})
|
||||
if c.fail {
|
||||
return nil, errors.New("forge down")
|
||||
}
|
||||
if c.nullCreate {
|
||||
return map[string]any{"created": false}, nil
|
||||
}
|
||||
return map[string]any{"number": len(c.issues) + 1, "created": true}, nil
|
||||
}
|
||||
|
||||
type fakeJournal struct {
|
||||
records []map[string]any
|
||||
consumed []any
|
||||
failUnconsumed bool
|
||||
failAppend bool
|
||||
}
|
||||
|
||||
func (j *fakeJournal) Append(record map[string]any) error {
|
||||
if j.failAppend {
|
||||
return errors.New("journal append failed")
|
||||
}
|
||||
cp := map[string]any{}
|
||||
for k, v := range record {
|
||||
cp[k] = v
|
||||
}
|
||||
cp["spool_id"] = fmt.Sprintf("spool-det-%d", len(j.records))
|
||||
cp["timestamp"] = "<TS>"
|
||||
j.records = append(j.records, cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j *fakeJournal) Unconsumed() ([]map[string]any, error) {
|
||||
if j.failUnconsumed {
|
||||
return nil, errors.New("journal read failed")
|
||||
}
|
||||
consumed := map[string]bool{}
|
||||
for _, id := range j.consumed {
|
||||
consumed[asStr(id)] = true
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, r := range j.records {
|
||||
if !consumed[asStr(r["spool_id"])] {
|
||||
cp := map[string]any{}
|
||||
for k, v := range r {
|
||||
cp[k] = v
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (j *fakeJournal) MarkConsumed(spoolID string) {
|
||||
j.consumed = append(j.consumed, spoolID)
|
||||
}
|
||||
|
||||
// ---- trace plumbing ----
|
||||
|
||||
func nonNil[V any](s []V) []V {
|
||||
if s == nil {
|
||||
return []V{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func traceOf(client *fakeClient, journal *fakeJournal) map[string]any {
|
||||
return map[string]any{
|
||||
"calls": nonNil(client.calls),
|
||||
"records": nonNil(journal.records),
|
||||
"consumed": nonNil(journal.consumed),
|
||||
}
|
||||
}
|
||||
|
||||
func canonical(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func freshFiler(client *fakeClient, journal *fakeJournal, batchSize int) *FailureAutoFiler {
|
||||
return NewFailureAutoFiler(client, journal, batchSize, true)
|
||||
}
|
||||
|
||||
func runScenario(t *testing.T, name string, client *fakeClient, journal *fakeJournal) map[string]any {
|
||||
t.Helper()
|
||||
switch name {
|
||||
case "submit_t2_gate":
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.Submit("sub", "ValueError: boom", 2, 100)
|
||||
f.Drain(-1)
|
||||
return mergeReturns(traceOf(client, journal), []any{})
|
||||
case "submit_coalesce_create":
|
||||
f := freshFiler(client, journal, 10)
|
||||
for i := 0; i < 3; i++ {
|
||||
f.Submit("api", "TimeoutError: t", 1, float64(100+i))
|
||||
}
|
||||
f.Drain(-1)
|
||||
return mergeReturns(traceOf(client, journal), []any{})
|
||||
case "file_first_only":
|
||||
f := freshFiler(client, journal, 10)
|
||||
r1 := f.File("s", "E: a", 1, 100)
|
||||
r2 := f.File("s", "E: b", 1, 101)
|
||||
r3 := f.File("s", "E: c", 1, 102)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1, r2, r3})
|
||||
case "file_batch_full":
|
||||
f := freshFiler(client, journal, 3)
|
||||
r1 := f.File("s", "E: a", 1, 100)
|
||||
r2 := f.File("s", "E: b", 1, 101)
|
||||
r3 := f.File("s", "E: c", 1, 102)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1, r2, r3})
|
||||
case "file_window_expiry":
|
||||
f := freshFiler(client, journal, 10)
|
||||
r1 := f.File("s", "E: a", 1, 100)
|
||||
r2 := f.File("s", "E: b", 1, 100+600)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1, r2})
|
||||
case "file_comment_existing":
|
||||
client.issues = []map[string]any{{"number": 7, "title": "[auto-failure:s:E] s"}}
|
||||
f := freshFiler(client, journal, 10)
|
||||
r1 := f.File("s", "E: latest text", 1, 100)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1})
|
||||
case "file_t2":
|
||||
f := freshFiler(client, journal, 10)
|
||||
r1 := f.File("s", "E: cron noise", 2, 100)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1})
|
||||
case "file_client_failure_spools":
|
||||
client.fail = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
r1 := f.File("api", "TimeoutError: down", 1, 100)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1})
|
||||
case "file_single_flight":
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.mu.Lock()
|
||||
f.inflight[sigKey{subsystem: "s", class: "E"}] = true
|
||||
f.mu.Unlock()
|
||||
r1 := f.File("s", "E: x", 1, 100)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1})
|
||||
case "file_spool_journal_failure_never_raises":
|
||||
client.fail = true
|
||||
journal.failAppend = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
r1 := f.File("api", "TimeoutError: down", 1, 100)
|
||||
return mergeReturns(traceOf(client, journal), []any{r1})
|
||||
case "call_dunder_is_submit":
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.Call("api", "ValueError: boom", 1, 100)
|
||||
f.Drain(-1)
|
||||
return mergeReturns(traceOf(client, journal), []any{})
|
||||
case "reconcile_groups_and_marks_consumed":
|
||||
client.fail = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.File("api", "TimeoutError: t1", 1, 100)
|
||||
f.File("api", "TimeoutError: t2", 1, 101)
|
||||
client.fail = false
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(client, journal), report)
|
||||
case "reconcile_comment_existing":
|
||||
client.fail = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.File("api", "TimeoutError: t1", 1, 100)
|
||||
client.fail = false
|
||||
client.issues = []map[string]any{{"number": 9, "title": "[auto-failure:api:TimeoutError] api"}}
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(client, journal), report)
|
||||
case "reconcile_t2_skipped":
|
||||
f := freshFiler(client, journal, 10)
|
||||
journal.Append(map[string]any{
|
||||
"kind": SPOOL_KIND, "spool_id": "spool-cron", "timestamp": "<TS>",
|
||||
"signature": []any{"cron", "Noise"}, "tier": 2, "count": 1,
|
||||
"error": "noise", "client_error": "-",
|
||||
})
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(client, journal), report)
|
||||
case "reconcile_malformed_line":
|
||||
f := freshFiler(client, journal, 10)
|
||||
journal.Append(map[string]any{
|
||||
"kind": SPOOL_KIND, "spool_id": "spool-bad", "timestamp": "<TS>",
|
||||
"signature": []any{"only-one"}, "tier": 1, "count": 1,
|
||||
"error": "x", "client_error": "-",
|
||||
})
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(client, journal), report)
|
||||
case "reconcile_failing_client_keeps_spooled":
|
||||
client.fail = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.File("api", "TimeoutError: t1", 1, 100)
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(client, journal), report)
|
||||
case "reconcile_journal_read_failure":
|
||||
journal.failUnconsumed = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(client, journal), report)
|
||||
case "reconcile_null_adapter_result":
|
||||
client.fail = true
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.File("api", "TimeoutError: t1", 1, 100)
|
||||
nullClient := &fakeClient{nullCreate: true}
|
||||
f.client = nullClient
|
||||
report := f.Reconcile()
|
||||
return mergeReport(traceOf(nullClient, journal), report)
|
||||
case "queue_full_spools_overflow":
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.queue = newFilerQueue(2)
|
||||
for i := 0; i < 4; i++ {
|
||||
f.Submit("api", "TimeoutError: t", 1, 100)
|
||||
}
|
||||
f.Drain(-1)
|
||||
return mergeReturns(traceOf(client, journal), []any{})
|
||||
case "drain_twice_idempotent":
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.Submit("api", "TimeoutError: t", 1, 100)
|
||||
f.Drain(-1)
|
||||
f.Drain(-1)
|
||||
return mergeReturns(traceOf(client, journal), []any{})
|
||||
default:
|
||||
t.Fatalf("unknown scenario: %s", name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func mergeReturns(trace map[string]any, returns []any) map[string]any {
|
||||
trace["returns"] = returns
|
||||
return trace
|
||||
}
|
||||
|
||||
func mergeReport(trace map[string]any, report map[string]int) map[string]any {
|
||||
trace["report"] = report
|
||||
return trace
|
||||
}
|
||||
|
||||
func TestFrozenScenarioBattery(t *testing.T) {
|
||||
raw, err := os.ReadFile("frozen_filer_scenarios.json")
|
||||
if err != nil {
|
||||
t.Fatalf("frozen scenarios missing: %v", err)
|
||||
}
|
||||
var scenarios []struct {
|
||||
Name string `json:"name"`
|
||||
Trace map[string]any `json:"trace"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &scenarios); err != nil {
|
||||
t.Fatalf("unparseable: %v", err)
|
||||
}
|
||||
for _, sc := range scenarios {
|
||||
sc := sc
|
||||
t.Run(sc.Name, func(t *testing.T) {
|
||||
client, journal := &fakeClient{}, &fakeJournal{}
|
||||
got := runScenario(t, sc.Name, client, journal)
|
||||
if canonical(got) != canonical(sc.Trace) {
|
||||
t.Errorf("trace mismatch\n got: %s\nwant: %s", canonical(got), canonical(sc.Trace))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// JsonlSpool round-trip: append → unconsumed → mark_consumed → empty
|
||||
// (oracle sidecar dialect; consumption never rewrites the journal).
|
||||
func TestJsonlSpoolSidecarRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/journal.jsonl"
|
||||
sp := NewJsonlSpool(path)
|
||||
if rows, _ := sp.Unconsumed(); len(rows) != 0 {
|
||||
t.Fatalf("empty journal: unconsumed = %v", rows)
|
||||
}
|
||||
if err := sp.Append(map[string]any{"event_id": "e1", "kind": "canonical"}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
if err := sp.Append(map[string]any{"kind": SPOOL_KIND, "spool_id": "spool-1", "tier": 1}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
if err := sp.Append(map[string]any{"kind": SPOOL_KIND, "spool_id": "spool-2", "tier": 1}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
// a malformed line rides along: never crash (oracle JSONDecodeError skip)
|
||||
malformed, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
malformed.WriteString("definitely not json\n")
|
||||
malformed.Close()
|
||||
rows, err := sp.Unconsumed()
|
||||
if err != nil {
|
||||
t.Fatalf("unconsumed: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("want 2 spool rows, got %d: %v", len(rows), rows)
|
||||
}
|
||||
if rows[0]["spool_id"] != "spool-1" {
|
||||
t.Fatalf("row order preserved: %v", rows[0])
|
||||
}
|
||||
// a foreign consumed marker rides the sidecar, never the journal
|
||||
sp.MarkConsumed("spool-1")
|
||||
rows, _ = sp.Unconsumed()
|
||||
if len(rows) != 1 || rows[0]["spool_id"] != "spool-2" {
|
||||
t.Fatalf("after mark_consumed: %v", rows)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if got := countLines(string(data)); got != 4 {
|
||||
t.Fatalf("journal must keep exactly 4 lines (never rewritten), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func countLines(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r == '\n' {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// runWorker smoke test, synchronously: items + shutdown sentinel are
|
||||
// queued up front, so runWorker drains the batch and returns without
|
||||
// any goroutine spawn (oracle worker loop, pinned scheduling).
|
||||
func TestRunWorkerSynchronous(t *testing.T) {
|
||||
client, journal := &fakeClient{}, &fakeJournal{}
|
||||
f := freshFiler(client, journal, 10)
|
||||
f.Submit("api", "TimeoutError: t", 1, 100)
|
||||
f.Submit("api", "TimeoutError: t", 1, 101)
|
||||
f.queue.putSentinel()
|
||||
f.runWorker()
|
||||
if got := len(client.calls); got != 2 { // search + create, one coalesced batch
|
||||
t.Fatalf("worker made %d client calls, want 2: %v", got, client.calls)
|
||||
}
|
||||
if u := f.queue.unfinishedTasks(); u != 0 {
|
||||
t.Fatalf("unfinished tasks after worker exit: %d", u)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
// spool.go — the failure_auto_filer spool journal (oracle JsonlSpool, 1:1).
|
||||
//
|
||||
// Spooled records ride the EXISTING durable event journal as JSON lines
|
||||
// with `kind: failure_auto_file_spool` next to canonical envelopes.
|
||||
// Consumption NEVER rewrites the canonical journal (#122 gate): the
|
||||
// marker is an append-only sidecar `<journal>.consumed` (one spool id
|
||||
// per line, atomic appends only). Unconsumed() = spool lines minus
|
||||
// sidecar ids. Name map (oracle → here): append→Append,
|
||||
// unconsumed→Unconsumed, mark_consumed→MarkConsumed.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const SPOOL_KIND = "failure_auto_file_spool"
|
||||
|
||||
type JsonlSpool struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func NewJsonlSpool(path string) *JsonlSpool {
|
||||
return &JsonlSpool{Path: path}
|
||||
}
|
||||
|
||||
func (s *JsonlSpool) Append(record map[string]any) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// json.Marshal of a map sorts keys — matches json.dumps(sort_keys=True)
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fh, err := os.OpenFile(s.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer fh.Close()
|
||||
_, err = fh.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *JsonlSpool) Unconsumed() ([]map[string]any, error) {
|
||||
if _, err := os.Stat(s.Path); err != nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
sidecar := map[string]bool{}
|
||||
if data, err := os.ReadFile(s.Path + ".consumed"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if t := strings.TrimSpace(line); t != "" {
|
||||
sidecar[t] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
rows := []map[string]any{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
var row map[string]any
|
||||
if json.Unmarshal([]byte(line), &row) != nil {
|
||||
continue // malformed line: never crash (oracle JSONDecodeError → skip)
|
||||
}
|
||||
if row["kind"] == SPOOL_KIND && !truthy(row["consumed"]) && !sidecar[asStr(row["spool_id"])] {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *JsonlSpool) MarkConsumed(spoolID string) {
|
||||
// last resort on error: the line stays unconsumed; dedup via
|
||||
// search_issues still prevents duplicate issues on replay
|
||||
if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
fh, err := os.OpenFile(s.Path+".consumed", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
_, _ = fh.WriteString(spoolID + "\n")
|
||||
}
|
||||
|
||||
// truthy mirrors Python truthiness for the `consumed` flag probe
|
||||
// (absent/None/False/0/"" are all falsy).
|
||||
func truthy(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return t != ""
|
||||
case float64:
|
||||
return t != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func asStr(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
// asNumber mirrors the oracle's int(...) coercions across JSON-decoded values.
|
||||
func asNumber(v any, def float64) float64 {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case string:
|
||||
var f float64
|
||||
if _, err := fmt.Sscan(t, &f); err == nil {
|
||||
return f
|
||||
}
|
||||
return def
|
||||
case bool:
|
||||
if t {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user