Short answer: a Node.js background worker can receive queued jobs through a public HTTPS push subscription, but it should authenticate every request and acknowledge only after an idempotent reservation transition.n that expires after a fixed hold window, use an authenticated HTTPS push endpoint, acknowledge only after an idempotent state transition, and make retries boring.
Keep the edge boring. It is not glamorous, but it is where auth belongs.\n\nThe useful mental model is a conveyor belt. A scheduler puts an expiry job on the belt; a public worker endpoint receives it; a database decides whether the reservation can still move to expired. Delivery is at-least-once in most practical queue systems, so duplicate messages are normal input, not an exceptional path.
What should a Node.js worker check before accepting queued jobs securely?
Start with the message contract, not the framework. The payload needs a stable jobId, reservationId, holdUntil, and an event type such as reservation.expire. The producer should include an expiry timestamp, while the worker uses the database clock for the final decision. Client-supplied time is useful context, not authority.
| Delivery choice | Pick this when | Main cost |
|---|---|---|
| HTTPS push to a worker | Jobs are irregular and you want low idle overhead | Public ingress, signature verification, and replay controls |
| Long polling | Your network cannot accept inbound HTTPS | Open connections and slower scale-out behavior |
| Pulling from a broker | You need consumer-controlled rate and ordering | More worker state and operational plumbing |
| A cron sweep | The job is naturally periodic and lateness is acceptable | Coarse timing and repeated scans |
For a reservation hold, push is a good fit when the queue can retry delivery and the worker can answer quickly. A cron sweep remains reasonable for a small system that can tolerate a few minutes of delay. The choice is about failure handling, not fashion.
A retry-safe expiration path in Express or Fastify
Treat the HTTPS handler as an adapter. Authentication, schema validation, and a short transaction belong at the edge; business work belongs behind a function that both Express and Fastify can call.
type ExpiryJob = {
jobId: string;
reservationId: string;
holdUntil: string;
event: "reservation.expire";
};
type ReservationStore = {
expireIfHeld: (reservationId: string, now: Date) => Promise<"expired" | "already_done" | "not_due">;
};
export function makeExpiryHandler(store: ReservationStore, verify: (raw: string, signature: string) => boolean) {
return async (request: { body: string; headers: Record<string, string | undefined> }, reply: { code: (n: number) => { send: (v: unknown) => void }; send: (v: unknown) => void }) => {
const signature = request.headers["x-queue-signature"];
if (!signature || !verify(request.body, signature)) return reply.code(401).send({ error: "unauthorized" });
let job: ExpiryJob;
try {
job = JSON.parse(request.body) as ExpiryJob;
} catch {
return reply.code(400).send({ error: "invalid_json" });
}
if (job.event !== "reservation.expire" || !job.jobId || !job.reservationId) {
return reply.code(400).send({ error: "invalid_job" });
}
const result = await store.expireIfHeld(job.reservationId, new Date());
// A duplicate delivery gets the same successful acknowledgement.
return reply.send({ accepted: true, outcome: result });
};
}
The transaction should look like UPDATE reservations SET status = 'expired' WHERE id = ? AND status = 'held' AND hold_until <= CURRENT_TIMESTAMP. Check the affected-row count. One row means this delivery won the transition; zero rows means another attempt already handled it or the hold is not due. Put a unique constraint on jobId if you retain a delivery ledger, but do not use that ledger as a substitute for the conditional state change.
I once started with a boolean processed flag in a design review. It sounded tidy until two workers read false before either wrote true. The repair was to make the reservation transition itself the idempotency key. That small change also made metrics honest: duplicates are counted, while inventory is changed once.
Return a 2xx only after the durable decision is complete. Return a retryable status for a transient dependency timeout, and make the queue's backoff bounded with jitter. A five-second first retry and a maximum delivery age are easier to reason about than an infinite storm.
Making public HTTPS delivery observable
Log one structured record per delivery: jobId, reservationId, attempt number, outcome, and latency. Never log the signature or full customer payload. Emit counters for accepted, duplicate, rejected, and expired-too-early jobs; alert on a rising rejected rate and on age of the oldest queued job.
A trace should connect enqueue, delivery, database transition, and acknowledgement. The useful dashboard is a before/after: queue age and held reservations before the worker change, then the same two measures after. Your mileage may vary when traffic is bursty; use a 95th-percentile age window rather than a single average.
Test the ugly cases deliberately. Send the same jobId twice in parallel, deliver a job before holdUntil, replay a valid signed body after its maximum age, and kill the process after the database commit but before the response. The expected result is one expiry, a successful duplicate acknowledgement, and a retry that cannot create a second side effect.
A useful failure drill follows one reservation from enqueue to reconciliation. Imagine reservation R-1842 with a 15-minute hold. The scheduler emits one job at minute 15, the queue delivers it twice because the first acknowledgement is delayed, and a deploy restarts the worker between the SQL commit and the HTTP response. The first transaction matches status = held and hold_until <= CURRENT_TIMESTAMP, changing exactly one row. The duplicate sees zero affected rows and returns a normal success response. After the restart, the redelivery sees the same zero-row result. Metrics show two deliveries, one state transition, and no inventory double-release. Now move the clock to minute 14: the conditional update changes nothing, and the handler returns not_due; the queue should treat that as a completed delivery only if the job policy allows early delivery to be discarded. Otherwise, schedule a delayed retry based on the remaining hold window. This is why a single processed flag is weak: it records a message, while the reservation row records the business truth. The same test works against an in-memory store in unit tests and a real transactional database in an integration test. Keep both.
Limits and a practical decision rule
The trade-off is clear: this limitation matters. Public HTTPS push is not suitable when policy forbids inbound traffic or when work routinely exceeds the queue's acknowledgement deadline. Choose a pull-based consumer in that environment. Long polling is also a better boundary when signatures cannot be verified at the edge.
This pattern does not provide ordering across different reservations, and it cannot make a non-transactional downstream API idempotent by itself. Use an outbox or a downstream idempotency key when expiry triggers a carrier call. Keep a cron sweep as a reconciliation path, but make it issue the same conditional update instead of inventing a second expiration rule.
The rule is simple: select the delivery mode your team can observe, authenticate, retry, and replay safely. For fixed-window logistics holds, that usually means a small HTTPS adapter wrapped around one database transition.
Top comments (0)