feat(storage): WebhookAuditSink close() lifecycle — stop signal, drain,...
Your AI's guard dog to stop it from going rogue.
Brought to you by:
doberman
Originally created by: Maqbool61
Adds a close() shutdown path to WebhookAuditSink, caches thread-start
failure as an inert sink, adds a pytest fixture that cleans up worker threads
after each test, and replaces two time.sleep races with deterministic
Event-based sync.
src/doberman/storage/sinks.py
_worker loop now checks not self._stop_event.is_set() (1 s queue timeoutclose(drain_timeout_s=5.0): marks sink inert immediately (concurrentemit() after close() → silent no-op), sets the stop event, joins theThread.start() wrapped in try/except: failure leaves _active=False sotests/unit/test_webhook_audit_sink.py
active_sink fixture: yields a live sink with a urlopen stub and callsclose() in teardown — no worker threads leak across tests.sink.close() explicitly ortest_drop_oldest_when_queue_full and test_drop_counter_increments_on_each_overflow:time.sleep(0.05) with a worker_blocked Event — deterministic| Test | Proves |
|---|---|
test_close_stops_worker_thread |
Worker thread is not alive after close() returns |
test_close_is_idempotent |
Second close() is a no-op, never raises |
test_emit_after_close_is_noop |
emit() after close() discards silently, no enqueue |
test_close_drains_queued_records |
All queued records are POSTed before worker exits |
test_close_on_inert_sink_is_noop |
close() on a no-config sink never raises |
test_thread_start_failure_leaves_sink_inert |
Start failure → _active=False, no retry |
Run locally across all 152 unit test files: 2279 passed, 5 skipped, 0 failures.
The 5 skips are pre-existing and unrelated.
emit() remains non-blocking. close() is never called from the decision
path. Sink failures remain swallowed and isolated.
Originally posted by: Maqbool61
Fixed. The active-state transition and enqueue are now atomic under
_state_lock.What changed (
sinks.py)Added
_state_lock = threading.Lock()to__init__.emit()acquires it for the entire check-and-enqueue as one atomic unit.close()acquires it before flipping_active = False, so it must wait for any in-progressemit()to finish putting before it stops the worker. Records enqueued whileclose()was waiting on the lock are delivered by the drain loop before the worker exits.Regression test added
test_emit_close_race_no_stranded_recorddeterministically forces the exact interleaving you flagged via aput_nowaitmonkeypatch that blocksemit()inside the lock untilclose()has been called. Assertscaptured == 1— the record must be delivered, never stranded.Smoke test results (20 forced trials of the exact interleaving)
Test | Result -- | -- Fixed code: 20 trials of the race interleaving — stranded records | 0 / 20 Old code (no lock): same 20 trials — stranded records | 20 / 20 close() drains 5 queued records before stopping | ✅ emit() after close() → silent no-op | ✅ Worker not alive after close() | ✅ close() idempotent | ✅ Thread-start failure → inert sink | ✅Full unit suite: 2279 passed, 5 skipped, 0 failures (152 files).
Originally posted by: Maqbool61
@fu351 I think it is ready to merge. Can you confirm it?
Ticket changed by: fu351
Originally posted by: fu351
The fix is exactly right: taking
_state_lockacross both the active-check and the enqueue inemit(), and the same lock around the_active = Falseflip inclose(), closes the window where a record could slip in after the worker exits.close()has to wait for any in-flightemit()to release the lock, and anyemit()that starts afterward sees_active=Falseunder that same lock. The audit path stays fail-safe too — a full queue or an error is swallowed, never raised into the decision path. Merging.One honest note on the regression test.
test_emit_close_race_no_stranded_recordpauses the enqueue only briefly, so the worker is usually still parked inqueue.get()when the deferredput_nowaitfinally fires — which means it likely passes on the pre-fix code too, not only on the fixed code. The production fix is correct regardless (I checked the lock directly); it's the test's proof that's weaker than it looks. If you feel like a quick follow-up, forcing the worker to fully exit before the deferred put — shrinking the poll interval, or patchingget— would make the test fail red on the old code and truly pin the race.For a next issue, [#245] (OpenTelemetry AuditSink) is the natural one: same enqueue-only, drain-on-close sink pattern you just got right, one seam over. Want me to assign it to you?
Thanks, Maqbool61.
Related
Tickets:
#245Originally posted by: Maqbool61
I will do the follow up and yes assign me to the [#245].
Related
Tickets:
#245