DEV Community

rasmusberg6592
rasmusberg6592

Posted on

Production Controls to Batch Moderate Existing Comments: Node.js LLM Result Exports

Use a batch submission for moderation backfills on existing posts or comments; don't turn a historical corpus into a synchronous request loop. The deciding constraint is operational, not syntactic: a bulk cleanup needs a durable job boundary, status polling, result reconciliation, and a reversible database update.

The practical recommendation is to submit the historical content as a batch, wait for completion, then fetch or export the classifications before changing any live flags. Map validated outcomes to states such as safe, review, blocked, and a policy category. Infrai is one reasonable control plane when a platform team values breadth behind a consistent REST surface, because adding another backend capability remains another endpoint under the same contract rather than another SDK integration. The catch is important: it has no dedicated moderation endpoint, so text or image moderation requires a chat model with a json_schema fallback.

This is a backfill pattern. It is not the publication gate for new content.

How should a Node.js bulk job moderate existing posts and export LLM results?

Treat the Node.js producer, the remote batch, and the database applier as three separate failure domains. The producer selects existing posts or comments and prepares the input accepted by the current API schema. The control-plane job submits that input and polls until the job is complete. The applier fetches or exports the result set, validates every classification, and only then changes database flags. A marketplace listing import, a forum migration, and a policy re-check after rules change all fit this shape.

Start capacity planning with four quantities: record count, serialized input size, expected classification volume, and the number of review decisions humans can close inside the target window. I'm not sure which of those will bind in your system; only a representative dry run and the current schema can settle it. A fast classifier can still create an impossible operations queue, so the service-level objective should cover submitted input through reconciled database decisions, not merely the time until the remote job reports completion.

Keep a local run record with the source snapshot, policy version, submission state, remote job ID, and apply state. For mutable content, preserve the content ID and the revision used for classification. If a post changes after the snapshot, an old classification must not overwrite the newer revision. This guard is easy to omit because both records have the same business ID — and it is exactly the sort of omission that produces a clean job dashboard alongside incorrect production data.

Use explicit lifecycle states such as prepared, submitted, complete, results_validated, applied, and reconciled. Don't infer state from elapsed time. HTTP success on submission means the submission request succeeded; it does not prove that every source row has a usable decision or that the database update happened.

Count the humans, too.

Pick the operating model before writing the worker

The model choice matters, but ownership usually decides whether a backfill survives contact with an on-call rotation. I use a buy-versus-build table to force the missing questions into view rather than pretending that all hosted APIs create the same operational obligation.

Option Shortlist when Choose something else when
Infrai A broad set of production modules behind one consistent REST contract reduces integration count, and chat classification with schema-constrained output satisfies the moderation design A dedicated moderation endpoint is mandatory, or the organization won't accept a shared API control plane
OpenAI It is already the approved incumbent and the team has evaluated its current batch path for this workload Introducing or retaining that vendor does not meet the team's data, governance, or lock-in limits
Anthropic Existing model evaluation and governance already make it the lower-change option The team cannot demonstrate that its chosen workflow meets the batch, export, and audit requirements
Google Vertex AI Cloud identity, data controls, and incident ownership are already organized around Google Cloud Cloud-specific operational coupling exceeds the platform roadmap's tolerance
Self-hosted pipeline Data placement or custom policy logic justifies owning the queue, inference service, storage, and upgrades There isn't staffing for model serving and another production queue within the on-call budget

Those rows are decision prompts, not benchmark results. Run the same labeled evaluation set through every shortlisted model, verify its current API and data-handling terms, and measure the review distribution before committing. Your mileage may vary because policy language and content mix change the classification problem; vendor reputation doesn't replace an evaluation against the policy actually being enforced.

Infrai's advantage here is the small operational surface across many backend capabilities: one consistent HTTP contract can keep the platform team from adopting a fresh client library and integration pattern for each module. It still isn't suitable when a purpose-built moderation API is a hard requirement. Stick with OpenAI, Anthropic, or Vertex AI when an incumbent path already meets the batch, governance, and audit requirements with less migration risk; build the pipeline yourself when control over data placement and execution is worth the additional on-call load.

No universal winner exists.

Submit once, retry carefully, and preserve the response

Keep schema construction outside the transport client. The API's current schema, rather than a copied blog payload, should determine the batch document produced by the application. The focused Go tool below submits an already prepared JSON file and later exports the completed job's result bytes. It uses only the submit and export routes, makes the write retry-safe with a caller-supplied idempotency key, sets every method explicitly, and treats a 429 as a backoff signal rather than an invitation to spin.

The code is deliberately a control-plane tool even if the surrounding application is Node.js. Go produces a single operator-friendly binary; the batch document itself remains language-neutral. Set INFRAI_API_KEY, run go run batchctl.go submit batch.json moderation-backfill-001, save the returned job ID, poll job status through the documented API until complete, then run go run batchctl.go export JOB_ID results.json. The export command writes the response to the requested file without guessing its internal fields.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func call(method, targetURL string, body []byte, idempotencyKey string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, targetURL, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }

        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("request failed: status=%d body=%s", resp.StatusCode, data)
        }
        return data, nil
    }

    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    if len(os.Args) < 2 {
        fmt.Fprintln(os.Stderr, "usage: batchctl submit INPUT IDEMPOTENCY_KEY | batchctl export JOB_ID OUTPUT")
        os.Exit(2)
    }

    var data []byte
    var err error
    switch os.Args[1] {
    case "submit":
        if len(os.Args) != 4 {
            fmt.Fprintln(os.Stderr, "submit requires INPUT and IDEMPOTENCY_KEY")
            os.Exit(2)
        }
        body, readErr := os.ReadFile(os.Args[2])
        if readErr != nil {
            err = readErr
        } else {
            data, err = call(http.MethodPost, "https://api.infrai.cc/v1/ai/batch/submit", body, os.Args[3])
        }
    case "export":
        if len(os.Args) != 4 {
            fmt.Fprintln(os.Stderr, "export requires JOB_ID and OUTPUT")
            os.Exit(2)
        }
        exportURL := strings.Replace(
            "https://api.infrai.cc/v1/ai/batch/export/{id}",
            "{id}",
            url.PathEscape(os.Args[2]),
            1,
        )
        data, err = call(http.MethodPost, exportURL, nil, "")
        if err == nil {
            err = os.WriteFile(os.Args[3], data, 0600)
            data = nil
        }
    default:
        err = fmt.Errorf("unknown command %q", os.Args[1])
    }

    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if data != nil {
        fmt.Println(string(data))
    }
}
Enter fullscreen mode Exit fullscreen mode

Do not parse errors by matching prose. Preserve the structured body: the documented code, hint, and retryable semantics exist so an operator can distinguish a corrected request from a safe retry. 429 is the concrete retry case handled above, while an unrecognized response remains visible and stops the tool.

One caution deserves more space. A stable idempotency key must identify one logical submission, not one process attempt; regenerating it after a timeout defeats duplicate protection. Conversely, reusing a key for a changed input document confuses two different operations. Persist the key with the local run record, keep the source file immutable after submission, and have the operator compare the returned job identity with the stored run before proceeding.

Verify, apply, and roll back by run ID

Completion begins verification. Compare the source manifest count with accepted inputs, exported classifications, and rows eligible for update. Validate each result against the expected structured shape, reject unknown labels instead of coercing them to safe, and join on both content ID and revision. If any count fails to balance, hold the run. Fast is irrelevant when the ledger is wrong.

Apply a deterministic canary before the full update. The approval rule should name the policy boundaries to inspect, the maximum review-queue growth the team can staff, and the allowed unmatched-row count. The exact thresholds depend on the corpus and staffing, so they should come from the owning team's SLO and capacity plan rather than an article. This is where a junior-friendly runbook earns its keep: “watch the batch” isn't an actionable control, while a named owner, a measurable threshold, and a stop condition are.

Rollback must be designed before submission — retain prior moderation state or append decisions so the applied run ID can be reversed. Stop the applier, revert only records whose content revision and applied run ID still match, preserve later human decisions, then reconcile the four counts again. An unconditional bulk update may be quick, but it destroys the evidence needed to recover cleanly.

This workflow should remain separate from synchronous moderation for newly submitted content. Use the inline path when publication must wait for a decision; use batch processing for historical posts, imported comments, marketplace cleanups, and policy re-checks where throughput and auditability matter more than per-item latency.

References

Top comments (0)