The AFTEC Batch Scheduler

There is no cron in AFTEC. Unattended work runs through a hand-rolled two-tier scheduler: a shell loop (process.job) draining a plain-text queue (/home/batch/fifo), plus a daily program (MRP.DAILY) that submits the evening’s one-shot jobs into that same queue.

Verified 2026-09-02 against process.job, the live /home/batch/fifo and fifo.default on PS-MRP, and the UniBasic source for JOB, JOB.RECUR and MRP.DAILY in PBSBASE.

Companion pages: AFTEC Job Costing & WIP (what END.OF.SHIFT actually does once it starts), Reading AFTEC Source (the catalog-drift hazard — it applies to END.OF.SHIFT).


The two tiers

TierMechanismHolds
1 — the backboneprocess.job shell loop draining /home/batch/fifo; entries re-queue themselves via JOB.RECUR~20 recurring jobs: J7272B, MRP.DAILY, the UPD.* family, LAST.COST.ROLLUP, weekly maintenance
2 — the evening cascadeMRP.DAILY runs at 16:45 from tier 1 and submits ~20 one-shot deferred jobs for tonight and tomorrow morningMRP regeneration chain and END.OF.SHIFT

The critical consequence: END.OF.SHIFT is not in the recurring queue. It is submitted fresh every afternoon by MRP.DAILY. If MRP.DAILY doesn’t complete, the entire nightly financial close does not run that night — see the lock.


Tier 1 — process.job

A /bin/sh loop running as root on PS-MRP. Full logic:

while [ 1 ]                       # curtime/extime reset each outer pass
do
  while [ $extime -le $curtime ]
  do
    echo "lock" >/tmp/udlock
    if [ ! -f /tmp/unixlock -a -s /home/batch/fifo ]
    then
      line=`head -1 /home/batch/fifo`        # ← ONLY ever the head
      extime=`echo $line | awk '{print $1}'`
      process=`echo $line | awk '{print $2}'`
      account=`echo $line | awk '{print $3}'`
      user=`echo $line   | awk '{print $4}'`
      recurr=`echo $line | awk '{print $5}'`
      curtime=`date '+%y%m%d%H%M'`
      if [ $extime -le $curtime ]
      then
        rm /tmp/udlock ; cd $account
        /usr/ud/bin/udt PHANTOM $process >/dev/null 2>&1     # or su $user -c ...
        ...pop the head line...
        if [ $recurr ]
        then  /usr/ud/bin/udt PHANTOM "JOB.RECUR $line" >/dev/null 2>&1 &
        fi
      fi
    else
      extime=9999999999999
    fi
    rm /tmp/udlock
  done
  sleep 60
done

Queue line format — five space-delimited fields:

2609021645 MRP.DAILY /home/pro3 root DAILY
└─extime──┘ └─process┘ └─account─┘ └who┘ └recur┘

extime is YYMMDDHHMM, compared numerically against date '+%y%m%d%H%M'. Minute resolution; the loop polls every 60 s.

Locks. /tmp/udlock is written by the scheduler as an advisory “batch is active” flag for other processes. /tmp/unixlock is the inbound gate — while it exists the scheduler processes nothing. Neither is the same as the UniData LOCKFILE records that individual programs use.

Execution is a PHANTOMudt PHANTOM <process> in the $account directory, as root directly or via su $user -c. Every invocation is >/dev/null 2>&1, so the scheduler itself logs nothing; the only trail is UniData’s own _PH_ phantom output.


Tier 1 submission — the JOB verb

PBSBASE/JOB is what writes the queue. Usage (from its own help text):

JOB command -DEFER MM/DD/YY.HH:MM
JOB command -DEFER HH:MM
JOB command -DEFER MM/DD/YY.HH:MM -RECUR DAILY,root

It builds the line as YYMMDDHHMM cmd @PATH who [recur] — note the account comes from @PATH, which is why JOB.RECUR can drop it when re-submitting.

It keeps the queue time-sorted by ordered insertion (SUBMIT: label):

LOOP
  TTIME = FIELD(REC<XX>," ",1)
  IF TTIME > TIME OR TTIME = "" THEN
    INS BATCMD BEFORE REC<XX>
    WRITE REC ON FIFODIR,"fifo" ; RETURN
  END
  IF XX > 99 THEN
    PRINT "QUE LIMIT HAS BEEN REACHED. JOB CAN'T BE SUBMITTED." ; RETURN
  END
  XX += 1
REPEAT

Two things worth knowing:

  • The 99-entry cap silently drops the job. It prints to a phantom’s /dev/null and returns. Nothing raises, nothing emails. With ~20 recurring entries plus ~20 submitted nightly by MRP.DAILY, headroom is real but not enormous — and a backlog eats it.
  • -QUE is dead. Every branch that mapped AFTEC/PRODUCTION/REPORTS/COMPUTER to queue-2..4 is commented out; CASE 1 ; QUE = "queue-1" catches everything. MRP.DAILY’s -QUE PRODUCTION is accepted and ignored. There is one queue.
  • If a -DEFER time has already passed today, GET.DEFER clamps it to now — the job runs on the next pass rather than waiting a year.

Tier 1 recurrence — JOB.RECUR

Fired in the background after a recurring job completes. It parses the line it was handed and re-submits:

recurNext run
DAILY+1 calendar day — includes weekends
WEEKLY+7 days
WKDAYS+1, then skip Saturday→Monday and Sunday→Monday
anything else / emptyprints an error and stops — the job is not re-queued

The next time is computed from the scheduled time, not the actual run time, so a slow run does not cause schedule drift.


Tier 2 — MRP.DAILY is the real nightly scheduler

PBSBASE/MRP.DAILY (JPT, 2015, maintained through 2025-08-05) runs at 16:45 from the FIFO. Despite the name it does far more than MRP — after backfilling JOB.NO onto PROJECT.1287, it submits the entire evening and overnight cascade:

Deferred toJob
today 20:00END.OF.SHIFT
today 21:04–21:11FIX.TRANS, FIXPOWOREQ, POXREFFIX, MRP.6999.DFLT, MRP.6810P.DFLT, BLD.MRP.LOTS, MRPSHRTS.DFLT, GROUPER, MRPBATCH.UNLOCK
tomorrow 00:06–00:15PLANORD, JOB.WIPBMF, UPDT.GROSSQTY, MRP.6799.DFLT, MRP.6710P.DFLT, JOBACT123.REV1
tomorrow 08:00END.OF.SHIFT

So END.OF.SHIFT runs twice per cycle — 20:00 and 08:00. This matters because END.OF.SHIFT gates its financial block on TIME() > 68400 (19:00):

  • The 20:00 run passes the gate → the full evening block, including UPD.JOB.INPROCESS (writes JOB.IP.1287), PBS1803 inventory valuation, VB_JOB.COST.REV1 (the SO Manager Job Cost tab), VB_UPD.WIPCOST, VB_GL7013 and the closing trial balance.
  • The 08:00 run fails the gate → only the every-pass steps (PO receipts, outside ops, timesheets, WIP receipts, WIP close, weld continuity).

The commented-out lines are their own history: a 03:00 END.OF.SHIFT, Sunday-specific scheduling, and J7272B at 00:05 have all been disabled at various points. JPT 09/05/22: CHANGE TO RUN 7 DAYS/WEEK.

The single point of failure

MRP.DAILY guards itself with a UniData LOCKFILE record and exits silently if the lock is held:

READU LOCKREC FROM LOCKFILE,PGM THEN
  RETURN                              ← PGM = "MRPBATCH"
END ELSE
  WRITE "AUTO" ON LOCKFILE,PGM
END

The lock is released by MRPBATCH.UNLOCK, deferred to 21:11 by the same run that took it.

If that unlock does not happen — MRP hangs, the box reboots mid-cascade, a job aborts — the MRPBATCH lock persists, and the next afternoon MRP.DAILY hits the RETURN. It submits nothing. END.OF.SHIFT is never scheduled, and the whole nightly financial close silently does not run: no JOB.IP.1287 row for that date, no G/L update, no WIPCOST.1287 snapshot, no cost-differ exception report.

The failure is silent in three separate ways: the RETURN is bare (no message, no email), the phantom’s output goes to /dev/null, and END.OF.SHIFT’s own start/finish notifications to JPT,AMD never fire because it never starts. The visible symptom is downstream and delayed — SO Manager’s Job Cost tab defaults its Run Date to yesterday and renders an empty grid when no snapshot exists for the date requested.

Operational check. JOB.IP.1287 should gain a row per open job every single day, weekends included. A gap in that file’s dates is the cheapest reliable detector that the cascade didn’t run. There is currently no alert on it.


The live queue

/home/batch/fifo on 2026-09-02:

TimeJobRecur
11:30 / 14:00 / 16:30J7272BDAILY
16:45MRP.DAILYDAILY
19:00 / 19:05 / 19:10 / 19:20UPD.WARRANTY, UPD.PRODUCT.LDS, UPD.MONTH.END, UPD.MSCHEDDAILY
21:15LAST.COST.ROLLUPDAILY
22:00 / 22:15 / 23:11RPT.PRODUCTION, UPD.CUM.WOHRS, UPD.SHOP.HOURSDAILY
next 08:15 / 08:18 / 09:00UPD.VACATION, J7272B ×2DAILY
Fri 21:00BUILD.ITEMHISTWEEKLY
Sun 12:00 / 12:15 / 23:00 / 23:45JS.DCOLLECT, UPD.JOBBOM, ACCUM.SETUP, CHECK.WIP.SO.ALLOCSWEEKLY

Everything runs as root in /home/pro3.

Drift from the template

/home/batch/fifo.default is the hand-maintained rebuild template. Comparing it to the live queue, one entry has changed cadence:

JobTemplateLive
LAST.COST.ROLLUPyymmsa2115WEEKLY, Saturdays2609022115 (a Wednesday) — DAILY

The last-cost rollup is the CA1771/ITEMCA LLC-descending replacement-cost cascade. It now runs seven times as often as the template documents. Whether that was deliberate is worth confirming with JPT — and either way fifo.default should be corrected, because it is what the queue gets rebuilt from.

Rebuilding the queue is a manual, error-prone procedure

fifo.default opens with:

***delete these lines before saving to fifo***
***replace yymmdd with current date***
***replace yymmde with tomorrow date***
***replace fr, sa, and su with current/next Fri, Sat, Sun date***
***if today is fri, sat, or sun - adjust list sequentially***

Recovery is hand-editing date arithmetic into 19 lines under time pressure. The /home/batch directory carries the scars: fifo.bad, fifo032725.bad, and dated snapshots going back to fifo20131024.


Risk register

#RiskSeverityNotes
1MRPBATCH lock strands the nightlyHighA stale lock silently cancels END.OF.SHIFT and the whole financial close. No alert.
2Head-of-line blockingHighprocess.job only ever inspects head -1. A single future-dated entry at the head stalls everything behind it, however overdue. The JOB verb’s sorted insert normally prevents this — but a hand-edited FIFO out of order will wedge the queue.
3Lost job on a racing appendMediumThe pop is count=grep -cmvtail -$count. A JOB submission landing between the count and the mv desynchronises the count and drops an extra line. Silent.
499-entry cap drops silentlyMediumPrint-and-return into /dev/null.
5Catch-up stormsMediumAfter an outage, backlogged DAILY entries re-queue at scheduled+1, still in the past, so every missed occurrence fires back-to-back. Fine for idempotent reports, not for accumulating updates.
6No loggingMediumEvery invocation is >/dev/null 2>&1. Post-incident reconstruction relies on _PH_ files and each program’s own notifications.
7Manual queue rebuildMediumSee above.
8Single unprotected root loopMediumNo supervisor, no restart-on-death. If process.job dies, everything stops with no signal.
9END.OF.SHIFT catalog driftHighSeparate but compounding: the cataloged object (2024-09-11) predates the PBSBASE source (2025-02-24), which carries an undeployed bare STOP that would strand steps 3–15. Recompiling and cataloging from current source would break the nightly. See Job Costing.

What this means for the Business Central migration

The nightly is not documented anywhere in the ERP. It is a shell loop, a text file, one UniBasic verb and a program named after a different subsystem. Any migration inventory built from AFTEC menus or program catalogs will miss it entirely — and will therefore miss that END.OF.SHIFT is scheduled by MRP.DAILY.

The scheduling dependency graph is implicit and undocumented. MRP.DAILY hard-codes ~20 successors at fixed clock offsets (21:04, 21:05, 21:05:30 …) with no dependency checking — ordering is by wall clock and nothing verifies a predecessor succeeded. A replacement needs real dependencies, not a timetable.

Silent failure is the default everywhere in this design — the bare RETURN, /dev/null, the 99-cap print, the unguarded loop. Whatever replaces it must fail loudly, and the daily job-cost control needs a freshness alert regardless of which scheduler runs it.

Cheapest immediate win, independent of BC: alert when JOB.IP.1287 has no rows for yesterday. That one check covers risks 1, 2, 5 and 8 at once.