5.2 KiB
Race Condition Fix - Implementation Summary
Problem
When multiple buyers attempt to purchase the last available units simultaneously, a race condition occurs where inventory can be oversold.
Solution
Implemented a 10-minute reservation system using the pending_orders table to temporarily hold inventory during checkout.
Changes Made
1. Database Migration
- File:
migrations/add_expires_at_to_pending_orders.sql - Added
expires_atcolumn topending_orderstable - Added index on
expires_atfor efficient cleanup queries
To apply the migration:
-- Run the migration file
SOURCE migrations/add_expires_at_to_pending_orders.sql;
2. Create Invoice Endpoint (app/api/payments/create-invoice/route.ts)
- Atomic Inventory Reservation: Uses database transactions to atomically check and reserve inventory
- Pending Orders Check: Includes non-expired pending orders when calculating available inventory
- Expiration Time: Sets
expires_atto 10 minutes from creation - NOWPayments Integration: Sets
invoice_timeoutto 600 seconds (10 minutes) when creating invoice
3. Active Drop Endpoint (app/api/drops/active/route.ts)
- Fill Calculation: Includes non-expired pending orders in fill calculation
- UI Display: Progress bar now shows total reserved inventory (sales + pending orders)
4. Cleanup Endpoint (app/api/payments/cleanup-expired/route.ts)
- Automatic Cleanup: Removes expired pending orders
- NOWPayments Status Check: Verifies payment status before cleanup
- Monitoring: GET endpoint to check count of expired orders
5. UI Updates (app/components/Drop.tsx)
- Visual Indicator: Added note that items are held for 10 minutes during checkout
- Progress Bar: Automatically reflects reserved inventory (including pending orders)
Setup Instructions
1. Run Database Migration
mysql -u your_user -p your_database < migrations/add_expires_at_to_pending_orders.sql
2. Set Up Cleanup Job
The cleanup endpoint should be called periodically (recommended: every 1-2 minutes) to remove expired pending orders.
Option A: Cron Job (Linux/Mac)
# Add to crontab (crontab -e)
*/2 * * * * curl -X POST https://your-domain.com/api/payments/cleanup-expired -H "Authorization: Bearer YOUR_CLEANUP_TOKEN"
Option B: Vercel Cron (if using Vercel)
Add to vercel.json:
{
"crons": [{
"path": "/api/payments/cleanup-expired",
"schedule": "*/2 * * * *"
}]
}
Option C: Node.js Cron Library
const cron = require('node-cron');
const fetch = require('node-fetch');
cron.schedule('*/2 * * * *', async () => {
await fetch('http://localhost:3000/api/payments/cleanup-expired', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CLEANUP_API_TOKEN}`
}
});
});
3. Environment Variables
Add to your .env file:
# Required: URL for external IPN handler service
IPN_CALLBACK_URL=http://your-ipn-service.com/api/payments/ipn-callback
# Optional: Token for cleanup endpoint security
CLEANUP_API_TOKEN=your-secure-random-token
Note: IPN callbacks are handled by an external service. The IPN_CALLBACK_URL must point to your external IPN handler that processes payment confirmations and creates sales. See IPN_INTEGRATION_README.md for integration details.
How It Works
-
Purchase Initiation: When a buyer clicks "Join Drop":
- System checks available inventory (sales + non-expired pending orders)
- If available, creates pending order with 10-minute expiration
- Creates NOWPayments invoice with 10-minute timeout
- Inventory is now "on hold" for 10 minutes
-
During Checkout:
- Progress bar shows reserved inventory (including pending orders)
- Other buyers see reduced availability
- If buyer completes payment within 10 minutes → sale is created
- If buyer doesn't complete payment within 10 minutes → order expires
-
Expiration:
- Cleanup job runs every 1-2 minutes
- Expired pending orders are removed
- Inventory becomes available again
- NOWPayments invoice expires on their end
-
Payment Confirmation (handled by external IPN service):
- External IPN handler receives payment notification from NOWPayments
- Checks if pending order is expired
- Validates final inventory availability
- Creates sale record if valid
- Deletes pending order
Testing
-
Test Race Condition:
- Open two browser windows
- Try to purchase the last available unit simultaneously
- Only one should succeed
- The other should see "Not enough inventory remaining"
-
Test Expiration:
- Create a pending order
- Wait 10+ minutes
- Run cleanup endpoint manually
- Verify order is removed
-
Test Payment Flow:
- Create pending order
- Complete payment within 10 minutes
- Verify sale is created
- Verify pending order is deleted
Notes
- Pending orders are automatically included in inventory calculations
- The 10-minute hold prevents overselling while giving buyers time to complete payment
- NOWPayments invoices also expire after 10 minutes
- Cleanup job should run frequently (every 1-2 minutes) to free up inventory quickly