feat(tower): instrument push and retry failed
This commit is contained in:
+46
-9
@@ -415,18 +415,25 @@ _busy_lock = threading.Lock()
|
|||||||
# ---- transfers journal lock ----
|
# ---- transfers journal lock ----
|
||||||
_history_lock = threading.Lock()
|
_history_lock = threading.Lock()
|
||||||
|
|
||||||
def push_batch(ip, delta):
|
def push_batch(ip, delta, subdir="podcasts"):
|
||||||
|
transfers_begin(ip, [(f, subdir) for f in delta])
|
||||||
ok = 0
|
ok = 0
|
||||||
for f in delta:
|
try:
|
||||||
try:
|
for f in delta:
|
||||||
push_file(ip, f); ok += 1
|
transfers_mark(f, "sending")
|
||||||
except Exception as e:
|
try:
|
||||||
print("push fail", f, e, flush=True)
|
push_file(ip, f, subdir=subdir); ok += 1
|
||||||
print(f"push done {ip} {ok}/{len(delta)}", flush=True)
|
transfers_mark(f, "done")
|
||||||
|
except Exception as e:
|
||||||
|
transfers_mark(f, "failed", str(e)[:200])
|
||||||
|
print("push fail", f, e, flush=True)
|
||||||
|
finally:
|
||||||
|
transfers_end()
|
||||||
|
print(f"push done {ip} {subdir} {ok}/{len(delta)}", flush=True)
|
||||||
with _busy_lock:
|
with _busy_lock:
|
||||||
_busy.discard(ip)
|
_busy.discard(ip)
|
||||||
|
|
||||||
def try_push(ip, delta):
|
def try_push(ip, delta, subdir="podcasts"):
|
||||||
"""Start a background push unless one already runs for ip. Returns started?"""
|
"""Start a background push unless one already runs for ip. Returns started?"""
|
||||||
if not delta:
|
if not delta:
|
||||||
return False
|
return False
|
||||||
@@ -434,9 +441,39 @@ def try_push(ip, delta):
|
|||||||
if ip in _busy:
|
if ip in _busy:
|
||||||
return False
|
return False
|
||||||
_busy.add(ip)
|
_busy.add(ip)
|
||||||
threading.Thread(target=push_batch, args=(ip, delta), daemon=True).start()
|
threading.Thread(target=push_batch, args=(ip, delta, subdir), daemon=True).start()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def failed_names(box_ip):
|
||||||
|
"""Failed (name, dir) for box_ip: active batch first, else recent history.
|
||||||
|
Skips names already re-sent successfully later in history."""
|
||||||
|
a = transfers_active()
|
||||||
|
pool = (a["files"] if a and a.get("box_ip") == box_ip else []) \
|
||||||
|
+ [h for h in transfers_history() if h.get("box_ip") == box_ip]
|
||||||
|
# later success overrides earlier failure: iterate chronological, keep last status
|
||||||
|
status_by = {}
|
||||||
|
for h in pool:
|
||||||
|
status_by[h["name"]] = (h["status"], h.get("dir", "podcasts"))
|
||||||
|
out = []
|
||||||
|
for name, (st, d) in status_by.items():
|
||||||
|
if st == "failed":
|
||||||
|
out.append((name, d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
def retry_failed(box_ip):
|
||||||
|
"""Re-push failed files for box_ip, grouped by subdir. Returns {started, count}."""
|
||||||
|
fails = failed_names(box_ip)
|
||||||
|
if not fails:
|
||||||
|
return {"started": False, "count": 0}
|
||||||
|
by_dir = {}
|
||||||
|
for name, d in fails:
|
||||||
|
by_dir.setdefault(d, []).append(name)
|
||||||
|
started = False
|
||||||
|
for d, names in by_dir.items():
|
||||||
|
if try_push(box_ip, names, subdir=d):
|
||||||
|
started = True
|
||||||
|
return {"started": started, "count": len(fails)}
|
||||||
|
|
||||||
# ---- transfers journal ----
|
# ---- transfers journal ----
|
||||||
TRANSFERS_JSON = os.environ.get("LISAEL_TRANSFERS", os.path.join(BASE, "transfers.json"))
|
TRANSFERS_JSON = os.environ.get("LISAEL_TRANSFERS", os.path.join(BASE, "transfers.json"))
|
||||||
TRANSFERS_MAX = 200
|
TRANSFERS_MAX = 200
|
||||||
|
|||||||
@@ -35,6 +35,107 @@ def test_history_truncates_200():
|
|||||||
# the most recent survive
|
# the most recent survive
|
||||||
assert any(h["name"] == "f249.mp3" for h in C.transfers_history())
|
assert any(h["name"] == "f249.mp3" for h in C.transfers_history())
|
||||||
|
|
||||||
|
def test_failed_names_from_history():
|
||||||
|
# seed a finished batch with one failed file for a box
|
||||||
|
C.transfers_begin("9.9.9.9", [("ok.mp3", "routines", 10), ("bad.mp3", "routines", 10)])
|
||||||
|
C.transfers_mark("ok.mp3", "sending"); C.transfers_mark("ok.mp3", "done")
|
||||||
|
C.transfers_mark("bad.mp3", "sending"); C.transfers_mark("bad.mp3", "failed", "net")
|
||||||
|
C.transfers_end()
|
||||||
|
fn = C.failed_names("9.9.9.9")
|
||||||
|
assert ("bad.mp3", "routines") in fn
|
||||||
|
assert ("ok.mp3", "routines") not in fn
|
||||||
|
# unknown box -> nothing
|
||||||
|
assert C.failed_names("0.0.0.0") == []
|
||||||
|
|
||||||
|
def test_failed_names_from_active():
|
||||||
|
# active batch (not yet ended) also visible
|
||||||
|
C.transfers_begin("10.0.0.1", [("x.mp3", "podcasts", 20), ("y.mp3", "podcasts", 20)])
|
||||||
|
C.transfers_mark("x.mp3", "sending"); C.transfers_mark("x.mp3", "failed", "timeout")
|
||||||
|
C.transfers_mark("y.mp3", "sending"); C.transfers_mark("y.mp3", "done")
|
||||||
|
fn = C.failed_names("10.0.0.1")
|
||||||
|
assert ("x.mp3", "podcasts") in fn
|
||||||
|
assert ("y.mp3", "podcasts") not in fn
|
||||||
|
# clean up active so next test starts fresh
|
||||||
|
C.transfers_end()
|
||||||
|
|
||||||
|
def test_push_batch_instrumented():
|
||||||
|
"""push_batch calls begin/mark/end in the right sequence without real network."""
|
||||||
|
journal = []
|
||||||
|
|
||||||
|
def fake_push(ip, name, subdir="podcasts"):
|
||||||
|
journal.append(("push", name, subdir))
|
||||||
|
if name == "fail.mp3":
|
||||||
|
raise OSError("net error")
|
||||||
|
|
||||||
|
orig_push = C.push_file
|
||||||
|
C.push_file = fake_push
|
||||||
|
try:
|
||||||
|
C.push_batch("1.1.1.1", ["ok.mp3", "fail.mp3"], subdir="podcasts")
|
||||||
|
finally:
|
||||||
|
C.push_file = orig_push
|
||||||
|
|
||||||
|
# journal: begin fired (check active was set, indirectly via history after end)
|
||||||
|
hist = C.transfers_history()
|
||||||
|
by_name = {h["name"]: h for h in hist}
|
||||||
|
assert by_name["ok.mp3"]["status"] == "done"
|
||||||
|
assert by_name["fail.mp3"]["status"] == "failed"
|
||||||
|
assert "net error" in (by_name["fail.mp3"]["error"] or "")
|
||||||
|
# push calls happened in order
|
||||||
|
assert journal == [("push", "ok.mp3", "podcasts"), ("push", "fail.mp3", "podcasts")]
|
||||||
|
# _busy must be released after push_batch
|
||||||
|
import threading
|
||||||
|
assert "1.1.1.1" not in C._busy
|
||||||
|
|
||||||
|
def test_push_batch_subdir_threaded():
|
||||||
|
"""push_batch passes subdir to push_file and releases _busy."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_push(ip, name, subdir="podcasts"):
|
||||||
|
calls.append(subdir)
|
||||||
|
|
||||||
|
orig = C.push_file
|
||||||
|
C.push_file = fake_push
|
||||||
|
try:
|
||||||
|
C.push_batch("2.2.2.2", ["r.mp3"], subdir="routines")
|
||||||
|
finally:
|
||||||
|
C.push_file = orig
|
||||||
|
|
||||||
|
assert calls == ["routines"]
|
||||||
|
assert "2.2.2.2" not in C._busy
|
||||||
|
|
||||||
|
def test_retry_failed():
|
||||||
|
"""retry_failed re-pushes only failed files, grouped by dir."""
|
||||||
|
pushed = []
|
||||||
|
|
||||||
|
def fake_push(ip, name, subdir="podcasts"):
|
||||||
|
pushed.append((name, subdir))
|
||||||
|
|
||||||
|
# seed history: one failed podcast, one failed routine, one done
|
||||||
|
C.transfers_begin("3.3.3.3", [
|
||||||
|
("a.mp3", "podcasts", 10),
|
||||||
|
("b.mp3", "routines", 10),
|
||||||
|
("c.mp3", "podcasts", 10),
|
||||||
|
])
|
||||||
|
C.transfers_mark("a.mp3", "sending"); C.transfers_mark("a.mp3", "failed", "err")
|
||||||
|
C.transfers_mark("b.mp3", "sending"); C.transfers_mark("b.mp3", "failed", "err")
|
||||||
|
C.transfers_mark("c.mp3", "sending"); C.transfers_mark("c.mp3", "done")
|
||||||
|
C.transfers_end()
|
||||||
|
|
||||||
|
orig = C.push_file
|
||||||
|
C.push_file = fake_push
|
||||||
|
try:
|
||||||
|
result = C.retry_failed("3.3.3.3")
|
||||||
|
finally:
|
||||||
|
C.push_file = orig
|
||||||
|
# drain any background thread
|
||||||
|
import time; time.sleep(0.1)
|
||||||
|
|
||||||
|
assert result["count"] == 2
|
||||||
|
assert result["started"] is True
|
||||||
|
# unknown box returns 0
|
||||||
|
r2 = C.retry_failed("0.0.0.0")
|
||||||
|
assert r2 == {"started": False, "count": 0}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for name, fn in sorted(globals().items()):
|
for name, fn in sorted(globals().items()):
|
||||||
if name.startswith("test_"):
|
if name.startswith("test_"):
|
||||||
|
|||||||
Reference in New Issue
Block a user