credit-queue
v0.1.0
Published
Atomic Redis Lua credit gate and write-behind event queue with two-phase reservations (reserve, settle, release) and monotonic versioning.
Maintainers
Readme
credit-queue
Atomic Redis Lua credit gate and write-behind event queue with two-phase reservations (reserve, settle, release) and monotonic versioning.
Built for usage-based APIs, AI/LLM token metering, and credit-gated SaaS plans where you need:
- Fast admission control (~2ms single Redis
EVAL) that prevents concurrent requests from racing past a budget. - Two-phase holds (
reservebefore work,settleactual cost orreleaseon failure). - Atomic write-behind queueing so every balance change pushes an ordered event (
version) for background persistence to Postgres/ClickHouse. - Zero runtime dependencies — works with
@upstash/redisor any Redis client that implementseval,hgetall, andlpop.
Installation
pnpm add credit-queue
# or
npm install credit-queueHow It Works
Caller (API / MCP / AI Route)
│
▼
CreditQueue.reserve / settle / release
│
├─► Runs a single atomic Redis Lua script:
│ 1. Checks or updates `{prefix}:{key}:state` (Hash)
│ • budget, consumed, reserved, available, version
│ • active per-request holds (`res:{requestId}`)
│ 2. Increments `version` (monotonic integer: v + 1)
│ 3. Appends `CreditQueueEvent` JSON to `{prefix}:events:queue` (List)
│
└─► Cold-cache miss (`uninitialized`)?
• Calls optional `loadBalance(key)` from your DB
• Seeds `{prefix}:{key}:state` (`SEED_BALANCE_LUA`)
• Retries the mutation automaticallyQuick Start
import { Redis } from '@upstash/redis';
import { CreditQueue } from 'credit-queue';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
type BillingMetadata = {
teamId: string;
workspaceId?: string;
userId?: string;
apiKeyId?: string;
toolName?: string;
};
export const creditQueue = new CreditQueue<BillingMetadata>({
redis,
prefix: 'billing', // defaults to 'credit-queue'
stateTtlSeconds: 35 * 24 * 60 * 60, // 35 days
loadBalance: async (key) => {
// Called only on a cold Redis cache miss for `key`
const period = await db.billingPeriod.findUnique({ where: { id: key } });
if (!period) return null;
return {
key: period.id,
budget: period.allowance + period.granted,
consumed: period.consumed,
version: period.checkpointVersion,
};
},
});1. Two-phase flow (reserve → settle / release)
Use this when the exact cost isn't known upfront (e.g. LLM streaming or paginated searches) or when you want to hold credits while an operation is in flight:
const requestId = crypto.randomUUID();
// 1. Reserve estimated max cost before executing work
const reservation = await creditQueue.reserve({
key: billingPeriodId,
requestId,
amount: 5_000,
metadata: {
teamId: 'team_123',
userId: 'user_456',
toolName: 'searchBrands',
},
});
if (!reservation.ok) {
// reservation.status === 'insufficient_credits' | 'uninitialized' | 'invalid_state'
return new Response('Out of credits', { status: 402 });
}
try {
const result = await runExpensiveOperation();
// 2a. Settle the actual cost (releases the 5,000 hold and adds 3,200 to `consumed`)
await creditQueue.settle({
key: billingPeriodId,
requestId,
amount: 3_200,
metadata: {
teamId: 'team_123',
userId: 'user_456',
toolName: 'searchBrands',
},
});
return Response.json(result);
} catch (err) {
// 2b. Operation failed — release the 5,000 hold without charging `consumed`
await creditQueue.release({
key: billingPeriodId,
requestId,
metadata: {
teamId: 'team_123',
userId: 'user_456',
toolName: 'searchBrands',
},
});
throw err;
}2. Direct settlement (settle without prior reserve)
If you already know the exact cost or want to record post-hoc usage directly:
const settled = await creditQueue.settle({
key: billingPeriodId,
requestId: crypto.randomUUID(),
amount: 1_000,
metadata: {
teamId: 'team_123',
toolName: 'getMedia',
},
});3. Draining the queue to your database
A background job or scheduled worker pops batches from Redis and persists them to your database, using version to guard against out-of-order batch writes:
const events = await creditQueue.dequeue(100);
for (const event of events) {
if (event.operation === 'settle') {
await db.usageEvent.insertIfNotExists({
id: event.requestId,
periodId: event.key,
amount: event.amount,
metadata: event.metadata,
createdAt: event.createdAt,
});
}
// Only advance the database checkpoint if this event is newer
await db.sql`
UPDATE billing_period
SET consumed = ${event.consumed},
checkpoint_version = ${event.version}
WHERE id = ${event.key}
AND checkpoint_version < ${event.version}
`;
}API Reference
new CreditQueue<TMetadata>(config)
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| redis | CreditQueueRedisClient | required | Redis client implementing eval, hgetall, and lpop (e.g. @upstash/redis). |
| prefix | string | 'credit-queue' | Key prefix for Redis state hashes and the default event queue key. |
| queueKey | string | '{prefix}:events:queue' | Custom Redis list key for the write-behind queue. |
| stateTtlSeconds | number | 3024000 (35 days) | Expiration TTL in seconds applied to state hashes. |
| loadBalance | (key: string) => Promise<SeedBalanceInput \| null> | undefined | Optional callback to load initial state from your primary database when a key is not in Redis. |
Methods
queue.reserve(input)— Holdsamountinreservedifavailable >= amount(or ifallowOverdraft: true), incrementsversion, and enqueues a'reserve'event.queue.settle(input)— Releases any active hold forrequestId, addsamounttoconsumed, incrementsversion, and enqueues a'settle'event.queue.release(input)— Releases the active hold forrequestIdwithout adding toconsumed, incrementsversion, and enqueues a'release'event. Returnsstatus: 'no_active_reservation'if no hold exists.queue.seedBalance(params)— Initializes or updates{prefix}:{key}:state. Always updatesbudget; only overwritesconsumedandversionif the incomingversionis greater than the current Redisversion.queue.getState(key)— Reads the currentCreditBalanceState(budget,consumed,reserved,available,version) or returnsnullif uninitialized.queue.dequeue(count = 100)— Atomically pops up tocountCreditQueueEventitems from the queue viaLPOP.
Redis Data Layout
credit-queue uses only two Redis keys:
{prefix}:{key}:state(Hash)key: Bucket identifier (e.g. billing period ID)budget: Total integer budget for the periodconsumed: Total settled usagereserved: Total currently held across in-flight requestsversion: Monotonically increasing integer counterres:{requestId}: Held amount for each active in-flight reservation (removed onsettleorrelease)
{prefix}:events:queue(List)- FIFO queue of JSON-encoded
CreditQueueEventpayloads appended viaRPUSHand drained viaLPOP.
- FIFO queue of JSON-encoded
License
MIT
