DEV Community

JensenCole5829
JensenCole5829

Posted on

Node.js API Queue: Multiple Audio Files, Batch Transcribe (and Why Claims Matter)

The least complicated design that survives a real batch is a durable job record plus a worker that claims one recording at a time. The upload request should create work, return a job identifier, and stop there. A worker can then fetch the audio, call the transcription API, persist the text, and emit usage for the tenant. The Node.js part is the HTTP boundary and worker runtime; the important boundary is the claim, not the queue brand.

Short answer: give every audio object a stable content key, put one transcription job behind it, and make the worker's completion operation idempotent. A retry must be allowed to repeat an HTTP request without creating a second transcript or charging the wrong tenant in your own accounting. RFC 9110 describes idempotent methods as requests whose repeated effect is intended to be the same, which is the right mental model for the state transitions here.

This is the shape I use for a B2B SaaS knowledge base that receives recorded customer calls. Each tenant may upload several files, and the resulting text stays private to that tenant before it is indexed for question answering. Cost visibility is a first-class output. A transcript without tenant_id, job_id, and an auditable usage event is incomplete, even if the text looks perfect.

How should an API batch transcribe multiple audio files?

The API accepts metadata and an object reference, rather than holding an HTTP connection open while audio is processed. A row in the application database owns the lifecycle: queued, running, succeeded, or failed. The queue contains a reference to that row. It is a delivery mechanism, not the source of truth.

That distinction matters after a worker disappears between transcription and acknowledgement. A durable record lets another worker decide whether the lease expired, inspect the attempt count, and continue. The storage key is also where tenant isolation becomes concrete: it should be derived from an authenticated tenant identifier, never accepted as an arbitrary path supplied by a client.

For a batch, the client submits many independent jobs. It does not submit one giant task whose partial progress is hidden. One file per job gives the system a useful retry boundary and makes a tenant's usage ledger explainable. If a 42-file upload has three bad recordings, the other 39 should not wait for a human to split the batch again.

Keep the claim small.

The queue is optional.

The failure boundary is not.

The most revealing test is a deliberately boring one: submit the same audio object twice, let one worker finish the remote call, pause it before the database commit, let another worker claim the lease, and then release the paused worker. That sequence contains the ordinary uncertainty of distributed work in miniature. A design that records only the final transcript cannot explain whether the two submissions were duplicates, a requested reprocess, or two tenants holding identical bytes; a design that records the tenant, content digest, profile revision, attempt, lease token, and usage-event key can answer each question without reading worker logs from last Tuesday. I keep those fields beside the job because operational archaeology is slow, and the customer asking about a bill should not need a reconstruction exercise.

There is a trade-off here. A queue-backed worker adds state, leases, and operational work that a synchronous endpoint avoids. It is a poor fit for a tiny internal tool processing one short file at a time; a direct request is easier to debug there. The queue earns its place when uploads can outlive an HTTP request, failures need replay, or tenants need separate visibility.

Here is the core state transition in Python. The same data contract can sit behind a Node.js endpoint and a queue consumer; keeping the example in one small module makes the failure behavior easier to inspect than a framework-specific sample.

from dataclasses import dataclass
from enum import Enum
from typing import Optional


class Status(str, Enum):
    QUEUED = "queued"
    RUNNING = "running"
    SUCCEEDED = "succeeded"
    FAILED = "failed"


@dataclass
class Job:
    job_id: str
    tenant_id: str
    object_key: str
    content_sha256: str
    status: Status = Status.QUEUED
    attempt: int = 0
    transcript: Optional[str] = None
    usage_event_id: Optional[str] = None


def claim(job: Job) -> bool:
    """Return True only when one worker owns the next attempt."""
    if job.status is Status.SUCCEEDED:
        return False
    if job.status is Status.RUNNING:
        return False
    job.status = Status.RUNNING
    job.attempt += 1
    return True


def complete(job: Job, transcript: str, usage_event_id: str) -> None:
    """A repeated completion writes the same result, never a new job."""
    if job.status is Status.SUCCEEDED:
        return
    job.transcript = transcript
    job.usage_event_id = usage_event_id
    job.status = Status.SUCCEEDED
Enter fullscreen mode Exit fullscreen mode

In production, claim and complete are database transactions, not in-memory mutations. The example exposes the invariants: a successful job is terminal, an attempt has an owner, and the usage event has a stable identifier. I prefer making those invariants boring before adding concurrency.

Why can a retry duplicate the result?

A worker usually has at least two external effects: the transcription request and the local write. It may receive the transcript, persist it, then crash before acknowledging the queue message. The queue redelivers the job. If completion means “insert another transcript,” the duplicate is real. If billing means “increment a counter whenever the worker runs,” the tenant's report is wrong even when the database has one visible transcript.

Use a deterministic idempotency key for the application operation. tenant_id + content_sha256 + transcription_profile is a useful starting point, provided a deliberate reprocess can include a new profile version. Enforce uniqueness in storage. A process-local set, a worker flag, or a timestamp check does not protect two workers racing on separate machines.

The transcription provider may have its own retry rules, and those rules are a separate contract. Keep the application job key in your request metadata when the API supports it, then still make your own completion write idempotent. Network timeouts do not tell you whether the remote operation finished. Treat an uncertain response as an uncertain response, not as proof of failure.

There is a small but important ordering choice. Write the transcript and an outbox usage event in one database transaction. A separate publisher can deliver that event to the cost ledger, where usage_event_id is unique. This avoids the split result in which search can see text but the tenant report cannot explain its processing. It also means a ledger consumer can retry safely.

How do tenant costs stay visible in a queue?

Cost visibility starts at enqueue time, not at the monthly report. Put tenant_id, job_id, byte size, duration when known, and the selected transcription profile on the job. Record actual provider usage when returned. If the response does not include a value you can verify, store the missing field explicitly rather than inventing a precise number.

The queue should enforce fairness as a scheduling concern. A tenant that submits 10,000 short clips can otherwise occupy every worker while a smaller tenant waits. A simple per-tenant concurrency limit and a global limit are enough to start. The limit is not a cost model; it is a way to prevent one tenant's burst from hiding everyone else's work.

I keep three quantities separate:

  • accepted work, which answers what entered the system;
  • completed work, which answers what produced usable text;
  • billable usage, which answers what the external transcription operation reported.

They diverge during retries, validation failures, and cancellations. That divergence is operational information. Collapsing them into one processed_files number makes an inexpensive dashboard and an expensive investigation.

For private knowledge bases, attach tenant authorization to every read path as well as every write path. A transcript job that is correctly tagged can still leak data if a later retrieval query filters only by document ID. The index record should carry the tenant boundary, and the retrieval service should require it rather than treating it as optional UI metadata.

What happens when the worker vanishes?

Use a lease with an expiry and an attempt count. Claiming a job records a worker token and a deadline. A worker may renew the lease while handling a long recording, but only the current token may complete it. After expiry, another worker can claim the job. The completion transaction checks that token, or checks that the job is already succeeded with the same idempotency key.

That last check handles the awkward race: worker A finishes after its lease expires, worker B has already claimed the job, and both receive a transcript. Only one completion wins. The loser rereads the job and treats an equivalent successful result as done. A database uniqueness constraint is a better guard than timing luck.

Not every error deserves a retry. An unreadable object, unsupported codec, or authorization failure should become a visible terminal failure with a reason safe for operators. A timeout, temporary upstream response, or worker process exit is a candidate for retry with bounded backoff. Keep the original error class and the latest attempt timestamp; “failed” alone does not tell anyone whether replay is sensible.

I also validate before enqueueing. Check the object exists, belongs to the authenticated tenant, has an allowed media type, and has a bounded size. This is cheaper than discovering the same fact inside a worker, and it keeps poison jobs from consuming concurrency. Validation is not a substitute for checking again in the worker because the object or authorization state may change between requests.

A practical rollout checklist

Start with a fixture set that includes one short recording, one long recording, an empty file, a deliberately unsupported format, and two identical files submitted under different job IDs. Run the same fixture through a notebook first, then through the worker. The notebook is useful for inspecting transcripts; it is not evidence that leases, redelivery, or tenant filtering work.

Test the transitions, not just the happy-path text. Kill a worker after the remote call and before acknowledgement. Deliver the same queue message twice. Let a lease expire while the first worker is still alive. Submit two files from two tenants with the same content hash. The expected result is one transcript per idempotency key, isolated retrieval, and two separately attributed usage events where the accounting contract calls for them.

Watch queue age, attempt count, lease expiry, terminal failure reason, transcript latency, and usage-event lag. Add tenant dimensions carefully: high-cardinality labels can make observability expensive. A log field or queryable event is often more useful than putting every tenant into a metrics label.

Finally, document the replay rule for operators. Replaying a failed validation job should not silently bypass validation. Reprocessing a succeeded job should require an explicit new profile or revision. That single distinction prevents a “retry all” button from turning an incident into duplicate work.

The least complex system remains the right one here: an API creates durable, tenant-owned jobs; a queue wakes workers; a lease controls ownership; a transaction records the transcript and usage event; and an idempotency key makes uncertain delivery survivable. Node.js can implement the HTTP and worker edges cleanly, but no queue library can decide which effects are safe to repeat. That decision belongs in the job contract.

Further reading

Top comments (0)