Series MapLesson 93 / 112
Focus mode active/Press Alt+Shift+R to toggle/Esc to exit
Final StretchOrdered learning track

Batch and Scheduled Jobs

Batch Scheduler CronJob and Reconciliation Jobs

Production model for scheduled jobs, Kubernetes CronJob, batch processing, retry jobs, cleanup jobs, archival jobs, reconciliation jobs, idempotent execution, locking, observability, and failed job recovery

9 min read1670 words
PrevNext
Lesson 93112 lesson track93–112 Final Stretch
#batch#scheduler#cronjob#reconciliation+4 more

Part 093 — Batch, Scheduler, CronJob, and Reconciliation Jobs

Fokus part ini: memahami job processing sebagai bagian dari production backend system. Targetnya bukan sekadar tahu cara membuat scheduler, tetapi mampu mendesain job yang idempotent, recoverable, observable, tidak mengganggu request path, dan aman terhadap duplicate execution, partial failure, stuck lock, data drift, dan rollback/roll-forward scenario.

Catatan penting:

This part does not assume CSG Quote & Order uses Kubernetes CronJob, Quartz,
Spring Scheduler, platform scheduler, DB scheduler, internal workflow engine,
Redis locking, PostgreSQL advisory lock, or a specific batch framework.

Treat all scheduler, locking, reconciliation, and batch execution details as
internal verification items.

A senior engineer should treat jobs as first-class production workloads.

They are not “background scripts”.

They mutate state, repair state, synchronize state, delete state, publish events, and often execute outside the normal HTTP request lifecycle.

That makes them dangerous when they are not designed with clear ownership and failure behavior.


1. Why Batch and Scheduled Jobs Exist

HTTP endpoints are good for request-response interactions.

But enterprise systems also need work that is not naturally driven by a user request.

Common examples:

expire stale quotes
recalculate pricing snapshots
reconcile order status with downstream system
retry failed external delivery
cleanup temporary files
archive old audit records
publish delayed notifications
repair derived read models
refresh catalog cache
close abandoned checkout/order flows

A job exists when work needs to happen:

at a scheduled time
periodically
as a delayed retry
in bulk
as a repair action
as a consistency check
outside a user request

The wrong mental model:

"Just run a script every night."

The production mental model:

A job is a distributed state transition mechanism with its own trigger,
lease, work selection, idempotency, progress tracking, observability,
retry, and recovery model.

2. Job Taxonomy

Different jobs need different controls.

Job TypePurposeTypical TriggerMain Risk
Scheduled jobperiodic business operationcron/timeduplicate execution, missed run
Retry jobretry failed workschedule/backoffretry storm, poison item
Cleanup jobremove temporary/expired dataperiodicdeleting valid data
Archival jobmove old data to cheaper storageperiodic/batchretention/compliance violation
Reconciliation jobcompare and repair inconsistent stateperiodic/manualincorrect repair
Backfill jobapply new derived field/state to old dataone-off/controlledproduction overload
Migration jobtransform data during releaserelease-controlledincompatible deployment
Notification jobdeliver pending notificationsqueue/scheduleduplicate delivery
Read-model rebuild jobrebuild projection/search index/cachemanual/scheduledstale/partial view

A single “scheduler” abstraction should not hide these differences.

A retry job and a cleanup job have different blast radius.

A reconciliation job and a backfill job need different approval processes.


3. Scheduler Is Not the Job

Separate the trigger from the execution.

flowchart LR Trigger[Scheduler / Cron / Manual Trigger] --> Launcher[Job Launcher] Launcher --> Lease[Acquire Lease / Lock] Lease --> Select[Select Work] Select --> Process[Process Batch] Process --> Persist[Persist Progress] Persist --> Emit[Emit Metrics / Logs / Audit] Emit --> Release[Release Lease]

The scheduler answers:

When should the job start?

The job answers:

What work should be processed?
How much work should be processed?
How is progress tracked?
What happens if the job dies?
What happens if another copy starts?
What is safe to retry?

Do not put correctness inside the scheduler alone.

A scheduler can fire twice.

A pod can restart mid-run.

A CronJob can overlap if previous execution is slow.

A manual operator can trigger a job while a scheduled run is active.

Correctness belongs in the job execution model.


4. Kubernetes CronJob Mental Model

A Kubernetes CronJob creates Jobs based on a schedule.

A Kubernetes Job creates Pods that run to completion.

That means the runtime chain is:

CronJob schedule
  -> Job object
    -> Pod
      -> container process
        -> Java main/job runner
          -> business job logic

Example skeleton:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: quote-expiration-reconciliation
spec:
  schedule: "*/15 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 1
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: job
              image: example-registry/quote-service:1.2.3
              args:
                - "run-job"
                - "quote-expiration-reconciliation"
              envFrom:
                - configMapRef:
                    name: quote-service-config
                - secretRef:
                    name: quote-service-secret

Important fields:

FieldWhy It Matters
scheduletrigger cadence
concurrencyPolicyoverlap behavior
backoffLimitKubernetes-level retry count
restartPolicypod restart behavior
history limitsoperational visibility vs object clutter
resource limitsavoid starving online services

But Kubernetes settings are not enough.

Even with concurrencyPolicy: Forbid, you still need application-level idempotency because:

cluster failover can produce edge cases
manual run can overlap
another environment can target same DB
job can partially commit then die
operator can retry a failed Job

5. In-Process Scheduler vs External Job Workload

Some systems run scheduled work inside the main service process.

Others run jobs as separate workloads.

ModelExampleAdvantagesRisks
In-process schedulertimer inside API servicesimple deploymentevery replica may run job unless guarded
Dedicated job processseparate Java mainisolation, clear resourcesmore deployment artifacts
Kubernetes CronJobscheduled podplatform-native schedulingcold start, missed schedule, overlap risk
Workflow engineCamunda/Zeebe/etc.long-running orchestrationengine complexity
External schedulerenterprise schedulercentral controldependency on platform integration

For production enterprise systems, prefer explicit job workloads when the job:

uses heavy CPU/DB resources
runs long
has a different scaling model
needs different permissions
must be triggered manually by operators
has high blast radius

In-process scheduling is acceptable only when:

execution is guarded by a robust lease
work is lightweight
failure is observable
all replicas coordinate correctly
shutdown is graceful

6. Idempotent Job Design

A job is idempotent when re-running it does not corrupt state.

This does not mean “no side effect”.

It means the side effect is safely repeatable.

Example: quote expiration job.

Unsafe design:

select quotes where expires_at < now
for each quote:
  set status = EXPIRED
  publish QuoteExpired event

Failure:

DB update succeeds
process dies before event publish
rerun publishes duplicate or misses event depending implementation

Safer design:

select eligible quotes where status = ACTIVE and expires_at < now
for each quote:
  transition ACTIVE -> EXPIRED conditionally
  insert outbox event with unique business key
  commit transaction
publisher later emits event from outbox

Pseudo-code:

public final class QuoteExpirationJob {
    public JobResult run(JobContext context) {
        JobLease lease = leaseService.acquire("quote-expiration", Duration.ofMinutes(10));
        if (!lease.acquired()) {
            return JobResult.skipped("lease-not-acquired");
        }

        try {
            int processed = 0;

            while (processed < context.maxItems()) {
                List<QuoteId> batch = quoteRepository.findExpirableQuotes(context.batchSize());
                if (batch.isEmpty()) break;

                for (QuoteId quoteId : batch) {
                    quoteService.expireIfStillActive(quoteId, context.clock());
                    processed++;
                }
            }

            return JobResult.completed(processed);
        } finally {
            lease.release();
        }
    }
}

The critical method is not expire.

It is expireIfStillActive.

The condition protects the state transition.


7. Work Selection Pattern

A batch job should select work in bounded chunks.

Avoid:

SELECT * FROM quote WHERE status = 'ACTIVE' AND expires_at < now();

Better:

SELECT id
FROM quote
WHERE status = 'ACTIVE'
  AND expires_at < now()
ORDER BY expires_at, id
LIMIT :batch_size;

For concurrent workers, you may need locking semantics.

Example PostgreSQL pattern:

SELECT id
FROM pending_job_item
WHERE status = 'READY'
  AND available_at <= now()
ORDER BY available_at, id
LIMIT :batch_size
FOR UPDATE SKIP LOCKED;

This lets multiple workers take different rows.

But it introduces review questions:

Is ordering still important?
Can work be processed out of order?
What happens if a transaction holds locks too long?
How is a stuck item recovered?

8. Job Locking and Lease Model

A lock prevents multiple job runners from executing the same exclusive job.

A lease is a lock with expiration.

Do not use a permanent lock without recovery.

Common approaches:

Lock TypeGood ForRisk
DB row lockshort transactional worktransaction too long
DB advisory locksingle database-scoped leaseconnection/session semantics must be understood
Redis lockdistributed leasestale lock, expiry race, split brain without fencing
Kubernetes concurrency policypreventing CronJob overlapnot enough for cross-cluster/app-level correctness
Job table leaseauditable app-level controlmust handle clock and expiry carefully

A robust job lease should include:

job_name
owner_id
lease_token
acquired_at
expires_at
heartbeat_at
fencing_token if external side effects require it

Example table:

CREATE TABLE job_lease (
    job_name        text PRIMARY KEY,
    owner_id        text NOT NULL,
    lease_token     text NOT NULL,
    fencing_token   bigint NOT NULL,
    acquired_at     timestamptz NOT NULL,
    heartbeat_at    timestamptz NOT NULL,
    expires_at      timestamptz NOT NULL
);

Lease acquisition must be conditional.

UPDATE job_lease
SET owner_id = :owner_id,
    lease_token = :lease_token,
    fencing_token = fencing_token + 1,
    acquired_at = now(),
    heartbeat_at = now(),
    expires_at = now() + interval '10 minutes'
WHERE job_name = :job_name
  AND expires_at < now();

If multiple runners race, only one should acquire.


9. Checkpointing and Progress Tracking

Long jobs should not rely only on process memory.

If a job dies, the next run needs to know what happened.

Patterns:

status per item
checkpoint cursor
watermark timestamp
run record
outbox/inbox dedupe record
business-state conditional update

A job run table is useful for audit and debugging.

CREATE TABLE job_run (
    id              uuid PRIMARY KEY,
    job_name        text NOT NULL,
    trigger_type    text NOT NULL,
    started_at      timestamptz NOT NULL,
    finished_at     timestamptz,
    status          text NOT NULL,
    owner_id        text NOT NULL,
    processed_count bigint NOT NULL DEFAULT 0,
    failed_count    bigint NOT NULL DEFAULT 0,
    last_error_code text,
    last_error_text text
);

Do not rely only on logs.

Logs help investigation.

A job run table helps operational control.


10. Retry Job Design

Retry is not just “try again later”.

A retry job needs:

attempt count
next_attempt_at
last_error_code
last_error_message
retry classification
dead-letter/final failure state
manual repair option

Example item table:

CREATE TABLE delivery_attempt_item (
    id              uuid PRIMARY KEY,
    business_key    text NOT NULL,
    status          text NOT NULL,
    attempt_count   int NOT NULL DEFAULT 0,
    next_attempt_at timestamptz NOT NULL,
    last_error_code text,
    last_error_at   timestamptz,
    created_at      timestamptz NOT NULL,
    updated_at      timestamptz NOT NULL,
    UNIQUE (business_key)
);

Retry classification:

ErrorRetry?Example
timeoutyes, boundeddownstream slow
429yes, with backoffrate limit
503yes, with backofftemporary outage
400 validationnoinvalid payload
401/403usually no until config fixedauth/config issue
duplicate business keyno or idempotent successalready processed

A retry job without error classification becomes a retry storm generator.


11. Cleanup and Archival Jobs

Cleanup jobs are deceptively dangerous.

They delete or move data.

A cleanup job must define:

what data is eligible
what retention rule applies
whether data has legal/compliance value
whether data can be restored
how deletion is audited
how dry-run works

Dangerous cleanup:

DELETE FROM temp_file WHERE created_at < now() - interval '1 day';

Safer cleanup:

SELECT id
FROM temp_file
WHERE created_at < now() - interval '7 days'
  AND status IN ('COMPLETED', 'FAILED_TERMINAL')
  AND legal_hold = false
ORDER BY created_at, id
LIMIT :batch_size;

Use dry-run mode for high-risk jobs.

mode=dry-run
  -> select eligible records
  -> log and metric expected deletion count
  -> do not mutate

mode=execute
  -> mutate in small chunks
  -> record job_run
  -> emit audit summary

12. Reconciliation Job Mental Model

A reconciliation job detects and repairs divergence.

Divergence can happen between:

API database and Kafka events
source table and read model
quote/order state and workflow engine
internal DB and external downstream system
object storage and metadata table
cache and source of truth
billing state and order state

Reconciliation requires three things:

source of truth
comparison rule
repair action

Without a source of truth, reconciliation becomes guessing.

flowchart LR SOT[Source of Truth] --> Compare[Compare Rule] Projection[Derived View / External State] --> Compare Compare -->|match| OK[No Action] Compare -->|mismatch| Classify[Classify Drift] Classify --> Repair[Repair / Requeue / Alert] Repair --> Audit[Audit Result]

Example reconciliation outcomes:

DriftAction
DB says order submitted, event not publishedinsert missing outbox event
event published, read model missingreplay event to projection
external system has unknown statusmark for manual review
stale cacheinvalidate/rebuild
duplicate deliverymark duplicate and suppress side effect

Some mismatches should not be auto-repaired.

For regulated or high-financial-impact flows, repair may require manual approval.


13. Backfill Jobs

Backfill jobs apply a new rule or field to existing data.

They are often one-off, but their risk is high.

Backfill checklist:

Can it be run multiple times?
Can it be paused?
Can it resume from checkpoint?
Can it be throttled?
Can it run while old and new application versions coexist?
Can it be rolled forward if rollback is impossible?
Does it emit progress metrics?
Does it have a dry-run estimate?

Backfill should usually process in pages:

select next N records by stable cursor
process transactionally
record checkpoint
sleep/throttle if needed
repeat

Avoid offset pagination for backfill over changing data.

Prefer stable cursor:

SELECT id
FROM quote
WHERE id > :last_seen_id
ORDER BY id
LIMIT :batch_size;

Or use created timestamp + id as composite cursor.


14. Job Observability

A production job needs metrics.

Minimum useful metrics:

job_started_total
job_completed_total
job_failed_total
job_skipped_total
job_duration_seconds
job_items_processed_total
job_items_failed_total
job_lag_seconds
job_last_success_timestamp
job_lock_acquire_failed_total
job_retry_scheduled_total
job_deadletter_total

Useful labels:

job_name
status
error_class

Dangerous labels:

tenant_id with high cardinality
quote_id
order_id
customer_id
raw error message

Logs should include:

job_run_id
job_name
trigger_type
owner_id
batch_number
processed_count
failed_count
correlation_id if manually triggered

A dashboard should answer:

Did the job run?
Did it finish?
How long did it take?
How many items did it process?
Is backlog growing?
Are failures concentrated by type?
When was the last successful run?

15. Job Failure Modes

Failure ModeSymptomLikely CauseDetectionMitigation
missed runno job activityscheduler disabled, cluster issuelast success metricalert on stale success
duplicate runsame work processed twiceoverlap/manual rerunduplicate side effectsidempotency, lease
stuck lockjob skipped forevercrashed owner, no expirylock age alertlease expiry, heartbeat
long-running transactionDB contentionlarge batch in one transactionlock wait, slow querysmaller batches
retry stormdownstream overloadaggressive retryspike in attemptsbackoff, retry budget
poison itemsame item fails repeatedlybad data/non-retryable errorrepeated failure per itemDLQ/manual repair
partial progress invisibleoperator cannot tell stateno job_run/checkpointmissing run recordspersistent progress
over-deletionvalid data removedweak cleanup predicateaudit/customer issuedry-run, review, retention guard
reconciliation false repairstate made worsewrong source of truthbusiness incidentclassify/manual review
no tenant isolationcross-tenant mutationmissing tenant filteraudit/log anomalytenant-aware queries

16. Manual Trigger and Operator Safety

Some jobs need manual execution.

Manual execution must not bypass safeguards.

A manual job trigger should require:

job name
mode: dry-run or execute
scope: tenant/environment/range
reason
requester identity
approval if high risk
correlation ID
max items / throttle

Example command shape:

java -jar quote-service-jobs.jar \
  run quote-expiration-reconciliation \
  --mode=dry-run \
  --tenant=tenant-a \
  --max-items=1000 \
  --reason="verify post-migration quote state"

The job should emit an audit summary.

job_run_id=...
requester=...
mode=dry-run
scope=tenant-a
eligible_count=842
mutated_count=0

Manual repair without audit is an incident waiting to happen.


17. JAX-RS Boundary for Jobs

A JAX-RS API can trigger or inspect jobs, but the endpoint should not become a hidden long-running worker.

Good API boundaries:

POST /internal/jobs/{jobName}/runs
GET  /internal/jobs/{jobName}/runs/{runId}
POST /internal/jobs/{jobName}/runs/{runId}/cancel
GET  /internal/jobs/{jobName}/status

Bad boundary:

POST /expire-all-quotes

that blocks the request for 30 minutes.

If a JAX-RS endpoint triggers a job:

validate request
authorize operator/system identity
create job_run record
enqueue or signal execution
return 202 Accepted with run ID
job executes out-of-band
client polls or receives event

Example response:

HTTP/1.1 202 Accepted
Location: /internal/jobs/quote-expiration/runs/5ad0...
Content-Type: application/json

{
  "jobRunId": "5ad0...",
  "status": "ACCEPTED",
  "mode": "DRY_RUN"
}

18. Internal Verification Checklist

Verify these in the internal CSG codebase/platform before making design assumptions:

[ ] What scheduler is used: Kubernetes CronJob, Quartz, platform scheduler, workflow engine, manual job runner, or in-process scheduler?
[ ] Are jobs deployed as separate workloads or inside the API service?
[ ] Is there a standard job_run table or operational tracking model?
[ ] Is there a platform standard for job locking/lease?
[ ] Are PostgreSQL advisory locks, Redis locks, or DB row leases used?
[ ] Is there a standard retry/DLQ model for job items?
[ ] Are job metrics standardized?
[ ] Are job alerts configured for stale success, failure rate, backlog, and duration?
[ ] Is manual job triggering allowed? Through what tool/API?
[ ] Are manual job runs audited?
[ ] Are jobs tenant-aware?
[ ] Are cleanup/archive jobs reviewed for retention/compliance?
[ ] Are reconciliation jobs allowed to auto-repair or only report?
[ ] Are backfill jobs reviewed as part of release plan?
[ ] Are job pods given separate CPU/memory limits from online API services?
[ ] Are job secrets/permissions more limited than API service permissions?
[ ] Are failed jobs recoverable without direct DB surgery?

19. PR Review Checklist

When reviewing job-related changes, ask:

[ ] What is the trigger?
[ ] What is the unit of work?
[ ] Is processing idempotent?
[ ] Can the job safely run twice?
[ ] What prevents overlap?
[ ] What happens if the process dies mid-batch?
[ ] Is progress persisted?
[ ] Is work selected in bounded chunks?
[ ] Is there a dry-run mode for dangerous jobs?
[ ] Is the job tenant-aware?
[ ] Are transactions small enough?
[ ] Are external calls retried with budget/backoff?
[ ] Are poison items isolated?
[ ] Is there a terminal failure state?
[ ] Are metrics and logs sufficient?
[ ] Is there an alert if the job stops running?
[ ] Is manual operation documented?
[ ] Does the release plan account for this job?

20. Senior Engineer Mental Model

A job is production code with a different trigger.

It still needs:

ownership
contract
idempotency
observability
security
tenant isolation
resource limits
recovery model
change management

For enterprise quote/order systems, scheduled and reconciliation jobs often protect business correctness.

They repair drift, expire state, retry integrations, and enforce lifecycle transitions.

That also means they can create severe incidents if they are weakly designed.

A senior engineer does not ask only:

Does the cron expression work?

A senior engineer asks:

Can this job run twice, fail halfway, recover safely, prove what it did,
and avoid damaging customer/business state?
Lesson Recap

You just completed lesson 93 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.