Jobs, Queues, and Batch Processing

An ERP is a batch system with an interactive UI attached. Most of the value — MRP, depreciation, settlement, payroll, billing, interfaces — happens in scheduled jobs, usually at night, usually unattended.

The job landscape

Frequency Jobs
Every few minutes Interface polling, IDoc/message processing, output/print, workflow timers
Hourly ATP recalculation, replication, cache refresh
Nightly MRP net change, billing due list, dunning, ATP rebuild, index maintenance, backups, data quality checks
Weekly MRP regenerative, reorganisation, statistics, archiving
Monthly Depreciation, settlement, allocations, WIP, revenue recognition, FX revaluation, close reports, payroll
Annually Balance carryforward, number ranges, budget rollover, standard cost roll, calendar creation

The seven properties every ERP job must have

1. Idempotent

Running it twice for the same scope produces the same result as running it once.

Depreciation run for period 03/2026, run twice
  → second run posts nothing (already posted for the period)

Implement with a run registry:

job_run(
  job_name, scope_key,       -- e.g. 'DEPRECIATION' + 'CC1000|2026|03'
  status,                    -- RUNNING | COMPLETED | FAILED
  started_at, completed_at,
  records_processed, records_failed,
  parameters JSONB,
  UNIQUE(job_name, scope_key, status) WHERE status = 'COMPLETED'
)

2. Restartable

A failure at record 80,000 of 100,000 resumes from 80,001.

job_checkpoint(run_id, last_processed_key, records_done, updated_at)

Checkpoint every N records, in the same transaction as the work. Then a crash loses at most one chunk.

3. Test / simulation mode

Produce the numbers, write nothing. Non-negotiable for anything financial — the controller will run it in test mode, review, then run it for real.

4. Chunked commits

Not: one transaction for 5,000,000 records   (blocks cleanup, brutal rollback, huge locks)
Not: one transaction per record              (commit overhead dominates)
Yes: commit every 1,000–10,000 records

5. Parallelisable

Split by a natural key range — company code, plant, material range, asset number, employee number.

Depreciation:  4 parallel jobs, asset numbers 1–250k, 250k–500k, 500k–750k, 750k–1M

Ensure the partitions don’t contend on the same rows. Splitting by a key that shares a hot aggregate row just moves the contention.

6. Observable

[2026-03-01 02:14:33] DEPRECIATION_RUN started, period 03/2026, companies [1000,2000]
[2026-03-01 02:14:35] Company 1000: 84,231 assets selected
[2026-03-01 02:19:12] Company 1000: 40,000/84,231 (47%) elapsed 4m37s, ETA 5m10s
[2026-03-01 02:24:51] Company 1000: complete. 84,198 posted, 33 errors
[2026-03-01 02:24:51] Errors written to job_error, run_id 88213

A silent job that has been running for three hours at 3am is indistinguishable from a hung one. Progress output is an operational requirement, not a nicety.

7. Graceful error handling

Wrong:  one bad asset → exception → whole run rolls back → nothing posted
Right:  one bad asset → log it → continue → report "84,198 posted, 33 failed" with reasons

Then provide a reprocessing path for the failures. A run that succeeds for 99.96% and reports the rest is far more useful than an all-or-nothing run that never completes.

Job scheduling

job_definition(
  id, name, program, parameters JSONB,
  schedule_cron, calendar_id,           -- factory/business calendar aware
  depends_on[],                          -- predecessor jobs
  max_runtime_minutes, retry_count, retry_delay,
  on_failure,                            -- ALERT | CONTINUE | STOP_CHAIN
  enabled, owner, business_criticality
)

Dependencies matter more than time. “Run depreciation at 02:00” fails when the prerequisite job runs late. “Run depreciation after asset acquisition posting completes” is correct. Model the chain, not the clock.

Nightly chain:
  interface_inbound
     └─▶ goods_movement_posting
            └─▶ inventory_valuation
                   ├─▶ mrp_net_change
                   └─▶ costing_update
                          └─▶ analytics_extract

Calendar awareness: a job scheduled for “the last working day of the month” must consult the factory/business calendar, not compute month-end minus weekends.

Queues and asynchronous processing

Producer ──▶ Queue ──▶ Consumer(s)
              │
         Retry with backoff
              │
        Dead letter queue ──▶ human review

The transactional outbox

The single most important pattern for reliable ERP integration:

BEGIN;
  INSERT INTO goods_receipt (...);
  UPDATE inventory SET ...;
  INSERT INTO journal_line (...);
  INSERT INTO outbox (event_type, payload, created_at) VALUES ('GoodsReceiptPosted', {...}, now());
COMMIT;

-- separate relay process
SELECT * FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 100;
  → publish to broker
  → UPDATE outbox SET published_at = now()

Why: publishing to a broker inside the business transaction gives you two failure modes — an event published for a transaction that rolled back, or a committed transaction with no event. The outbox eliminates both. Nothing else does.

Dead letter handling

Messages that fail repeatedly must go somewhere a human looks at, with:

  • The original payload
  • The error and the stack
  • Retry count and history
  • A reprocess action after the underlying issue is fixed
  • An owner and an alert. A DLQ nobody monitors is a data-loss mechanism with extra steps.

Concurrency in batch

The classic problems:

Problem Mitigation
Batch job vs interactive users on the same rows Run in a low-usage window; chunk commits; shorter transactions
Two instances of the same job Advisory lock or a unique constraint on the run registry
Deadlock between parallel workers Process keys in a consistent order; partition so ranges don’t overlap
Long transaction blocking cleanup/vacuum Chunked commits
Batch saturating the connection pool A separate pool for batch, capped

The batch window

18:00  Users go home (mostly)
20:00  Interfaces settle
22:00  ─────── BATCH WINDOW OPENS ───────
       Backups
       Data quality checks
       MRP net change                    ← the long one
       Costing update
       Billing due list
       Dunning proposal
       Analytics extract
       Index maintenance
05:00  ─────── WINDOW CLOSES ───────
06:00  Early shift logs in

The window shrinks as the business grows and the data grows. Track every job’s duration as a time series — an MRP run creeping from 90 minutes to 4 hours over a year tells you months in advance that you have a problem.

For global companies there is no window: someone is always working. This forces online-capable batch — jobs that can run alongside users, with smaller chunks, lower priority, and throttling.

Output determination generates documents (invoices, delivery notes, purchase orders) and routes them:

Trigger (document posted)
  → determine output type, medium, and recipient from config
  → render (form template + data)
  → dispatch: printer / email / EDI / portal / e-invoicing API / archive
  → record status; retry on failure

Common failure modes: printer offline, mailbox full, e-invoicing API down, template error on an edge-case document. Every output needs a status and a resend action. “Did the customer get the invoice?” must be answerable from the system.

Monitoring and alerting

Alert on:

Condition Severity
Critical job failed Page someone
Job exceeded 2× normal duration Warn
Job did not start when scheduled Warn
Dead letter queue depth > threshold Warn
Interface error queue growing Warn
Chain broken (a predecessor failed) Page during close
Batch window overrun Warn, trending to critical
Zero records processed when records were expected Warn — silent failure is the worst kind

That last one catches the nastiest bug class: a job that “succeeds” while selecting nothing because a parameter or a date range was wrong.

Developer checklist

  • Idempotent, with a run registry
  • Restartable, with checkpoints
  • Test/simulation mode
  • Chunked commits (1k–10k)
  • Parallelisable by a key range
  • Progress logging with counts and ETA
  • Per-record error handling, not all-or-nothing
  • Failed records reprocessable
  • Posting date is a parameter, defaulted, never hardcoded
  • Handles closed periods as a business error
  • Selection is bounded — no unbounded full-table scans
  • Tested at production volume
  • Runtime tracked as a metric over time
  • Alerting configured, including the zero-records case
  • Runbook: what to do when it fails at 3am

Next