Advanced Debugging Assistant
Diagnoses bugs in your code, pinpoints root causes, and delivers fixed code with prevention tips.
Example run
Generated by Claude Opus 5.5 on September 23, 2026
Inputs used
- Programming language
- JavaScript (Node.js)
- Runtime environment
- Node.js v20.11.0, Express 4.18, deployed on AWS ECS Fargate, Redis 7 (via ioredis) as the shared inventory cache, PostgreSQL 15 as the source of truth, behind an ALB with 4 running task replicas
- Expected behavior
- When two customers try to buy the last unit of the same product ("SKU-4471", stock = 1) at nearly the same time, exactly one order should succeed and the other should receive a 409 "out of stock" response. Inventory in Redis and Postgres should always stay in sync and never go negative.
- Observed behavior
- Under load-testing with k6 (50 concurrent requests hitting POST /api/orders for the same SKU with stock = 1), 6-9 orders out of 50 succeed with HTTP 200 and get a confirmed order row in Postgres, even though only 1 unit was in stock. Redis inventory count ends up at -8. Occasionally the process also crashes a few seconds after the test with an unhandled promise rejection.
- Error message or stack trace
- node:internal/process/promises:288 triggerUncaughtException(err, true /* fromPromise */); ^ UnhandledPromiseRejection: This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). at decrementStock (/app/src/services/inventoryService.js:34:11) at async processOrder (/app/src/routes/orders.js:22:20) at async /app/src/routes/orders.js:9:5 Error: Redis connection lost while executing WATCH/MULTI transaction at Redis.sendCommand (/app/node_modules/ioredis/built/Redis.js:312:22) at Redis.multi (/app/node_modules/ioredis/built/Redis.js:412:10) at decrementStock (/app/src/services/inventoryService.js:29:34) at async processOrder (/app/src/routes/orders.js:22:20) [pm2] App [api] exited with code 1 [pm2] App [api] restarted Also periodically in logs: warn: order 9f3a1c2e-... confirmed for SKU-4471 but Postgres stock_units already at -8 (constraint check disabled in migration 0032_drop_stock_check.sql)
- Code to debug
- // src/routes/orders.js const router = require('express').Router(); const { decrementStock } = require('../services/inventoryService'); const { createOrder } = require('../services/orderService'); router.post('/api/orders', async (req, res) => { const { sku, quantity, customerId } = req.body; processOrder(sku, quantity, customerId).then((order) => { res.status(200).json(order); }); }); async function processOrder(sku, quantity, customerId) { const currentStock = await getStock(sku); if (currentStock < quantity) { throw new Error('out of stock'); } // simulate a bit of processing / fraud check latency await new Promise((resolve) => setTimeout(resolve, 50)); await decrementStock(sku, quantity); const order = await createOrder({ sku, quantity, customerId, status: 'confirmed' }); return order; } async function getStock(sku) { const val = await redisClient.get(`stock:${sku}`); return parseInt(val, 10); } module.exports = router; // src/services/inventoryService.js const redisClient = require('../lib/redisClient'); async function decrementStock(sku, quantity) { const key = `stock:${sku}`; const current = parseInt(await redisClient.get(key), 10); const updated = current - quantity; const multi = redisClient.multi(); multi.set(key, updated); await multi.exec(); return updated; } module.exports = { decrementStock };
Summary: A non-atomic check-then-act across 4 replicas (read → 50 ms sleep → read-modify-write), with no enforcement in Postgres, lets many requests pass the stock check. A missing error path turns every "out of stock" into a hung request and a process crash.
Before the issues, one caveat. The code shown is not exactly the code producing your logs:
orders.jscallsredisClient.get(...)but never importsredisClient. As pasted,getStockwould throwReferenceErroron every request, and no order could ever succeed.- The stack trace references
inventoryService.js:29and:34and aWATCH/MULTItransaction. The pasted file is about 14 lines long and contains noWATCH.
The root-cause analysis below holds for both versions, because the race exists whether or not WATCH is used this way. Still, please confirm what is actually deployed, for example with git log on the image tag. I'm also assuming a schema (products(sku, stock_units), orders(...)) and a pg Pool at src/lib/db, because orderService.js wasn't provided. Send it if my assumptions are wrong.
Mental reproduction (50 concurrent requests, stock = 1, 4 replicas)
t=0ms Req A..Z (spread across 4 tasks) → GET stock:SKU-4471 → all read "1"
all pass `1 < 1 === false` check
t=0-50ms everyone sleeps 50 ms (the "fraud check") ← window is now huge
t=50ms Req A: GET → 1, SET 0 → createOrder ✅
Req B: GET → 0, SET -1 → createOrder ✅
Req C: GET → -1, SET -2 → createOrder ✅
Req D, E: GET → -2 (both), SET -3 (both) ← lost update, both ✅
...
later Req that read stock after the first SET sees 0 → throw 'out of stock'
→ unhandled rejection → Node 20 kills the task → pm2 restarts
Every request whose initial GET landed before the first SET believes stock exists. That is why you get 6–9 successes instead of 1. The exact number depends on how many requests arrive within the check window. Postgres has no CHECK constraint (migration 0032 dropped it), so nothing downstream stops them either.
Things only go "correctly" out of stock for requests arriving late. Those throw, get no response, and crash the replica.
Issue #1: Race condition (TOCTOU / non-atomic check-and-decrement)
- Location:
processOrder(thegetStockcheck, the sleep, thendecrementStock) and all ofdecrementStock - Severity: High (this is the bug that causes overselling)
- Root cause:
- Check and act are separate operations. The stock check (
getStock) and the decrement happen at different times, with at least 50 ms and several network round-trips between them. Any number of other requests can pass the same check in that window. decrementStockis itself a read-modify-write. It doesGET, subtracts in JavaScript, thenSETs an absolute value. Wrapping only theSETinMULTIprovides zero protection.MULTImakes the queued commands atomic, but theGEThappened outside the transaction.- Even with
WATCH, you'd need to checkexec()returningnull(transaction aborted) and retry. The code doesn't do this. - ioredis's
exec()also doesn't throw on per-command errors. It returns[[err, result], ...].
- Even with
- In-process locks can't help. With 4 ECS replicas, a Node mutex or queue only serializes within one task. The atomicity has to live in Redis and/or Postgres.
- Check and act are separate operations. The stock check (
- Fix: Perform check + decrement as one atomic server-side operation. A Lua script executes atomically in Redis, so no other command interleaves:
// src/services/inventoryService.js
const redisClient = require('../lib/redisClient');
class OutOfStockError extends Error {
constructor(sku) { super(`out of stock: ${sku}`); this.name = 'OutOfStockError'; this.sku = sku; }
}
class StockNotInitializedError extends Error {
constructor(sku) { super(`stock key missing for ${sku}`); this.name = 'StockNotInitializedError'; this.sku = sku; }
}
// Atomic: returns new stock on success, -1 if insufficient, -2 if key missing.
redisClient.defineCommand('reserveStock', {
numberOfKeys: 1,
lua: `
local raw = redis.call('GET', KEYS[1])
if not raw then return -2 end
local stock = tonumber(raw)
local qty = tonumber(ARGV[1])
if stock < qty then return -1 end
return redis.call('DECRBY', KEYS[1], qty)
`,
});
async function reserveStock(sku, quantity) {
const result = await redisClient.reserveStock(`stock:${sku}`, quantity);
if (result === -2) throw new StockNotInitializedError(sku);
if (result === -1) throw new OutOfStockError(sku);
return result;
}
async function releaseStock(sku, quantity) {
return redisClient.incrby(`stock:${sku}`, quantity);
}
module.exports = { reserveStock, releaseStock, OutOfStockError, StockNotInitializedError };
- Verification: Set
stock:SKU-4471 = 1, then fire 50 parallelreserveStock('SKU-4471', 1)calls withPromise.allSettled. Exactly 1 should be fulfilled, 49 rejected withOutOfStockError, and the final value should be0. Repeat with 4 separate Node processes hitting the same Redis. - Prevention: Treat any "read, decide in app code, write" sequence against shared state as a bug unless it is guarded by an atomic primitive: a Lua script,
WATCH+ retry on anullexec(),SELECT ... FOR UPDATE, or a conditionalUPDATE ... WHERE.
Issue #2: Postgres (the source of truth) doesn't enforce the invariant
- Location:
createOrder/orderService.js, and migration0032_drop_stock_check.sql - Severity: High
- Root cause:
- Your spec says Postgres is the source of truth, yet it accepts
stock_units = -8because the CHECK constraint was dropped. Redis is a cache and can be flushed, evicted, restarted, or drift. - So Redis cannot be the only thing standing between you and overselling.
- Also, Redis is decremented and then the order is inserted as a separate step. If
createOrderfails, Redis is permanently off byquantity. This is the dual-write problem.
- Your spec says Postgres is the source of truth, yet it accepts
- Fix: Put the authoritative check in a single Postgres transaction, using a conditional update. Restore the constraint as a backstop.
// src/services/orderService.js (assumes pg Pool at ../lib/db; adjust to your schema)
const pool = require('../lib/db');
const { OutOfStockError } = require('./inventoryService');
async function placeOrder({ sku, quantity, customerId }) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Atomic check-and-decrement: the row lock serializes concurrent writers.
const upd = await client.query(
`UPDATE products
SET stock_units = stock_units - $1
WHERE sku = $2 AND stock_units >= $1
RETURNING stock_units`,
[quantity, sku]
);
if (upd.rowCount === 0) throw new OutOfStockError(sku);
const ins = await client.query(
`INSERT INTO orders (sku, quantity, customer_id, status)
VALUES ($1, $2, $3, 'confirmed')
RETURNING *`,
[sku, quantity, customerId]
);
await client.query('COMMIT');
return ins.rows[0];
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}
module.exports = { placeOrder };
-- migrations/0045_restore_stock_check.sql
-- NOT VALID lets you add it while bad rows (-8) exist; fix data, then validate.
ALTER TABLE products
ADD CONSTRAINT products_stock_units_nonnegative CHECK (stock_units >= 0) NOT VALID;
-- after reconciling oversold rows:
-- ALTER TABLE products VALIDATE CONSTRAINT products_stock_units_nonnegative;
- Verification: Run 50 concurrent
placeOrdercalls directly against Postgres, bypassing Redis entirely, withstock_units = 1. Expect exactly 1 order row andstock_units = 0. A manualUPDATE products SET stock_units = -1should now fail with a check violation. - Prevention:
- Invariants like "never negative" belong in the database as constraints, not only in app code.
- Any migration that drops a constraint should need an explicit justification in review.
- Use Redis as a fast-path gate, not the authority.
Issue #3: Unhandled rejection crashes the process; failed requests never get a response
- Location: the route handler in
orders.js(processOrder(...).then(...)with no.catch, and noawait) - Severity: High
- Root cause:
- Since Node 15, the default is
--unhandled-rejections=throw. Any rejected promise with no handler terminates the process. - In this handler,
processOrderrejects on every "out of stock" and on every Redis or Postgres error, and nobody catches it. - Consequences:
- The client never gets a 409. The socket hangs until ALB or k6 times out.
- The whole ECS task dies. It takes all other in-flight requests with it, which is the "exited with code 1" you see.
- Express 4 does not catch async errors on its own (Express 5 does). You must
try/catchand respond or callnext(err). - The "Redis connection lost" error in your trace is just one of many errors that follow this same crash path.
- Since Node 15, the default is
- Fix:
router.post('/api/orders', async (req, res, next) => {
try {
// ...validate, then:
const order = await processOrder(sku, quantity, customerId);
return res.status(201).json(order);
} catch (err) {
if (err instanceof OutOfStockError) {
return res.status(409).json({ error: 'out_of_stock', sku: err.sku });
}
return next(err); // central error handler → 500, logged
}
});
- Verification: Set stock to 0 and POST once. You should get a 409 immediately, and the process should stay up. Kill Redis mid-request (
docker pause redis). You should get a 500 or 503 response, not a crash. - Prevention:
- Never fire-and-forget a promise in a request handler.
- Add ESLint
@typescript-eslint/no-floating-promisesoreslint-plugin-promise. - Add a central Express error middleware.
- Log
process.on('unhandledRejection')for visibility, but do not use it to swallow errors.
Issue #4: NaN bypasses the stock check; quantity is unvalidated
- Location:
getStock(parseInt), andreq.bodydestructuring - Severity: High (for security and correctness)
- Root cause:
- Missing key: if the Redis key is missing (eviction, flush, cold start),
redisClient.getreturnsnull. ThenparseInt(null, 10)isNaN, andNaN < quantityis false, so the check passes. The code then writesNaN - 1, which is stored as the string"NaN"and poisons the key. - Bad
quantity: it comes straight from the client.quantity: -5passes the check and increases stock, which lets anyone mint inventory.quantity: 0creates free orders.quantity: "1e9"orquantity: 1.5behave unpredictably due to type coercion.
- Trusted
customerId:customerIdfrom the body lets anyone place orders as anyone.
- Missing key: if the Redis key is missing (eviction, flush, cold start),
- Fix:
const SKU_RE = /^[A-Z0-9-]{1,64}$/;
const MAX_QTY = 100;
function validateOrderInput(body) {
const { sku, quantity } = body ?? {};
if (typeof sku !== 'string' || !SKU_RE.test(sku)) return 'invalid sku';
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_QTY) return 'invalid quantity';
return null;
}
The Lua script in Issue #1 also returns -2 for a missing key instead of treating it as available. Derive customerId from the authenticated session (req.user.id), not the body.
- Verification:
- POST
quantity: -5,0,"1",1.5: each should return 400. - Run
DEL stock:SKU-4471and POST: you should get a 503 or a resync, never a 200.
- POST
- Prevention: Use schema validation at the edge (zod, joi, or ajv) on every route. Never trust identity fields sent in the request body.
Issue #5: Redis ↔ Postgres drift (dual write without compensation)
- Location: the sequence
decrementStock→createOrder - Severity: Medium
- Root cause:
- Two separate stores can't be updated atomically. If the Redis decrement succeeds and Postgres fails, Redis under-reports stock forever. Postgres could fail because of the restored constraint, a timeout, or a crash.
- The reverse can also happen: Postgres may say out of stock while Redis said OK, because Redis was stale.
- Fix: Reserve in Redis, commit in Postgres, and on any Postgres failure, compensate with
releaseStock(shown in the refactor below). Add a periodic reconciliation job that sets Redis from Postgres. This covers the one gap compensation can't: a crash between the two steps. - Verification: Inject a failure in
placeOrder(e.g., throw afterBEGIN). The Redis value should return to its original number. - Prevention: Designate one authority (Postgres) and treat the other store as a derived cache. Reconcile on a schedule and alert on drift.
Issue #6: Artificial latency inside the critical section
- Location: the 50 ms
setTimeoutinprocessOrder - Severity: Medium (it amplifies Issue #1)
- Root cause: Doing slow work (a fraud check) between check and act makes the race window 50 ms+ wide instead of microseconds. With the atomic fix, correctness no longer depends on this window. It's still better to run the fraud check before reserving stock, so you don't hold inventory for requests that will be rejected.
- Fix: Move the fraud check before
reserveStock, or release the reservation if the fraud check fails after it. - Prevention: Keep critical sections as short as possible, and don't do I/O while holding a reservation or lock.
Issue #7: Missing import, and code/deployment mismatch
- Location:
orders.js(redisClientundefined); the stack trace's line numbers andWATCH/MULTI - Severity: Low (as pasted it would be fatal; it indicates the deployed build differs)
- Fix: The refactored version removes
getStockfrom the route entirely. Verify that the image deployed to ECS matches the commit you're debugging.
Refactored Version
// src/services/inventoryService.js
const redisClient = require('../lib/redisClient');
class OutOfStockError extends Error {
constructor(sku) { super(`out of stock: ${sku}`); this.name = 'OutOfStockError'; this.sku = sku; }
}
class StockNotInitializedError extends Error {
constructor(sku) { super(`stock key missing for ${sku}`); this.name = 'StockNotInitializedError'; this.sku = sku; }
}
const stockKey = (sku) => `stock:${sku}`;
// Runs atomically inside Redis: no other command can interleave.
redisClient.defineCommand('reserveStock', {
numberOfKeys: 1,
lua: `
local raw = redis.call('GET', KEYS[1])
if not raw then return -2 end
local stock = tonumber(raw)
local qty = tonumber(ARGV[1])
if stock == nil or qty == nil or qty <= 0 then return -3 end
if stock < qty then return -1 end
return redis.call('DECRBY', KEYS[1], qty)
`,
});
async function reserveStock(sku, quantity) {
const result = await redisClient.reserveStock(stockKey(sku), quantity);
if (result === -1) throw new OutOfStockError(sku);
if (result === -2) throw new StockNotInitializedError(sku);
if (result === -3) throw new Error(`corrupt stock value or bad quantity for ${sku}`);
return result;
}
async function releaseStock(sku, quantity) {
return redisClient.incrby(stockKey(sku), quantity);
}
async function setStockFromSource(sku, units) {
return redisClient.set(stockKey(sku), units);
}
module.exports = {
reserveStock,
releaseStock,
setStockFromSource,
OutOfStockError,
StockNotInitializedError,
};
// src/services/orderService.js
const pool = require('../lib/db');
const { OutOfStockError } = require('./inventoryService');
async function placeOrder({ sku, quantity, customerId }) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const upd = await client.query(
`UPDATE products
SET stock_units = stock_units - $1
WHERE sku = $2 AND stock_units >= $1
RETURNING stock_units`,
[quantity, sku]
);
if (upd.rowCount === 0) throw new OutOfStockError(sku);
const ins = await client.query(
`INSERT INTO orders (sku, quantity, customer_id, status)
VALUES ($1, $2, $3, 'confirmed')
RETURNING *`,
[sku, quantity, customerId]
);
await client.query('COMMIT');
return { order: ins.rows[0], remaining: upd.rows[0].stock_units };
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}
async function getStockUnits(sku) {
const { rows } = await pool.query('SELECT stock_units FROM products WHERE sku = $1', [sku]);
return rows[0]?.stock_units ?? null;
}
module.exports = { placeOrder, getStockUnits };
// src/routes/orders.js
const router = require('express').Router();
const {
reserveStock,
releaseStock,
setStockFromSource,
OutOfStockError,
StockNotInitializedError,
} = require('../services/inventoryService');
const { placeOrder, getStockUnits } = require('../services/orderService');
const { runFraudCheck } = require('../services/fraudService'); // your existing check
const logger = require('../lib/logger');
const SKU_RE = /^[A-Z0-9-]{1,64}$/;
const MAX_QTY = 100;
function validateOrderInput(body) {
const { sku, quantity } = body ?? {};
if (typeof sku !== 'string' || !SKU_RE.test(sku)) return 'invalid sku';
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_QTY) return 'invalid quantity';
return null;
}
router.post('/api/orders', async (req, res, next) => {
const validationError = validateOrderInput(req.body);
if (validationError) return res.status(400).json({ error: validationError });
const { sku, quantity } = req.body;
const customerId = req.user?.id; // from auth middleware, NOT the body
if (!customerId) return res.status(401).json({ error: 'unauthenticated' });
try {
const order = await processOrder(sku, quantity, customerId);
return res.status(201).json(order);
} catch (err) {
if (err instanceof OutOfStockError) {
return res.status(409).json({ error: 'out_of_stock', sku });
}
if (err instanceof StockNotInitializedError) {
return res.status(503).json({ error: 'inventory_unavailable', sku });
}
return next(err);
}
});
async function processOrder(sku, quantity, customerId) {
// 1. Slow checks BEFORE touching inventory; keep the critical section short.
await runFraudCheck({ sku, quantity, customerId });
// 2. Fast, atomic gate in Redis (rejects the other 49 without hitting Postgres).
try {
await reserveStock(sku, quantity);
} catch (err) {
if (err instanceof StockNotInitializedError) {
await resyncFromPostgres(sku); // warm the cache for the next request
}
throw err;
}
// 3. Authoritative commit in Postgres; compensate Redis on ANY failure.
try {
const { order } = await placeOrder({ sku, quantity, customerId });
return order;
} catch (err) {
await releaseStock(sku, quantity).catch((releaseErr) =>
logger.error('failed to release redis reservation; reconciliation needed', {
sku, quantity, releaseErr,
})
);
if (err instanceof OutOfStockError) {
// Redis said yes, Postgres said no → cache drifted; correct it.
await resyncFromPostgres(sku);
}
throw err;
}
}
async function resyncFromPostgres(sku) {
try {
const units = await getStockUnits(sku);
if (units !== null) await setStockFromSource(sku, units);
} catch (err) {
logger.warn('stock resync failed', { sku, err });
}
}
module.exports = router;
// src/app.js (excerpt): central error handler, registered after all routes
app.use((err, req, res, _next) => {
req.log?.error({ err }, 'unhandled request error') ?? console.error(err);
res.status(500).json({ error: 'internal_error' });
});
process.on('unhandledRejection', (reason) => {
console.error('unhandledRejection', reason); // visibility only; still let it crash
throw reason;
});
Why this is correct even though it spans two stores:
- Postgres is the single authority. The conditional
UPDATE ... WHERE stock_units >= $1takes a row lock. Concurrent transactions serialize on it, and the loser re-evaluates theWHEREagainst the committed value and matches 0 rows. Postgres alone guarantees exactly one success. The CHECK constraint is a second backstop. - Redis is an optimization. It rejects the other 49 requests cheaply without opening 49 Postgres transactions. If Redis drifts, the worst case is a spurious 409 or an extra Postgres round-trip, never an oversell.
Recommendations
- Concurrency regression test. Use Jest + testcontainers with real Redis and real Postgres, not mocks, because mocks can't reproduce races.
test('only one of 50 concurrent orders wins the last unit', async () => { await seed({ sku: 'SKU-4471', units: 1 }); const results = await Promise.allSettled( Array.from({ length: 50 }, (_, i) => request(app).post('/api/orders').set(authFor(`c${i}`)).send({ sku: 'SKU-4471', quantity: 1 })) ); const codes = results.map((r) => r.value.status); expect(codes.filter((c) => c === 201)).toHaveLength(1); expect(codes.filter((c) => c === 409)).toHaveLength(49); expect(await redis.get('stock:SKU-4471')).toBe('0'); expect(await pgStock('SKU-4471')).toBe(0); expect(await orderCount('SKU-4471')).toBe(1); }); - Re-run the k6 test across all 4 replicas.
- Pass criteria: exactly one 201 and 49 409s. The run should have zero timeouts and zero task restarts, and both stores should end at
0. - Chaos variant: add
docker pauseon Redis mid-run and confirm that you get 503s but no oversell.
- Pass criteria: exactly one 201 and 49 409s. The run should have zero timeouts and zero task restarts, and both stores should end at
- Clean up the existing damage. Find the oversold orders (
ordersfor SKU-4471 beyond the firststock_unitsworth), cancel or refund them, and correctstock_units. ThenVALIDATE CONSTRAINTandSET stock:SKU-4471from Postgres. - Reconciliation job. Every N minutes, compare Redis vs Postgres for active SKUs, overwrite Redis from Postgres, and emit a
inventory.driftmetric. Alert if drift is nonzero. - Idempotency keys. Require an
Idempotency-Keyheader, stored in a unique column onorders. That way client or ALB retries of a timed-out request don't create duplicate orders. - Structured logging. For each order attempt, log
{sku, quantity, customerId, redisResult, pgRowCount, outcome, latencyMs}. The current "stock already at -8" warning should become an error-level alert, not a warn. - Linting. Enable
no-floating-promisesandrequire-await. Add a code-review rule that migrations dropping constraints need sign-off. - Process manager. On ECS, running pm2 inside the container hides crashes from ECS health checks and metrics. Prefer
nodeas PID 1, e.g. with--init. Let ECS restart the task so crashes are visible in CloudWatch.
Related prompts
Algorithm Design Expert
Designs, analyzes, and optimizes algorithms with multiple approaches, Big-O complexity analysis, production code, and tests.
API Development Architect
Designs a production-ready RESTful API with OpenAPI spec, secure auth, validation, and scalable architecture.
Code Review Expert
Performs a rigorous senior-level code review covering correctness, design, performance, and security with prioritized, actionable fixes.
SQL Query Optimizer
Diagnoses a slow SQL query and returns an optimized rewrite, index plan, and execution-plan reasoning.