Support Catalog Backfill: Moderate Existing Posts and Comments in a Node.js Bulk Job A developer outlines a Node.js bulk job design for backfilling a customer-support catalog, emphasizing a durable ledger that ties each classification result and usage record to a tenant, policy version, and source item. The approach uses a bounded worker with restartable steps, checkpointing, and tenant-scoped JSONL export, while recording token usage and deriving costs from versioned rates. The developer warns against parallel API calls and highlights the importance of separating moderation and enrichment labels. Per-tenant cost visibility changes the design: don't begin with parallel API calls; begin with a durable ledger that ties every classification result and usage record to a tenant, policy version, and source item. For a customer-support catalog backfill, the practical choice is a bounded Node.js worker that reads existing posts and comments, classifies them through a replaceable adapter, checkpoints each result, and exports tenant-scoped JSONL. Short answer: make the ledger the product of the job and the LLM call one restartable step inside it. That ordering matters when support conversations contain messy product descriptions such as “the small blue charger for the old tablet.” The moderation label decides whether the text is safe to reuse; the enrichment labels connect it to a catalog candidate. Operations still need to answer a less glamorous question: which tenant consumed the tokens? Token totals belong beside decisions, not in an unrelated monthly dashboard. Record normalized input and output token counts on every completed row, then aggregate by tenantId , policyVersion , and time window. If the API reports different usage units, preserve the raw usage payload in restricted telemetry and map it explicitly; don't pretend unlike units are interchangeable. Start there. Three signals are enough for the first useful view: | Signal | Group by | Operational question | |---|---|---| | Completed items | tenant, policy version | Is the backfill moving? | | Input and output tokens | tenant, model | Where is consumption occurring? | | Review and block counts | tenant, content kind | Did the decision mix shift? | Cost in currency should be derived from a versioned rate configuration, not baked into historical rows. Store usage and the model identifier, then apply the applicable rate when producing a report. This keeps a rate change from rewriting what the runtime actually observed. It also lets finance reproduce an invoice-period view while engineering inspects tokens per catalog item. Watch cardinality. Tenant IDs are useful dimensions in logs and ledger queries, but a metrics backend can become expensive or hard to operate when every item ID becomes a label. Put itemId and the stable key in structured logs or traces. Keep metrics aggregated by bounded dimensions such as policy version, content kind, and worker outcome; whether tenant ID is acceptable as a metric label depends on tenant count and the limits of the telemetry system. I'm not sure there is one universal cutoff — measure series growth in the backend you use. One more warning: a falling token average isn't automatically good news. It may mean descriptions became cleaner. It may also mean truncation removed the evidence needed for a correct moderation decision. Pair consumption with review rate and a labeled evaluation sample. The tempting before picture is simple: query every old comment, call a classification API, then write one large results file. It works until process exit code 137 , a deployment, or a rate limit lands after 38,000 items. At that point, an output file answers neither “what was committed?” nor “what may be sent again?” The after picture has four named stages — source, ledger, classifier, export. The source yields immutable item IDs. The ledger records a terminal result for each tenantId, itemId, policyVersion key. The classifier adapter turns one chunk into normalized decisions and usage. The exporter reads committed rows rather than live API responses. Each arrow can stop independently, and every operational graph can use the same tenant dimension. Keep moderation and catalog enrichment distinct in the result even if one prompt produces both. moderationLabel: "allow" and catalogTags: "charger", "tablet" have different consumers, retention rules, and review paths. A single vague classification field makes later policy changes painful. This is the crisp mental model: requests are temporary; decisions are durable. Use a small concurrency limit, stable item keys, and an adapter that normalizes the API response. The example below expects newline-delimited JSON as input and output because an interrupted append leaves earlier lines readable. It deliberately accepts the classifier URL and model identifier through configuration; the endpoint contract and usage fields must be mapped to the API you actually operate. js import { appendFile, readFile } from "node:fs/promises"; import { createHash } from "node:crypto"; type SourceItem = { tenantId: string; itemId: string; kind: "post" | "comment"; text: string; }; type Decision = { moderationLabel: "allow" | "review" | "block"; catalogTags: string ; }; type Usage = { inputTokens: number; outputTokens: number; }; type LedgerRow = SourceItem & { key: string; policyVersion: string; decision: Decision; usage: Usage; completedAt: string; }; type ClassifierResponse = { decision: Decision; usage: Usage; }; const policyVersion = "catalog-moderation-v3"; const outputPath = "moderation-results.jsonl"; const classifierUrl = process.env.CLASSIFIER URL; const classifierModel = process.env.CLASSIFIER MODEL; if classifierUrl || classifierModel { throw new Error "CLASSIFIER URL and CLASSIFIER MODEL are required" ; } function stableKey item: SourceItem : string { return createHash "sha256" .update ${item.tenantId}\0${item.itemId}\0${policyVersion} .digest "hex" ; } async function classify item: SourceItem : Promise