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/fifoandfifo.defaulton PS-MRP, and the UniBasic source forJOB,JOB.RECURandMRP.DAILYinPBSBASE.
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
| Tier | Mechanism | Holds |
|---|---|---|
| 1 — the backbone | process.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 cascade | MRP.DAILY runs at 16:45 from tier 1 and submits ~20 one-shot deferred jobs for tonight and tomorrow morning | MRP 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
doneQueue 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 PHANTOM — udt 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/nulland returns. Nothing raises, nothing emails. With ~20 recurring entries plus ~20 submitted nightly byMRP.DAILY, headroom is real but not enormous — and a backlog eats it. -QUEis dead. Every branch that mappedAFTEC/PRODUCTION/REPORTS/COMPUTERtoqueue-2..4is commented out;CASE 1 ; QUE = "queue-1"catches everything.MRP.DAILY’s-QUE PRODUCTIONis accepted and ignored. There is one queue.- If a
-DEFERtime has already passed today,GET.DEFERclamps 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:
recur | Next run |
|---|---|
DAILY | +1 calendar day — includes weekends |
WEEKLY | +7 days |
WKDAYS | +1, then skip Saturday→Monday and Sunday→Monday |
| anything else / empty | prints 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 to | Job |
|---|---|
| today 20:00 | END.OF.SHIFT |
| today 21:04–21:11 | FIX.TRANS, FIXPOWOREQ, POXREFFIX, MRP.6999.DFLT, MRP.6810P.DFLT, BLD.MRP.LOTS, MRPSHRTS.DFLT, GROUPER, MRPBATCH.UNLOCK |
| tomorrow 00:06–00:15 | PLANORD, JOB.WIPBMF, UPDT.GROSSQTY, MRP.6799.DFLT, MRP.6710P.DFLT, JOBACT123.REV1 |
| tomorrow 08:00 | END.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(writesJOB.IP.1287),PBS1803inventory valuation,VB_JOB.COST.REV1(the SO Manager Job Cost tab),VB_UPD.WIPCOST,VB_GL7013and 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.1287should 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:
| Time | Job | Recur |
|---|---|---|
| 11:30 / 14:00 / 16:30 | J7272B | DAILY |
| 16:45 | MRP.DAILY | DAILY |
| 19:00 / 19:05 / 19:10 / 19:20 | UPD.WARRANTY, UPD.PRODUCT.LDS, UPD.MONTH.END, UPD.MSCHED | DAILY |
| 21:15 | LAST.COST.ROLLUP | DAILY |
| 22:00 / 22:15 / 23:11 | RPT.PRODUCTION, UPD.CUM.WOHRS, UPD.SHOP.HOURS | DAILY |
| next 08:15 / 08:18 / 09:00 | UPD.VACATION, J7272B ×2 | DAILY |
| Fri 21:00 | BUILD.ITEMHIST | WEEKLY |
| Sun 12:00 / 12:15 / 23:00 / 23:45 | JS.DCOLLECT, UPD.JOBBOM, ACCUM.SETUP, CHECK.WIP.SO.ALLOCS | WEEKLY |
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:
| Job | Template | Live |
|---|---|---|
LAST.COST.ROLLUP | yymmsa2115 — WEEKLY, Saturdays | 2609022115 (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
| # | Risk | Severity | Notes |
|---|---|---|---|
| 1 | MRPBATCH lock strands the nightly | High | A stale lock silently cancels END.OF.SHIFT and the whole financial close. No alert. |
| 2 | Head-of-line blocking | High | process.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. |
| 3 | Lost job on a racing append | Medium | The pop is count=grep -c → mv → tail -$count. A JOB submission landing between the count and the mv desynchronises the count and drops an extra line. Silent. |
| 4 | 99-entry cap drops silently | Medium | Print-and-return into /dev/null. |
| 5 | Catch-up storms | Medium | After 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. |
| 6 | No logging | Medium | Every invocation is >/dev/null 2>&1. Post-incident reconstruction relies on _PH_ files and each program’s own notifications. |
| 7 | Manual queue rebuild | Medium | See above. |
| 8 | Single unprotected root loop | Medium | No supervisor, no restart-on-death. If process.job dies, everything stops with no signal. |
| 9 | END.OF.SHIFT catalog drift | High | Separate 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.