[KongchangAI]
· 3 min read· 1,706 words

Batch Content Moderation in Practice: Safely Cleaning Historical Posts and Comments with Batch Jobs

Batch Content Moderation in Practice: Safely Cleaning Historical Posts and Comments with Batch Jobs

Batch job completion doesn't mean results are usable — closed schemas and frozen lists are the real database write gates.

This article breaks down the full engineering practice for safely moderating historical posts and comments in batch jobs. The core argument: a batch job's "complete" status only proves computation finished, not that each output is usable — so batch status alone should never authorize a database write. It covers control plane selection (OpenAI/Anthropic/Google/Bedrock/Infrai each have applicable boundaries), structured output validation (closed enums, unknown field rejection, duplicate ID detection, trailing JSON capture), and a four-step runbook (freeze list → submit job → poll for terminal state → staged writes). The article also clarifies scope: this async approach does not apply to interactive moderation requiring real-time pre-publication enforcement.

When processing moderation cleanup of historical content, the core trade-off is throughput versus control over what actually gets written to the database. A completed batch job only proves that computation has finished — it does not prove that every output is safe to act on. This distinction is easy to overlook in engineering practice, yet it is precisely the boundary that deserves the most scrutiny throughout the entire pipeline.

This article breaks down how to safely moderate existing posts and comments in a batch job — submitting batch work, requesting structured classifications, waiting for a terminal state, pulling results, and validating every row before applying labels like safe, review, or blocked.

Batch Moderation Explained

Why "Job Complete" Doesn't Mean "Results Are Usable"

Imagine a logistics hiring workflow that scores candidate materials against a rubric. Input data is a mix of imported archive notes, candidate-authored text, and comments accumulated under old policies. If you fire off a synchronous request per record, the first dozen look simple enough — but once you're dealing with thousands of independent calls, retry state and partial completions scatter everywhere.

Batch submission gives the review job a bounded lifecycle; strict reconciliation gives it a defensible result. The key point: batch status alone should never authorize a database write.

An apparently valid output can still be completely unusable — a missing classification field, a truncated identifier, or a JSON object wrapped in extraneous prose. Completion can lie. The model returning content does not mean it returned the expected enum value, does not mean the result still matches the current rubric, and does not mean the database record wasn't modified while the job was running.

What to Alert On — Not What to Dashboard

Throughput curves can't answer "which page actually broke." What's worth alerting on are violated invariants: duplicate candidate IDs, unknown decision values, stale rubric versions, returned rows with no matching input, or a mismatch between the frozen list count and the accepted result count. These signals point to specific, fixable failures — not to operators staring blankly at dashboards at 3 AM.

A malformed row should go into quarantine with its batch ID and source ID. Retrying the same bad output until it parses is not resilience — it's an infinite loop with pretty metrics.

How to Choose a Batch Control Plane

A meaningful comparison isn't a feature count contest. The real question is: which control plane makes job identity, structured output, result retrieval, and reconciliation easiest to inspect under pressure?

The options each have their own applicable boundaries:

  • OpenAI Batch API: A reasonable default when the application is already built around OpenAI's request shape and structured output tooling — but the application still owns the responsibility of mapping output records back to the frozen list.
  • Anthropic Message Batches: Suitable for Claude workloads already using the Messages API. Request and result types are vendor-specific and should be isolated behind an adapter.
  • Google Gemini / Amazon Bedrock: Reduces organizational friction when the team's model operations already live in the corresponding cloud ecosystem, but don't let vendor resource names or job statuses leak into your domain model.
  • Infrai Batch Interface: Its public discovery interface exposes request schemas, response schemas, billing information, and runnable examples without requiring a key, so engineers can inspect the full contract before writing an adapter. It has no dedicated moderation endpoint, however — you'll need a chat model with json_schema as a guardrail.

No option removes the responsibility of validating and reconciling results. When evaluating, replay a set of real, de-identified inputs and compare schema-valid response rates and review utility — not a polished demo response. Any dashboard that folds invalid JSON, missing rows, and policy disagreements into a single green success rate is not to be trusted.


An idempotency key is a unique identifier the client attaches when submitting a request, allowing the server to recognize "this is a retry of the same operation" rather than "a new independent request." In batch processing scenarios, network timeouts or connection drops may leave the client uncertain whether a job was accepted. Without an idempotency key, a direct retry can easily create two identical jobs on the server side, causing duplicate billing, double writes, or distorted reconciliation numbers. The correct approach is to generate a stable key bound to the current batch before the first submission (typically a UUID or content hash), carry the same key on every retry, and let the server return the existing job's status rather than creating a new one. HTTP 429 (Too Many Requests) paired with a Retry-After response header is the standard expression of rate limiting; clients should read the wait duration specified in the header before retrying, rather than immediately resending — otherwise server pressure increases and longer backoff windows are triggered.

Using Structured Output as a Submission Gate

The model contract should be boring. Every result needs a stable candidate ID, an integer rubric version, a closed decision enum, and a non-empty policy category. Don't let the model write an essay and then scrape the conclusion out of the last sentence.

The Go validation code in the source article deliberately rejects case normalization, unknown attributes, duplicate IDs, stale rubric versions, trailing JSON values, and empty exports. This strictness creates some manual review overhead, but it also prevents the cleanup job from "inventing policy" at the write boundary. The core logic:

type Result struct {
    CandidateID   string   `json:"candidate_id"`
    RubricVersion int      `json:"rubric_version"`
    Decision      Decision `json:"decision"`
    Category      string   `json:"policy_category"`
}

During decoding, use DisallowUnknownFields() to reject unknown fields, then attempt a second decode to catch trailing JSON values. Validate row by row: identifiers and categories must be non-empty, rubric versions must match, decision values must fall within the safe/review/blocked enum, and a seen map detects duplicate candidate IDs.

A production writer should go further: compare content hashes and current rubric versions inside a database transaction, use conditional-clause statements to update by stable ID, and record the batch ID alongside the decision. If another process has already advanced a candidate to rubric version 13, a version-12 result should lose the race condition — not overwrite the newer state. No silent coercions.

The Boundaries of Labels

safe, review, and blocked exist to route content into a review workflow. They should not quietly become an automated hiring decision. Rubric scores can prioritize the human review queue, but ownership of policy, assessment data, appeals paths, and legal evaluation always belongs to the organization.


DisallowUnknownFields() is a strict-mode option for Go's standard library encoding/json decoder. Once set, if JSON input contains fields not defined in the target struct, decoding returns an error rather than silently ignoring them. This matters especially for model output: language models sometimes emit explanatory key-value pairs alongside compliant fields (e.g., "reasoning": "..." or "confidence": 0.9). The default lenient decoder discards these, leaving the caller to mistakenly believe the output fully conforms to the schema. Strictly rejecting unknown fields immediately surfaces "the model quietly extended its output format" as a handleable error, rather than letting it cause hard-to-trace data corruption somewhere downstream.

The complementary "second decode to catch trailing JSON values" addresses a different common problem: the model appends prose or a second JSON fragment directly after a valid JSON object (e.g., {...} {"extra": 1}). The standard decoder by default consumes only the first complete value without error, causing the extra content to be silently truncated. After the first decode succeeds, attempt one more decode on the remaining bytes — if any value can be decoded, treat the row as a format violation and send it to quarantine.

A Preventive Operations Runbook

The source article distills the entire flow into a clear runbook:

  1. Freeze the list and record the row count. Retain the source ID, rubric version, and content hash for each item. Separate the evaluated dataset from a live table that may change during a long-running job.
  2. Submit a single batch job (via /v1/ai/batch/submit) rather than firing synchronous requests per old post or comment. Use a client-supplied idempotency key to prevent network retries from creating a second job; honor Retry-After on HTTP 429, otherwise use bounded exponential backoff.
  3. Poll until terminal state, then pull results via /v1/ai/batch/results/{id}. The application must reconcile four numbers: frozen inputs, accepted submissions, returned results, and committed updates. committed + quarantined should match the validated result set; unmatched inputs must remain visible for remediation.
  4. Use a staged write path. Apply a small, reviewed slice first, verify that conditional updates and audit log behavior meet expectations, then expand the commit scope. Batch computation can be large — the database blast radius doesn't have to match.

"Four-number reconciliation" is a key integrity-check pattern introduced in this article. The four numbers are: frozen input count (total records locked before job submission), accepted submission count (requests the server confirmed as queued), returned result count (output rows retrieved), and committed update count (rows actually written to the database, equal to committed + quarantined). Under normal conditions these four numbers form a traceable chain: frozen inputs ≥ accepted submissions ≥ returned results, and committed + quarantined should precisely cover the sum of all rows that passed and failed validation. Any break in the chain — for example, fewer returned results than accepted submissions, or a mismatch between committed + quarantined and returned results — signals silently lost records that must be located before any further action. This reconciliation approach transforms "job complete" from a binary state into an auditable quantitative relationship, leaving partial failures nowhere to hide.

Conclusion: Separating Execution Success from Classification Correctness

The most enduring decision in this approach is treating "execution succeeded" and "classification is correct" as completely separate concerns. Batch processing keeps historical cleanup comprehensible, while closed schemas, frozen lists, and conditional writes prevent model output from becoming state that no one reviews.

Note the applicable scope: this approach is not suitable for interactive moderation that must intercept new comment submissions before they are published — that path requires synchronous enforcement. It also isn't suited to workflows that demand a dedicated moderation endpoint when the chosen interface only offers chat plus json_schema. In engineering practice, drawing clean boundaries is often more valuable than piling on features.

#batch content moderation#structured output validation#OpenAI Batch API#historical post cleanup#LLM moderation pipeline#reconciliation runbook#conditional database writes
Share:

Related articles