Skip to content

fix(outbox): add lease-based claim/reclaim to prevent stranded events - #432

Merged
YaronZaki merged 4 commits into
Quantarq:mainfrom
temiport25:fix/outbox-relay-crash-recovery
Aug 22, 2026
Merged

fix(outbox): add lease-based claim/reclaim to prevent stranded events#432
YaronZaki merged 4 commits into
Quantarq:mainfrom
temiport25:fix/outbox-relay-crash-recovery

Conversation

@temiport25

Copy link
Copy Markdown
Contributor

Description

Add a lease-based claim/reclaim mechanism to the outbox relay so events stranded in "processing" state after a crash are automatically reclaimed and re-queued within 5 minutes.

Related Issue

Closes #417

Change Type

  • fix — bug fix
  • test — adding or updating tests

Testing Done

  • Atomic claim via UPDATE WHERE status IN (pending,failed)
  • Stale processing events (>5 min) reclaimed on next scan
  • UUID validation in process_position_opened_task
  • claimed_at cleared on failure for proper retry
  • Tests for failed publish, stale reclaim, UUID validation

Screenshots (if UI changes)

None

Environment Variables

None

Checklist

  • make lint passes (pylint on changed .py files)
  • make test passes (pytest in quantara/web_app/tests/)
  • CI is green on this PR
  • Documentation updated (if applicable)
  • PR is linked to a related issue (Closes #417)

- Add claimed_at column to OutboxEvent for lease tracking
- Atomic claim via UPDATE ... WHERE status IN (pending,failed)
- Reclaim stale processing events older than 5 minutes
- Validate UUID format in process_position_opened_task
- Clear claimed_at on failure for proper retry
- Alembic migration for claimed_at column
- Tests for failed publish, stale reclaim, UUID validation

Closes Quantarq#417

class TestUUIDValidation:
def test_valid_uuid(self):
assert _is_valid_uuid(str(uuid.uuid4())) is True
assert _is_valid_uuid(str(uuid.uuid4())) is True

def test_invalid_uuid(self):
assert _is_valid_uuid("not-a-uuid") is False
assert _is_valid_uuid("not-a-uuid") is False

def test_empty_string(self):
assert _is_valid_uuid("") is False

def test_truncated_uuid(self):
valid = str(uuid.uuid4())
assert _is_valid_uuid(valid[:30]) is False
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
process_position_opened_task(task, event_id=str(event.id))

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "failed"

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "failed"
assert updated.claimed_at is None
updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "failed"
assert updated.claimed_at is None
assert updated.retry_count == 1
import sentry_sdk
from datetime import datetime, timedelta
from celery import Celery
from sqlalchemy import and_, or_
def test_already_claimed_event_is_not_double_dispatched(
self, mock_init_db, mock_delay, mock_session_local, db_session
):
now = datetime.now()
relay.process_pending_events()

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"
assert updated.claimed_at is not None
relay.process_pending_events()

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"
assert updated.claimed_at is not None
relay.process_pending_events()

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"
assert updated.claimed_at is not None
relay.process_pending_events()

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "processing"
relay.process_pending_events()

updated = db_session.query(OutboxEvent).filter(OutboxEvent.id == event.id).one()
assert updated.status == "pending"
- Convert event_id to uuid.UUID in process_position_opened_task to fix
  SQLite UUID string comparison ('str' object has no attribute 'hex')
- Use fresh sessions in tests that query after relay/task closes the
  original session (DetachedInstanceError)
- Track .update() calls in test_relay_worker_dispatches_task to properly
  simulate synchronize_session='fetch' behavior
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
Comment thread quantara/web_app/tests/test_outbox_relay.py Fixed
The relay's finally block closes the session, invalidating ORM objects
for post-relay assertions. Stubbing close() as a no-op keeps the
session usable while preserving cleanup in the fixture teardown.

@YaronZaki YaronZaki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@YaronZaki
YaronZaki merged commit 179a780 into Quantarq:main Aug 22, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Outbox relay strands events in processing state after a crash

4 participants