DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Seven steps to charge one credit, and why none of them can be reordered

Our interview practice feature works like this: you record five short video answers in the browser, they upload straight to object storage, and then a background job transcribes them, samples frames, and writes feedback. One run costs one credit.

The submit endpoint that sits between those two halves is seventy lines long and took longer to get right than the processing job it queues. Here is the sequence, and what each step is defending against, because every one of them was added after thinking about a specific way the naive version fails.

The naive version

await deductCredit(userId);
await setVideoKey(interviewId, key);
await processInterviewTask.trigger({ interviewId });
return ok();
Enter fullscreen mode Exit fullscreen mode

Three lines, and at least four ways to lose money or trust.

Step by step

1. Load the interview and check ownership. 404 if it does not exist, 403 if it is not yours. Unremarkable, and it has to be first because everything below needs the row.

2. A cheap status fast-path.

if (interview.status !== 'pending') {
  return apiError('Interview has already been submitted', 409);
}
Enter fullscreen mode Exit fullscreen mode

The comment next to this is the important part: this is not the authoritative guard. Two concurrent requests can both read pending and both pass. It exists purely so that the common case of a double-clicked button is rejected before we spend five network round trips on storage checks. The real guard is step 5.

Labelling a check as non-authoritative in the code is worth doing. Otherwise the next person reads it as the concurrency protection and deletes the thing that actually is.

3. Verify every recording exists in storage, before charging anything.

const baseKey = `interviews/${userId}/${interviewId}`;
const existence = await Promise.all(
  Array.from({ length: N_QUESTIONS }, (_, i) => objectExists(`${baseKey}/q${i + 1}`))
);
const missing = existence
  .map((present, i) => (present ? null : i + 1))
  .filter((n): n is number => n !== null);
Enter fullscreen mode Exit fullscreen mode

Uploads go browser-to-storage with presigned URLs, so the server never sees the bytes and cannot assume they arrived. A flaky connection on question four produces a client that happily calls submit with four fifths of an interview.

Five parallel HEAD requests are cheap. A credit spent on an interview that cannot be processed is not, and the support conversation that follows is worth even less.

The error names the specific questions that are missing rather than saying "upload failed", because the user can actually act on "question 3 did not finish uploading".

4. Check the balance. A read-only check, returning 402 if it is empty. Also not authoritative, for the same reason as step 2, and also worth doing because a friendly error beats a thrown exception.

5. Atomically claim the row, before charging.

const claimed = await claimInterviewForProcessing(interviewId);
if (!claimed) {
  return apiError('Interview has already been submitted', 409);
}
Enter fullscreen mode Exit fullscreen mode

This is a conditional update: pending -> processing, returning whether it changed anything. Exactly one of any number of concurrent submissions wins.

The ordering here is the whole point of the post. The claim happens before the deduction. If it happened after, two concurrent requests would both deduct, and you would be refunding somebody while apologising. Claim first, and the loser of the race is rejected having been charged nothing.

The general rule: when an operation has a side effect that is hard to undo (billing) and one that is easy (a status flip), do the easy one first and use it as the mutual exclusion.

6. Deduct the credit, and hand the claim back if it fails.

try {
  await deductCredit(userId, interviewId);
} catch (err) {
  await updateInterviewStatus(interviewId, 'pending').catch((e) =>
    logError(`[complete] Failed to revert claim after deduct failure for ${interviewId}:`, e)
  );
  if (err instanceof InterviewError && err.code === ErrorCodes.INSUFFICIENT_CREDITS) {
    return apiError('Insufficient credits to submit this interview.', 402);
  }
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

The deduction can still fail even though step 4 said the balance was fine, because another request may have spent the last credit in between. So the claim is reverted to pending and the user can retry.

Note the .catch() on the revert. If the revert itself fails, we log and continue to return the real error. A rollback that throws and masks the original failure is a debugging nightmare, and the user's experience is identical either way.

7. Persist the key and queue the job, refunding if that fails.

try {
  await setVideoR2Key(interviewId, baseKey);
  await processInterviewTask.trigger({ interviewId });
} catch (err) {
  await refundCredit(userId, interviewId).catch(/* log */);
  await updateInterviewStatus(interviewId, 'failed', /* ... */);
}
Enter fullscreen mode Exit fullscreen mode

This is the charged-but-unqueued state, and it is the worst one available: the user has paid and nothing is running, with no event that will ever fix it. So a failure here refunds and marks the interview failed.

A refund rather than a reversal, because the ledger is append-only. The balance ends up where it started and the log shows a spend followed by a refund, which is what actually happened.

The compensations are the design

Four of the seven steps are a happy path. The other three are what to do when the step after them fails. That ratio is normal for anything that touches money and an external queue, and it is the part that gets skipped when you estimate the work as "add a submit button".

If you want a rule to carry into the next one of these: for each step, ask what state the system is in if the process dies immediately afterwards, and whether that state is self-correcting. Ours are:

  • after the claim: interview stuck in processing, nothing charged. Recoverable by a sweep.
  • after the deduction: charged and claimed, not queued. Not self-correcting, hence step 7's compensation.
  • after the trigger: normal operation, the job owns it from here.

Only one of those three needed explicit compensation. Finding out which one is the entire exercise.

The storage lifecycle, briefly

Once the job has consumed the recordings it deletes them:

Array.from({ length: N_QUESTIONS }, (_, i) => deleteObject(`${baseKey}/q${i + 1}`))
Enter fullscreen mode Exit fullscreen mode

The bucket only ever holds raw uploads, and only until they have been processed. Extracted frames and audio stay on the worker's local disk and are never persisted. That started as a cost decision and turned out to be a much easier privacy answer: the honest sentence is "the recording is deleted after processing", and it is honest because there is a line of code that does it.

You can try the flow at https://cogniprep.app/interview. A new account gets a free credit, so you can watch the sequence above from the outside: submit with a deliberately blocked upload and you will get the named-question 422 from step 3, with your credit still in your balance.

Top comments (0)