Skip to content
How Work Actually Runs

What actually happens when I press Finalise? How work actually runs

“What actually happens when I press Finalise?” — the screen only hands the work to BigLedger’s job processors and comes straight back; over the next few seconds they write the journal, move the stock, recalculate the item’s moving-average cost and queue whatever comes next.

You press Finalise on a cash bill and the screen comes back almost at once. Nothing looks different. Yet within the next few seconds a journal is written, stock leaves a location, the item’s moving-average cost is recalculated, a knock-off queue opens for the delivery order that comes next, and an e-invoice row appears in a pool.

None of that is on your screen, and none of it happened while you were waiting. This page is the part underneath: the job processors, which are how almost everything in BigLedger that is not a keystroke gets done. Read it once and a dozen otherwise baffling behaviours stop being baffling — why a document changes after you saved it, why an error arrives ten minutes late and lands on nobody, why a colleague’s tenant does something yours does not, and why “it hasn’t posted yet” and “it failed and is not going to post” look identical on the screen.

Twenty minutes. Nothing to configure; this is orientation, not a procedure.

A processor is not one thing — it plays four roles

Vincent’s own description, and the reason this page exists rather than a list of which processors to switch on:

“A processor is used in a variety of ways in our platform. They can act as queue / publish-subscribe / actors / schedulers… There’s primary job processors that can trigger secondary job processors, it can run synchronously or asynchronously, it can also have cron schedulers that will clear up what’s supposed to run, but didn’t run, from the job processor queue.”

Four roles, one mechanism. Here is each one with a real example, and one honest caveat.

As a queue — work waits in a table, and a worker claims it

Every job is a row in a plain database table. On your tenant that table is app_scheduler_queue: an event code (which processor), an event-properties blob (usually just the document’s id), a start time, an expiry and a priority.

A worker takes a job by deleting it:

DELETE FROM app_scheduler_queue WHERE ctid = (
  SELECT ctid FROM app_scheduler_queue WHERE event_code = :eventCode
   AND start_time <= NOW() AND expire_time >= NOW()
   ORDER BY priority, start_time
   LIMIT 1 FOR UPDATE SKIP LOCKED
) RETURNING ROW_TO_JSON(app_scheduler_queue) AS data

FOR UPDATE SKIP LOCKED is the whole horizontal-scaling story in one clause: several workers can read the same queue at the same time and each simply steps over rows another worker has already locked. Add capacity and the same code goes faster. Every tenant has its own database, so the work shards by customer for free.

The consequence you have to hold on to is that claiming a job destroys the evidence that it was queued. There is no “in progress” row and no “done” row. A job is either still waiting or gone.

As publish-subscribe — and this one is literal, not a metaphor

When a document is finalised, the primary processor does not call the journal, then the stock, then the points. It publishes, and whoever subscribed gets a job.

The subscriptions live in two places, which is the single most useful fact on this page:

WhereWhat it holds
The master database, maintained by BigLedgerwhich processors may subscribe to which publisher, and on what terms
Your tenant’s databasewhich of those subscriptions your tenant actually has switched on

A secondary only runs if your tenant holds a row for that subscription. So the answer to “does BigLedger post a journal when I finalise an invoice?” is genuinely it depends on your tenant — and that is a feature, not a gap. A group with no loyalty scheme does not run the points processor. A company that keeps its accounts elsewhere does not run the journal processor. These are the subscriptions a finalise can fan out to:

SubscriptionWhat it does when your tenant holds it
Knock-offOpens the queue the next document reads
E-invoice (three or four processors)Puts the document into a pool and submits it
Journal postingWrites the double entry
Inventory postingMoves stock, and starts the costing chain behind it
Non-stock and trade-in linesPosts the lines that carry no stock
Membership pointsAwards loyalty points

A missing subscription is not a failure, then — it is a statement about what this tenant uses BigLedger for. But it does mean no page anywhere — including this one — can tell you what your finalise does. Only your tenant’s subscriptions can, and there is no screen that shows them today. That is a real gap and we have raised it; in the meantime it is a question for BigLedger.

On top of the subscription there are two more filters, and both are worth knowing because they explain “it posts for sales invoices but not for cash bills”:

  • A document-type constraint on the subscription itself. A link can name the server_doc_type values it wants; a document of any other type is skipped silently.
  • A per-company include/exclude list held on the company record. If it names an include list, only those processors run; anything on the exclude list never runs for that company.

As a scheduler — cron, per tenant, with a run log

Some work is not caused by anything you did. It has to happen at ten past the hour, or at 1 a.m. on the first of the month. That is bl_sch_crontab_hdr: one row per scheduled job on your tenant, with a standard cron expression, a status, an execution strategy and a last_run_time.

Your tenant has some of these already. A set of them are defaults that BigLedger inserts when a tenant is created — the e-invoice submission and status-update passes every ten minutes, the consolidation pass every twenty, the stock-balance queue every fifteen, the deposit rollover and the monthly aging on the first of the month. Two more joined the set in October 2026: the cashbook watchdog and the bank-reconciliation balance refresh.

The words when a tenant is created are doing a lot of work in that sentence. The sync only inserts a clock that is not already there, and it runs for new tenants — so a job added to the defaults after your tenant was created does not appear on your tenant unless BigLedger re-installs the defaults for it, which adds every default clock that is missing. That is the single commonest reason a feature that “ships” does nothing for you.

As an actor — the one place the code does not match the description

This is the role we could not confirm as described, so we are saying so rather than forcing the fit. The platform’s own vocabulary for a processor has exactly three values — PRIMARY, SECONDARY and SYSTEM — and there is no actor framework: no mailbox per entity, no single-threaded owner of a piece of state, no supervision tree.

What is there is exclusivity where it matters, done with named locks rather than actors:

  • bl_job_processor_lock — a named lock row with an expiry, taken by a job that must not run twice at once (the document ETL path, the import-failure mailer). The expired ones are swept on every pacemaker pass. The table holds only the locks being held right now — it is a live latch, not a log.
  • A per company and item lock taken by the costing pass, so two documents touching the same item cannot both recalculate its FIFO chain at once.

So the behaviour Vincent describes — one owner, serialised work, no two runs colliding — is real and is what the locks buy. The mechanism is not an actor system. Flagged for Vincent: if “actors” points at something else in the platform (a WMS device handler, the chat queues) we have not found it, and this section should be rewritten rather than left as our inference.

Trace it once: what one Finalise actually starts

Finalise is its own endpoint, not a save. If you go looking for the posting in the code or the audit trail of an ordinary update, you will never find it, because it is not there. This is the most useful sentence in this page for anyone who has tried.

Here is the whole chain for one finalised document, end to end.

    flowchart TD
  F["You press Finalise<br/>(a separate endpoint, not Save)"]
  Q["One row inserted into app_scheduler_queue<br/>BLG_ERP_GENERIC_DOCUMENT_PRIMARY_PROCESSOR"]
  R["HTTP response returns here —<br/>the chain runs on a background thread"]
  P["Primary processor: look up this tenant's subscribers"]
  J["Journal posting"]
  I["Inventory transaction lines"]
  K["Knock-off open queue"]
  E["E-invoice pool / matching"]
  M["Membership points"]
  O["…26 others, per subscription"]
  B["Stock balance per location and company"]
  C["FIFO / LIFO costing — cost written BACK<br/>onto the document line"]
  F --> Q --> R
  Q --> P
  P --> J
  P --> I
  P --> K
  P --> E
  P --> M
  P --> O
  I --> B --> C
  

Step by step, with the names:

  1. Finalise inserts one queue row for BLG_ERP_GENERIC_DOCUMENT_PRIMARY_PROCESSOR and then hands the work to a background thread pool. Your HTTP response returns without waiting.

  2. The primary processor asks who subscribed. In the master registry, 32 secondary processors are linked to this one publisher. Your tenant’s own subscription rows decide which of the 32 are eligible, then the document-type constraint and the company include/exclude list narrow it further.

  3. Each surviving secondary gets its own queue row. Thirty-one of the 32 links are marked RUN_NOW; one is INSERT_TO_QUEUE.

  4. A secondary can itself be a publisher. This is where the chain gets deeper than two levels, and where the costing write-back in the ripple map actually happens:

    INVENTORY_TRANSACTION_LINE_PROCESSOR → INVENTORY_TXN_LINE_TO_CURRENT_STOCK_BALANCE_PROCESSOR → INVENTORY_FIFO_LIFO_COSTING_PROCESSOR

    The last of those computes the cost and writes it back onto the document line you finalised. Your document is not finished when you finalise it. It is finished when the chain has run — and it is a loop, not a line.

Seven other publishers have their own fan-outs in the same registry, and the shapes rhyme: voiding a document publishes to ten void processors, discarding publishes to three, taking a finalised document back to Draft publishes to exactly one, and a cash document publishes to a cashbook, a journal and a post-dated-cheque processor. What a Void Undoes walks the first and the third of those end to end — including the part this page cannot tell you, which is that subscribing to a posting processor and subscribing to its void counterpart are two separate rows, and nothing provisions them together — so holding the first is no guarantee of the second.

Synchronous or asynchronous — who pays the wait

There are exactly two execution strategies, and the difference is whose thread does the work.

StrategyWhat happensWho waits
INSERT_TO_QUEUEThe job row is written and nobody runs it. It waits for the next sweep.Nobody. The work happens up to a minute later.
RUN_NOWThe job row is written and the same thread immediately tries to claim and run one job of that code.Whoever called.

The trick that makes finalise fast is that the two are combined. Finalise uses RUN_NOW — but it first pushes the whole thing onto a background thread pool (30 threads, fixed, per API instance). So:

  • Your cashier never waits for the journal. The till’s Finalise returns as soon as the queue row is written. A twenty-second call to a regulator never shows up as a twenty-second checkout. You can verify this at your own till in thirty seconds.
  • The background thread pays for all of it, serially. Because the secondaries are RUN_NOW, the primary’s thread runs subscriber after subscriber in turn. Thirty-two subscribers on one thread means the last one starts after the first thirty-one have finished.
  • Thirty threads is the ceiling per instance. A burst of finalises beyond that queues behind them — which is fine, because the queue is exactly where the work is safe.

This is also why errors do not land on the person who caused them. By the time the journal processor fails, the request that finalised the document returned long ago, and there is no thread to raise the error on. Nothing is shown at the till, nothing is e-mailed, and the person who pressed the button sees a perfectly normal document.

The cron sweeper: exactly what it catches, and what it does not

This is the part that makes asynchronous work defensible, and it is also the part our own pages have most often got wrong in both directions.

What drives everything

A pacemaker tick arrives from outside the application — an AWS CloudWatch/SNS message, repeated if it is not processed within a minute. It runs MAIN_GLOBAL_PROCESSOR, which loops every active tenant and puts a MAIN_TENANT_PROCESSOR job on that tenant’s queue. That per-tenant processor then does three separate things on every pass:

  1. Fires due clocks. It reads your active bl_sch_crontab_hdr rows, works out which are due, queues each one (or finds an identical job already waiting), stamps last_run_time and writes a row into bl_sch_crontab_event.
  2. Drains the queue. It selects up to ten due event codes sitting in app_scheduler_queue and drives each one. This is the sweeper in the plainest sense: anything that was queued and never ran — because a process restarted, a thread died, the instance was replaced mid-chain — is still a row, and the next pass picks it up.
  3. Takes out the rubbish. It deletes expired queue rows, rows whose retries are exhausted, and expired job-processor locks.

So the honest general answer is: work that is still in the queue always gets run eventually. Work that was claimed and then failed does not.

The bit that surprises people

Remember that claiming a job deletes it. On the main tenant queue the handler then runs outside a transaction, and if it throws, the code writes an error row and stops. The queue row is not put back. Nothing retries it.

(Other queue families in the platform behave better: they roll back, decrement a retry counter and try again until it reaches zero. The main tenant queue — the one your document postings go through — is not one of them.)

So “the sweeper will get it” is true for a job that never started and false for a job that started and blew up. Those two cases look identical from your desk.

What closes that gap, where anything does: the watchdogs

A watchdog is a different idea from the sweeper. It does not look at the queue at all — it looks at your documents, finds the ones that are missing a posting, and queues the job again from scratch. That is the piece that genuinely answers “nothing tells you it ran”.

The inventory watchdog exists and works. Every run it finds every document where posting_status = 'FINAL' and the inventory posting status is still blank and the document is more than twenty minutes old, and re-queues the stock posting for it — skipping anything already queued, so it cannot pile up duplicates. It does three more jobs besides: re-posts stock reversals for voided documents whose stock never came back, clears draft serial-number locks left behind by documents that were finalised or discarded, and queues a refresh wherever the stock ledger and the stored balance disagree.

It is registered, but nothing schedules it for you: it runs only where somebody has added a clock row for it. If your tenant has one, a sales invoice whose stock posting failed will be picked up and retried within the hour without anybody noticing. If it does not, it will not.

There is no journal watchdog, except for internal cash transfers. Since October 2026 the cashbook watchdog re-queues the journal of a finalised or voided internal cash transfer whose journal never posted, and the cashbook lines of any finalised or voided document whose lines did not come out right — where its clock is on your tenant. For every other document, nothing re-selects documents whose journal is missing. And that matters more than it sounds, because journal posting is the job that fails most often of any in the platform, ahead even of inventory posting — and inventory and cashbook posting are the subsystems that have a watchdog.

The knock-off watchdog is written but cannot run. The code exists and does exactly the right thing — find finalised documents whose knock-off queue was never opened, re-post them. But its queue code is not in the registry the scheduler resolves names against, and the scheduler drops an unrecognised code silently. Even if somebody added a clock for it, nothing would happen and nothing would say so.

SubsystemIs a document-level watchdog written?Can it run?Will it run for you?
Inventory postingYesYesOnly if a clock row for it was added to your tenant
Knock-offYesNo — not registeredNever, clock or no clock
Cashbook lines, and the journal of an internal cash transferYes, since October 2026YesOnly where its clock is on your tenant: one of the defaults for a tenant created since then, otherwise only if BigLedger installed it
Journal posting, every other documentNo——
Points, tax, budget, commissionNo——

So: the sweeper covers the queue. One watchdog covers stock and another the cashbook, each only where it has been scheduled. Nothing covers the ledger beyond internal cash transfers.

Shipped, registered, scheduled, running — four different facts

We got this wrong twice on our own pages in one day, so it is worth being blunt. A processor can be in any of four states and only the last one means anything to you.

StateWhat it meansHow anyone can tell
1. The code existsSomebody wrote and merged itA class in the backend. Says nothing about your tenant.
2. It is registeredIts queue code is a member of the platform’s processor registryIf it is not, the scheduler cannot resolve the name and drops it without a word
3. It is scheduled or subscribed on your tenantYou have a clock row, or a subscription rowA bl_sch_crontab_hdr row with status ACTIVE — the Scheduler applet lists them — or a subscription, which no screen shows
4. It has actually runThe job did its workThe work itself: the rows, postings or messages the job produces, or the posting status on your document. A clock’s last_run_time only proves state 3 fired

Two live examples, both measured on 2026-09-17:

  • E-invoice ghost-document detection — the standing check for finalised, e-invoice-eligible documents that never reached any e-invoice pool. It shipped. It is registered. It is in state 3’s waiting room: it is not one of the default clocks, and nothing adds it afterwards. Unless somebody creates the clock by hand, no digest e-mail will ever arrive, and a page that says “standing detection now runs” sends you to wait for something that cannot happen.
  • The knock-off watchdog — stuck at state 2. Not registered, therefore unrunnable: adding a clock for it would change nothing, and nothing would say so.

State 4 is the one people read off the wrong place. Every clock that fires stamps its own last_run_time and writes a row into the run log, bl_sch_crontab_event — but it does that when it puts the job on the queue, and also when an identical job was already waiting and nothing new was queued. The job runs afterwards, and a job that fails inside leaves last_run_time exactly as it was. So that pair answers “did the clock fire”, not “did the job work”. The second answer is in the job’s own results. A clock whose last_run_time is weeks old is worth a look — though whether it is actually stale depends entirely on its cron expression, so treat it as a prompt to check rather than a fault.

How you know it worked

Three checks, in the order of how much work they are.

One document, thirty seconds — the Posting tab, where there is one. Open a finalised document and look at its Posting tab. It shows five statuses read straight off the document: Journal, Inventory, Membership Points, Cashbook and Tax.

Read it like this:

  • POSTED — that subsystem is done.
  • Blank on a FINAL document — the job has not run or it failed. Those are the same value. There is no third value here: the journal processor writes POSTED on success and never writes FAILED, so a failure looks exactly like a job that has not got there yet.
  • Give it a minute before you conclude anything. If it is still blank after twenty minutes on a tenant with the inventory watchdog, the stock posting has been retried at least once already.

Check that your applet has one before you send anyone looking for it. Nine document applets a tenant can open today carry it — sales invoice, purchase invoice, purchase invoice without stock-in, delivery order, consignment billing, deposit, myBilling and two trade-specific ones — and an administrator can switch it off on all of them except myBilling (HIDE_POSTING_TAB). Two more are built with it but have no entry in the applet registry, so they appear in no applet list yet: the sales invoice without stock-out and the self-billed purchase invoice. Several applets that used to have it no longer do: POS General has never had one, and the receipt voucher, payment voucher, purchase refund note and sales return have had theirs removed or commented out.

That is not a gap. Those applets carry a TraceDocument tab instead, and it answers the same question better: rather than five status words it shows the postings themselves, as five grids — Journal Txn, Cashbook Txn, Points Txn, Tax Txn, Inv Txn. A missing journal is an empty grid, which is unambiguous in a way that a blank status is not. The one thing it will not tell you is why, and for that you go to Trace Document below — where the reason arrives when you press Resolve, not before.

POS is the exception with no tab of either kind: a cash bill’s posting state is not on any POS screen, and the route for it is how to tell whether the posting happened.

One document, properly — Trace Document. In the Financial Report applet, Error Checking → Trace Document takes a document type and number and runs six checks against it: journal exists, journal is right, cashbook line exists, shadow document exists, and for a voided document the reversal and its queue. Each failed check carries a Resolve button that creates or re-posts the missing piece. Every one of those six asks whether the artefact is there — none of them reads a job record, so a red check tells you the posting did not happen and never tells you whether a job failed or none was ever queued. The reason, when there is one, comes back from Resolve. Use Resolve rather than trying to finalise again. Resolve is the path the product built for this; re-running Finalise on a document that is already FINAL is not a repair: the server refuses it, with “Generic Document has already been posted to FINAL”, before anything is queued again.

In bulk — the Stock Flow Report. Finalised document amounts against the journal lines on each module’s account, with a difference column you can post from. It checks documents against journals, not stock against the stock account. This is the one to put on a routine, because nothing will prompt you.

When it goes wrong

What you seeWhat it usually isWhat to do
The document is FINAL, but there is no journal and nobody was toldThe journal job failed. There is no alert, no retry and no watchdog for the ledger, except the cashbook watchdog’s retry of an internal cash transfer where that clock runs.Trace Document → Resolve. Fix the cause first — a missing default GL code is the commonest — or the repost fails the same way.
Stock has not moved twenty minutes after finalisingEither the inventory subscription is off for your tenant, or the job failedIf the inventory watchdog is scheduled for you, wait one more cycle. If nothing changes, it is the subscription, and that is a question for BigLedger.
The next document cannot find this one in its pickerThe knock-off queue was never opened — usually because the company has no enabled knock-off pair for that document-type pairEnable the pair in the Organization applet, then re-queue the document you already raised: the Data Fix applet’s Generic Documents › Missing Open Queue runs the open-queue step for it, for an enabled pair only (how). Nothing does it on its own: the watchdog that would have swept it up cannot run.
A feature you read about does nothing on your tenantIt is registered but not scheduled for you — the default clocks are only inserted when a tenant is created, or when BigLedger re-installs themOpen the Scheduler applet and look for its clock in the listing. If it is missing, you can add it: press + and pick the code in the Select Processor picker. That picker is BigLedger’s platform-wide list, not your tenant’s, and it does not match what the clock can run in either direction, so a clock made from it is not proof the job will run — watch for its effect. If the job is not in the picker, or its clock never fires, that part is ours — what to send.
A colleague on another tenant gets a different result from the same actionDifferent subscriptions. This is the design working, not a bugCompare the two Scheduler listings first — that settles it whenever the difference is a clock. Subscriptions proper have no screen, so if the clocks match, ask.
The cost on a finalised line changed after you looked at itThe costing pass runs last and writes back onto the lineExpected. Read the cost after the chain has run, not during it.
Errors pile up for months and nobody mentions itNothing reads the failure tablePut the Stock Flow Report on a schedule you actually keep.

What this will not do

Stated plainly, because a limit saves more time than a feature.

  • It will not tell you a job failed. There is no alert, no e-mail, no badge and no entry in any screen you can open. Failures go into a table (app_scheduler_std_error) that nothing in the product reads — not a report, not a screen, not a digest. It has been filling up for years and is never looked at. If you are given the table, read a blank reason as a failure, not a success: the runner stores the exception’s message, and an exception with no message is stored as an empty string. The rows whose messages are joined by || are the document checks that refused, and those are the readable ones. We have raised it as a product issue. Until it is fixed, a failed job today tells nobody, and your own routine is the only cover.
  • It will not retry a posting that failed. On the queue your documents go through, a claimed job that throws is gone. Only the inventory watchdog and, since October 2026, the cashbook watchdog re-create work from the documents themselves, and only where they have been scheduled.
  • It will not show you the subscriptions. Which secondary processors are wired to which publisher on your tenant is not on any screen, and that is the honest limit behind a good many of the “ask your BigLedger contact” sentences you will read on our pages. The clocks are a different matter, and we used to say otherwise here. The Scheduler applet is the tenant’s cron table: its listing shows every scheduled job and when its clock last fired. Its Select Processor picker shows the platform’s list of job processors, not your tenant’s, and not an exact match for what the clock can run. If you have that applet, look before you ask.
  • It will not put a failed document right by finalising it again. Resolve the check in Trace Document instead.
  • It will not order the secondaries for you beyond what the chain fixes. Costing runs after stock because it is a child of the stock job, not because anything sequences the fan-out. Two subscribers of the same publisher have no guaranteed order between them.
  • It cannot run a processor whose code is not registered, and it will not tell you that it skipped one.
  • The failure count is not a document count. One row is one failed attempt, and some queue families write a row per retry, so the row count is an upper bound on the damage, not a measure of it.
  • Queues can fall behind badly and silently. A tenant queue can sit on hundreds of thousands of inventory-posting jobs that were queued a month ago and have still not been claimed. The queue is doing its job — the work is safe and will run — but nothing announces a backlog of that size to anyone.

Where this sits in the product

What has to exist first. A subscription on your tenant for the subsystem you care about, and — for the journal — a default GL code the posting can resolve. Without the GL mapping the job finalises the document and then fails, and you find out at month-end. Without the subscription nothing is even attempted.

What this makes possible. Everything the ripple of one finalised document buys you: nine consequences from one keystroke, each of which is a person and a spreadsheet in a business running separate systems. It is also what lets the till stay cheap and uninterruptible while everything expensive, fallible or regulated happens on the reporting side.

Which is which — sweeper against watchdog. They are not alternatives and people mix them up. The sweeper drives work that is still in the queue; use it to explain why something ran a minute late. A watchdog re-creates work from your documents when the queue no longer has it; use it to explain why a stock posting recovered by itself. If you are asking “will this fix itself?”, the question is which of the two applies — and for the ledger, neither does.

What breaks if this is wrong. A subscription that is off does not fail loudly; it fails invisibly, across every module at once. Journal off means no ledger and a clean-looking document. Inventory off means stock balances that quietly diverge from the movements behind them. Knock-off off means every downstream applet’s Search Document tab comes up empty and the reason is on a company settings screen nobody thought to open.

What it pairs with in practice. The document itself — see The Generic Document for why a cash bill and a purchase invoice behave identically here — and the reporting snapshots, because a job that has not run and a snapshot that has not been rebuilt produce the same complaint from the same person. That is when two numbers don’t tally.

Related documentation

Last updated on