Add TW retry queue and wire it into webhooks and escrows#103
Open
AnoukRImola wants to merge 2 commits into
Open
Add TW retry queue and wire it into webhooks and escrows#103AnoukRImola wants to merge 2 commits into
AnoukRImola wants to merge 2 commits into
Conversation
|
@AnoukRImola is attempting to deploy a commit to the ManuelJG's projects Team on Vercel. A member of the Team first needs to authorize it. |
Collaborator
Review — almost LGTM (one required fix)Strong PR for #63. Scope, API surface, and wiring look right. What looks good
Intentionally deferred Required before mergeRename SQL migrations —
(or fold the webhook job_type into a single Non-blocking notes
After the migration rename + green CI, this is good to merge from my side. Nice work @AnoukRImola. |
josueazc
added a commit
to josueazc/ThalosBackend
that referenced
this pull request
Jul 23, 2026
…SSoT docs) - Rename migration 002_create_verifications.sql -> 004 to clear the number collision with kyb (002, main), kyc (Thalos-Infrastructure#80) and retry-queue (Thalos-Infrastructure#103) migrations. - Tighten authorization on the compliance endpoints (IDOR fix): reads now require the caller to be the subject (users), an admin, or an internal service. Adds JwtOrInternalSecretGuard (JWT or x-thalos-internal-secret) and VerificationService.assertCanRead; unit tests cover self/admin/internal/denied. - Document the single-source-of-truth model in the README: verifications is a read-only projection that KYC/KYB writers must upsert into, so Thalos-Infrastructure#71/Thalos-Infrastructure#72/Thalos-Infrastructure#75 don't invent another status shape.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Trustless Work Retry & Recovery Queue
Closes #63
Implements the foundational retry/recovery queue for Trustless Work (TW) operations and
connects it to the two places in the backend that talk to TW today (inbound webhooks and
outbound escrow writes), so network failures and transient TW errors no longer permanently
fail an Agreement.
Why
Network failures, temporary TW outages, or blockchain transaction delays must not permanently
fail Agreement execution. Before this PR,
src/webhooks/webhooks.service.tshad its ownin-memory retry loop (
withRetry), and the outbound TW calls insrc/internal-trustless/escrow-write.helper.tshad no retry at all — a single failure meant alost operation. This PR builds the one shared retry primitive the issue calls for, so every
module reuses it instead of accumulating competing retry implementations.
What's included
1. The retry queue itself (
src/retry-queue)RetryQueueService.enqueue(jobType, payload, idempotencyKey, options?)— persists a job tothe
retry_jobstable (Postgres/Supabase). A duplicateidempotencyKeyis a no-op thatreturns the existing job (including a race-safe path for concurrent inserts).
RetryQueueService.registerHandler(jobType, handler)— one handler perRetryJobType,typically registered in a consumer module's
onModuleInit. The handler must throw to signalfailure; the queue treats any rejection as retryable.
setInterval, default every 5s) picks up duependingjobs and processes them withexponential backoff (
base * 2^(attempt-1), capped) untilmax_attemptsis reached, thenmarks the job
failed.processingpast a staleness threshold are reclaimed back topendingon the next poll — nothing is lost on a process restart.POST /v1/retry-queue/:id/retryre-runs a job once, bypassing itsscheduled backoff.
GET /v1/retry-queue/GET /v1/retry-queue/:idfor inspection, bothgated on
profiles.role === 'admin'.RETRY_QUEUE_MAX_ATTEMPTS,RETRY_QUEUE_BASE_DELAY_MS,RETRY_QUEUE_MAX_DELAY_MS,RETRY_QUEUE_POLL_INTERVAL_MS,RETRY_QUEUE_CONCURRENCY,RETRY_QUEUE_STALE_PROCESSING_MS— see.env.example.RetryQueueModuleis@Global(), registered inAppModule— any module can injectRetryQueueServicewithout importing the module.scripts/002_create_retry_jobs.sql(table + indexes + RLS).2. Webhooks now reuse the queue instead of retrying inline
WebhooksService.withRetry()(in-memory, no persistence, competing retry logic) is removed.handleEvent()now enqueues aWEBHOOK_EVENT_PROCESSINGjob and ACKs200immediately,instead of holding the HTTP connection open for up to ~7s of inline retries.
applyStatusUpdate/applyMilestoneUpdate/applyInfoUpdate,including the existing idempotent-duplicate guard) now run as the registered handler, driven
by the queue's poller with real backoff and persistence.
contractId:event:hash(payload), since TW webhook payloadscarry no event id.
scripts/003_add_webhook_retry_job_type.sqladdswebhook_event_processingto theretry_jobs.job_typecheck constraint.3. Escrow writes get a retry backstop (without changing the frontend contract)
src/internal-trustless/escrows.controller.tswrite endpoints (create,fund,approve-milestone,change-milestone-status,release) still call Trustless Worksynchronously and return the result (e.g. the unsigned XDR) exactly as before — the frontend
signing flow is unaffected on the happy path.
On a transient failure (TW
5xxor a network-level error), the request still fails the same wayit did before, but the operation is now also enqueued on the shared queue
(
AGREEMENT_CREATION/MILESTONE_UPDATE/PAYMENT_EXECUTION, keyed by a deterministicidempotency key per operation) so it isn't silently dropped and can recover in the background or
be manually retried by an admin. Validation errors (
4xx) are not retried — retrying a badrequest can't help.
Scope boundaries (intentionally left out)
CONTRACT_RETRIEVAL(getEscrowsBySigner/getEscrowsByRole) — read-only GETs with noside effect to recover; the caller has already moved on by the time a background retry would
finish, so wiring these to the queue wouldn't do anything useful.
STATUS_SYNC— no producer exists yet. There is no periodic reconciliation job in thisbackend (no cron/scheduler); building that is the job of the dependent "Agreement
Synchronization" issue, not this one. The job type is defined and ready to use once that
producer exists.
disputeMilestone/sendTransaction— don't map to any of the 5 operation types theissue defines; left untouched.
AgreementsService— pure Supabase CRUD, makes no Trustless Work calls, so it's out ofscope for a Trustless Work retry primitive.
Testing
(
src/retry-queue/retry-queue.spec.ts).idempotency-key blocking, admin manual retry via HTTP (incl. 403 for non-admins), and a new
case proving
WebhooksServicereuses the shared queue end-to-end — enqueue → poller → DBupdate (
src/integration/retry-queue.integration.spec.ts).processingby a simulated crash is reclaimed and resumes.src/webhooks/webhooks.spec.ts— rewritten to verify enqueue-and-ACK behavior and that theregistered handler still runs the existing business logic.
src/internal-trustless/escrows.controller.spec.ts— new: backstop enqueues on5xx/networkerrors, does not enqueue on
4xx, happy path is unchanged, generic replay handler works.All 102 tests pass across 9 suites;
tsc --noEmitandeslintare clean.Files changed