feat(queue): add swap job processor with idempotent AMM execution - #22
Merged
ameeribro4-sudo merged 3 commits intoAug 20, 2026
Merged
Conversation
Consume the registered-but-unprocessed swaps Bull queue with a
SwapJobProcessor that executes single, multi_leg, and batch jobs
against the AMM swap path. A durable Redis idempotency guard
(swap:executed:{swapId}, SET NX EX) prevents Bull retries from
executing a completed swap twice; multi-leg jobs resume at the first
unexecuted leg and batch jobs are best-effort per sub-swap. Permanent
failures land in the dead-letter queue, which now tracks the swaps
queue for recovery.
P3az3
force-pushed
the
fix/issue-15-swap-job-processor
branch
from
August 20, 2026 15:52
f07af0e to
e9f751f
Compare
ameeribro4-sudo
requested changes
Aug 20, 2026
ameeribro4-sudo
left a comment
Contributor
There was a problem hiding this comment.
@P3az3 resolve conflicts please
Contributor
Author
|
@ameeribro4-sudo resolved. |
6 tasks
DeRossa1
added a commit
to DeRossa1/Peerx-Backend
that referenced
this pull request
Aug 20, 2026
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.
Summary
Closes #15
Adds
SwapJobProcessor, the missing consumer for the registered-but-unprocessedswapsBull queue. It executessingle,multi_leg, andbatchswap jobs against the existing AMM swap path (LiquidityPoolService.swap) — the only real swap-execution service in the codebase (StellarServicehas no swap method), satisfying the issue's requirement to reuse an existing service rather than build a new engine. The single most important design decision is the durable Redis idempotency guard (swap:executed:{swapId},SET NX EXvia the existingRedisPoolService): a Bull retry of an executed swap short-circuits, so a funds-moving job is never executed twice.Why
QueueModuleregisters theSWAPSqueue andQueueServiceexposesaddSingleSwapJob/addMultiLegSwapJob/addBatchSwapJob, but no processor ever consumed the queue — jobs landed inwaitingand wedged silently, and withremoveOnComplete: false/removeOnFail: falsethey accumulated forever. A caller got ajobIdand believed a swap was in flight when nothing would ever execute it. Because a swap moves funds and Bull retries with backoff, the naive fix (callpoolService.swap(...)in a@Processhandler) would double-execute on retry, so an idempotency key was the correct primitive: claimed atomically before execution, released on failure (so a genuinely failed swap is re-attempted), and retained on success (so a retry or duplicate enqueue of the sameswapIdis a no-op).What was built
src/queue/processors/swap.processor.ts(new; tests insrc/queue/processors/swap.processor.spec.ts):swap.processor.ts@Processor(QueueName.SWAPS)with@Process('single'),@Process('multi_leg'),@Process('batch'). Executes viaLiquidityPoolService.swap(poolId, { userId, tokenIn, amountIn, minAmountOut }).single: guard keyswap:executed:{swapId}.multi_leg: per-leg keys ({swapId}:leg:{index}) so a retry resumes at the first unexecuted leg and never re-executes completed legs.batch: best-effort — every sub-swap is attempted independently with its own per-swapIdguard; failures are collected and the job fails after all are attempted, so a retry only re-attempts the failed sub-swaps. MissingpoolIdthrowsNonRetryableSwapError(no point retrying an unresolvable job).@OnQueueFailedmoves permanently-failed jobs to the DLQ withMAX_RETRIES_EXCEEDEDorNON_RETRYABLE_ERROR.swap.processor.spec.ts(new)NX/EX/TTL) with mocked AMM + DLQ: successful single swap with correctSwapDto; retry does not execute twice; failure releases the guard and re-executes; missingpoolIdis non-retryable; DLQ routing for both reasons; multi-leg in-order execution; multi-leg resumes at the failed leg without re-executing completed legs; batch executes all sub-swaps; batch fails the job when any sub-swap fails but still attempts all; batch retry skips already-executed sub-swaps.Integration changes outside
src/queue/processors/src/queue/queue.service.ts—SingleSwapJobDatagains optionalpoolId?: numberandminAmountOut?: number;MultiLegSwapJobData.legsentries gain the same. Optional fields are backward-compatible (no existing caller constructs these payloads — verified: the only references to the enqueue methods are insidequeue.service.tsitself), and the enqueue methods are unchanged.src/queue/queue.module.ts— registersSwapJobProcessorinprovidersand importsExchangeModule(exportsLiquidityPoolService). No circular dependency:ExchangeModuleimports onlyTypeOrmModule; madge confirms the same 3 pre-existing cycles, none involving queue or exchange.src/queue/dead-letter-queue.service.ts— injects theSWAPSqueue and adds it togetQueueByName, so a DLQ'd swap job can be recovered and retried (the issue explicitly allows DLQ wiring where needed to make swap failures recoverable). Also typed thegetQueueByNameswitch predicate asQueueName, fixing the file's pre-existingno-unsafe-enum-comparisonlint findings on that switch.README.md— documents the swap execution target (AMM), the idempotency guard, multi-leg resume semantics, and batch best-effort semantics under the Redis setup section.Acceptance criteria coverage
singleswap job enqueued viaaddSingleSwapJobis picked up by a processor and executed against the chosen swap service. (swap.processor.spec.ts— "executes a successful single swap against the AMM service";LiquidityPoolService.swapis the chosen service, the only real swap executor in the codebase)multi_legandbatchjobs are processed with defined partial-failure behavior. (swap.processor.spec.ts— multi-leg "resumes at the failed leg on retry without re-executing completed legs" and batch "attempts all swaps and fails the job when any sub-swap fails" + "skips already-executed sub-swaps on retry"; semantics documented in the processor docstring and README: multi-leg resumes at the first unexecuted leg with no rollback of completed legs, batch is best-effort per sub-swap)swap.processor.spec.ts— "does not execute the same swap twice on retry (idempotency guard)"; durable RedisSET NX EXguard, reusedRedisPoolService)swap.processor.spec.ts— all three, plus the failure-DLQ routing tests)queue.serviceusage. (queue.service.ts— enqueue methods byte-for-byte unchanged; job-data additions are optional fields)swap:executed:{swapId}guard, multi-leg resume, batch best-effort; processor docstring)Test plan
npm run build— succeedsnpx jest src/queue/processors/swap.processor.spec.ts— 11/11 passing (11 new tests)npm run test— 461/513 passing; failing suites identical to base branch (pre-existing ts-jest/uuid resolution failures; zero new failures)npx eslint— 0 issues on the two new files; on the three edited source files, no new issues versus base (the one new enum-comparison finding my addedcase QueueName.SWAPSwould have introduced was eliminated by typing the switch predicate, which also fixed the pre-existing findings on that switch)npx madge --circular --extensions ts src/— same 3 pre-existing cycles, none involving queue or exchangeEnv vars / Notes
No new env vars. The idempotency guard rides the existing Redis connection (
REDIS_HOST/REDIS_PORT) already used by Bull and the cache layer; Redis must be reachable for the guard to work (the queue already requires Redis). Guard TTL is 24h — shorter than the 7-dayremoveOnFailretention, so markers age out rather than accumulate. Delivery semantics per job type are documented in the processor docstring and README: single is exactly-once under retry, multi-leg resumes at the first unexecuted leg (completed legs are not rolled back — no compensation mechanism exists in the AMM path), batch is best-effort per sub-swap. Swap jobs now needpoolId(and optionallyminAmountOut) in their payload; jobs withoutpoolIdfail fast as non-retryable and land in the DLQ with a clear reason.