What
Add row-level locking (FOR UPDATE SKIP LOCKED or equivalent) to the due-schedule query in the payroll scheduler so concurrent pods can't pick up and execute the same schedule at the same time.
Why
processDueSchedules() in backend/src/services/scheduleExecutor.ts (L44-150) runs on a node-cron('* * * * *') job initialized unconditionally in backend/src/index.ts (L30) — meaning every backend pod runs this job independently. The query selecting due schedules (SELECT ... WHERE next_run_timestamp <= NOW() AND status='active') has no row locking. k8s/base/backend-hpa.yaml sets minReplicas: 2, so at least two pods are always running this cron. The code's own comment (L95-97) acknowledges there's no real idempotency check on this path — and idempotencyMiddleware is applied to HTTP schedule-creation routes but never reaches this background executor. In production this can mean two pods executing the same due payroll schedule simultaneously — real duplicate Stellar payments to employees.
Scope
In scope:
- Add
FOR UPDATE SKIP LOCKED (or a Postgres advisory lock / claim-row pattern) to the schedule-selection query so only one pod claims a given due schedule
- Ensure the claim + status transition happens atomically (single transaction)
- Test coverage simulating two concurrent "pods" processing the same due schedule
Out of scope:
- Changes to how schedules are created or their cron cadence
- The separate
backend/src/jobs/part49Jobs.ts daily jobs (tracked in a different issue)
Acceptance Criteria
Technical Context
backend/src/services/scheduleExecutor.ts — processDueSchedules(), L44-150, comment at L95-97
backend/src/index.ts — L30, unconditional cron init
k8s/base/backend-hpa.yaml — minReplicas: 2
idempotencyMiddleware — currently only wired to HTTP routes, not this background path
What
Add row-level locking (
FOR UPDATE SKIP LOCKEDor equivalent) to the due-schedule query in the payroll scheduler so concurrent pods can't pick up and execute the same schedule at the same time.Why
processDueSchedules()inbackend/src/services/scheduleExecutor.ts(L44-150) runs on anode-cron('* * * * *')job initialized unconditionally inbackend/src/index.ts(L30) — meaning every backend pod runs this job independently. The query selecting due schedules (SELECT ... WHERE next_run_timestamp <= NOW() AND status='active') has no row locking.k8s/base/backend-hpa.yamlsetsminReplicas: 2, so at least two pods are always running this cron. The code's own comment (L95-97) acknowledges there's no real idempotency check on this path — andidempotencyMiddlewareis applied to HTTP schedule-creation routes but never reaches this background executor. In production this can mean two pods executing the same due payroll schedule simultaneously — real duplicate Stellar payments to employees.Scope
In scope:
FOR UPDATE SKIP LOCKED(or a Postgres advisory lock / claim-row pattern) to the schedule-selection query so only one pod claims a given due scheduleOut of scope:
backend/src/jobs/part49Jobs.tsdaily jobs (tracked in a different issue)Acceptance Criteria
processDueSchedules()against the same due schedule result in exactly one executionTechnical Context
backend/src/services/scheduleExecutor.ts—processDueSchedules(), L44-150, comment at L95-97backend/src/index.ts— L30, unconditional cron initk8s/base/backend-hpa.yaml—minReplicas: 2idempotencyMiddleware— currently only wired to HTTP routes, not this background path