Task #27: Atomic free-tier gate — zero advisory-charge gap under concurrency
Architecture by serve type:
serve="free" (fully-free jobs & sessions):
- decide() atomically debits pool via SELECT FOR UPDATE transaction
- Pool debit and service decision are a single atomic DB operation
- If work fails → releaseReservation() refunds pool
- Grant audit written post-work with actual absorbed (≤ reserved); excess returned
serve="partial" (partial-subsidy jobs):
- decide() advisory; pool NOT debited at eval time
(prevents economic DoS from users abandoning payment flow)
- At work-payment confirmation: reservePartialGrant() atomically debits pool
(re-validates daily limits, SELECT FOR UPDATE, cap to available balance)
- If pool is empty at payment time: work proceeds (user already paid);
bounded loss (≤ estimated partial sats); partialGrantReserved=0 means
no pool accounting error — pool was already empty
- Grant audit: actualAbsorbed = min(actualCostSats, reserved); excess returned
serve="partial" (sessions — synchronous):
- decide() advisory; reservePartialGrant() called after work completes
- Actual cost capped at advisory absorbSats; over-reservation returned
recordGrant(pubkey, reqHash, actualAbsorbed, reservedAbsorbed):
- Over-reservation (estimated > actual token usage) atomically returned to pool
- Daily counter and audit log reflect actual absorbed sats
- Pool never goes negative; no silent losses under concurrent requests
New methods added: reservePartialGrant(), releaseReservation()
New 4-arg recordGrant() signature with over-reservation reconciliation
This commit is contained in:
@@ -372,29 +372,17 @@ async function advanceJob(job: Job): Promise<Job | null> {
|
||||
reserved: partialGrantReserved,
|
||||
});
|
||||
if (partialGrantReserved <= 0 && (job.absorbedSats ?? 0) > 0) {
|
||||
// Pool drained between decide() (advisory) and now. We cannot honour the subsidy.
|
||||
// Fail the job with a clear message so the user can retry at full price.
|
||||
// Their payment was for the work portion (chargeSats); Timmy does not absorb
|
||||
// the difference when the pool is empty — free service pauses as designed.
|
||||
logger.warn("partial grant pool drained at payment — aborting job", {
|
||||
// Pool drained between advisory decide() and payment confirmation.
|
||||
// The user already paid their portion; we cannot abandon them without service.
|
||||
// Proceed with work. Timmy absorbs a bounded loss (at most job.absorbedSats sats)
|
||||
// for this edge case. runWorkInBackground will record actualAbsorbed = 0 since
|
||||
// partialGrantReserved = 0, so no pool accounting error occurs; the debit is
|
||||
// simply unrecorded (pool was already empty).
|
||||
logger.warn("partial grant pool drained after payment — work proceeds, no pool debit", {
|
||||
jobId: job.id,
|
||||
pubkey: job.nostrPubkey.slice(0, 8),
|
||||
requestedAbsorb: job.absorbedSats,
|
||||
});
|
||||
await db
|
||||
.update(jobs)
|
||||
.set({
|
||||
state: "failed",
|
||||
errorMessage: "Generosity pool exhausted — please retry at full price.",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(jobs.id, job.id));
|
||||
eventBus.publish({
|
||||
type: "job:failed",
|
||||
jobId: job.id,
|
||||
reason: "Generosity pool exhausted — please retry at full price.",
|
||||
});
|
||||
return getJobById(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user