This is the first article in a series of five on SAP Commerce Order Management. The series is written against a 2211 installation and focuses on runtime mechanics and project-level decisions that sit a layer below the high-level guides on the SAP Help Portal. Where something is already covered well in the official documentation, I link to it instead of retelling it.

I want to start with waiting, because waiting is where orders go to die.

About the code in this article

Java snippets below are an illustrative reconstruction: paraphrases written to communicate what the engine does, with names and structure deliberately simplified. They are not a reproduction of any implementation and should be read as diagrams. The ImpEx and XSD fragments are different — those are configuration files shipped in the distribution, and I quote them as they are, because the whole point of a process definition is that you can read and change it.

An order is mostly idle

Take any order management system, in any product, and measure where the wall-clock time goes. Pricing, fraud scoring, inventory lookup and warehouse selection together take a few hundred milliseconds. Everything else is waiting: for a fraud analyst to look at a flagged order, for a warehouse to acknowledge that it received the pick list, for a picker to actually pick, for a carrier to collect, for a customer to walk into a store, for a returned parcel to arrive at a depot, for a payment gateway to settle a refund.

A commerce platform therefore has to model long-lived state that advances on external signals. That is a different engineering problem from request handling, and it fails differently. Two failure modes dominate:

The signal never arrives. A warehouse integration drops a message, a store associate goes on vacation with eleven pick tasks assigned, an external fulfillment system returns 200 OK and then forgets. The order sits in a valid, consistent, permanently stationary state. Nothing is broken in a way that any health check would notice.

The signal arrives in a shape the workflow does not accept. The event fires, but with a payload the definition does not recognize, or with two competing instructions at once. Now the workflow has to decide between ignoring it, failing, and guessing.

Industry practice around this is well settled, and worth stating explicitly because it gives us a yardstick. BPMN has boundary timer events: you attach a clock to a state, and when the clock runs out, the token leaves the state along a different path. Temporal and similar durable-execution engines give you workflow.await(timeout) with an explicit deadline on every blocking call. Message-driven designs use dead-letter queues plus a redelivery policy. And almost everyone, regardless of engine, ends up with the same operational artifact: a dashboard or report listing orders that have not changed state in N hours, grouped by the state they are stuck in.

The common thread is that the absence of an event is itself modeled. It has a duration, a destination, and an owner.

Wall-clock timeline of an order: sub-second compute slivers separated by multi-hour and multi-day wait bands with no timeout

How SAP Commerce models the waiting

Order Management runs on the platform's process engine, the same one behind order confirmation emails and the B2B approval flows. Three processes matter here, and they are separate business processes with separate lifecycles:

Process Scope Created by
order-process the order as a whole order placement
consignment-process one shipment allocation
return-process one return request return request creation

The first thing worth knowing is that these three definitions are not compiled into the extension. They are data. Each is an ImpEx file that inserts a DynamicProcessDefinition whose content attribute is the XML of the process:

INSERT_UPDATE DynamicProcessDefinition;code[unique=true];active;content
;order-process;true;"<?xml version='1.0' encoding='UTF-8'?>
<process xmlns='http://www.hybris.de/xsd/processdefinition'
         start='checkOrder' name='order-process'
         processClass='...OrderProcessModel'>
...
</process>"

You will find them under yacceleratorordermanagement/resources/impex/, one file per process. That means the shape of the order lifecycle on your project is a row in a database table rather than a code change, which matters for the rest of this article: every checklist item below is closed by editing that row.

A node of type <wait> suspends the process until a named event fires. The implementation is small and worth understanding, because it explains both the failure modes and the fix. When the process reaches a wait node, the engine creates a task with a condition attached, and then returns. Reconstructed:

// illustrative
void trigger(BusinessProcessModel process) {
    ProcessTaskModel task = new ProcessTaskModel();
    task.setRunnerBean("taskRunner");
    task.setAction(nodeId);
    task.setProcess(process);

    TaskConditionModel condition =
        modelService.create(TaskConditionModel.class);
    String code = evaluateEventName(eventExpression, process);
    if (prependProcessCode) {
        code = process.getCode() + "_" + code;
    }
    condition.setUniqueID(code);
    task.setConditions(Set.of(condition));

    // <- the branch this article is about
    if (timeout.isConfigured()) {
        task.setExpirationTimeMillis(timeout.getExpirationTime());
    }
    taskManager.scheduleTask(task);
}

So a waiting order is a row in Tasks joined to a row in TaskConditions whose uniqueID is the event name the process is listening for. Firing the event means satisfying that condition; the task engine then picks the task up and the process moves on. Nothing polls, nothing holds a thread, and a node restart changes nothing, because the whole state is in the database. For durability this design is very good.

How a wait node is stored in Tasks and TaskConditions with a NULL expiration and released when an external event fires

The event name goes through Groovy

One detail in that reconstruction deserves a closer look, because it is easy to miss and often surprises people when they first spot it in a profiler.

The event name in a wait node is not used literally. It is wrapped in quotes and evaluated as a Groovy expression, with the process and its context parameters in scope. That is how the consignment definition can write this:

<wait id='waitForPreFulfillmentProcessToFinish'
      then='processConsignmentPreFulfillment'
      prependProcessCode='false'>
  <event>${process.code}_ProcessConsignmentPreFulfillmentEvent</event>
</wait>

${process.code} is a GString placeholder, resolved at the moment the wait node is armed. This is how you correlate a wait with one specific outbound call instead of with the process as a whole — anything reachable from the process or its context parameters can go into the event name, so a custom integration can arm a wait that only its own callback will satisfy.

The cost side is that every entry into a wait node runs the scripting engine, including for a constant string like CSAOrderVerified. On a high-volume installation that adds up to a measurable amount of Groovy work, and it is worth a look if your task engine is busier than the volumes suggest.

The ten places an order waits

Here is the complete set, read out of the three shipped process definitions. I am listing them in full because the list is the argument.

# Process Wait node / Event Waiting for
1 order waitForManualOrderCheckCSA
CSAOrderVerified
a human to clear a fraud-flagged order
2 order waitForOrderAction
OrderActionEvent
any order-level action for the whole life of the order
3 consignment waitForPreFulfillmentProcessToFinish
${process.code}_ProcessConsignmentPreFulfillmentEvent
a custom pre-fulfillment step to report back
4 consignment waitForConsignmentSubmissionConfirmation
${process.code}_ConsignmentSubmissionConfirmationEvent
an external fulfillment system to confirm receipt
5 consignment waitForShipConsignment
ConsignmentActionEvent
pick, pack, ship, cancel or reallocate
6 consignment waitForPickupConsignment
ConsignmentActionEvent
the same, for a click-and-collect shipment
7 return waitForConfirmOrCancelReturnAction
ConfirmOrCancelRefundEvent
an agent to approve or reject the return
8 return waitForGoodsAction
ApproveOrCancelGoodsEvent
the goods to physically arrive and be accepted
9 return waitForFailCaptureAction
FailCaptureActionEvent
a human to decide what to do about a failed refund
10 return waitForFailTaxReverseAction
FailTaxReverseEvent
a human to decide what to do about a failed tax reversal

Every one of these is a legitimate place to wait. An order under fraud review genuinely should sit there until somebody looks at it. A consignment genuinely should sit in waitForShipConsignment until the warehouse does something.

The question is what happens if nobody ever does.

What "never" looks like over a quarter

To put numbers on it, I simulated a modest operation: about 2,100 consignments a day, 70% from a distribution center working two shifts six days a week, 30% shipped from stores that open 10:00 to 20:00 and close on Sunday. Pick times are drawn from realistic working-hour distributions, and a small share of consignments never get their ConsignmentActionEvent: 0.3% for the DC (a dropped message), 1.0% for stores (an associate who never opens the task). Together that is about one consignment in two hundred. The chart counts what sits in waitForShipConsignment every hour for 90 days.

Simulated count of consignments in waitForShipConsignment over 90 days: legitimately waiting consignments swing between a few hundred and 3,500 with a weekly pattern, while consignments whose event never arrives grow linearly to 991; with a 48-hour timeout the stuck count stays below 35

The stuck ones grow at a steady eleven a day and never leave, so by day 90 there are 991 of them. That part is plain arithmetic. What the simulation adds is how well they hide. The legitimate population swings by a factor of about eight every week, peaking on Sunday night when the stores are closed. Against that background, a backlog that grows by eleven a day does not show up as a trend on any chart of "orders in progress". Even so, by day 11 a quarter of the waiting consignments will never move, and by day 37 the stuck ones outnumber the live ones. No error is logged and no task fails. The process engine is doing exactly what the definition says.

The dark line at the bottom is the same simulation with a 48-hour timeout that declines the consignment and re-sources it. The number stuck at any moment stays under 35, because each lost consignment is out after two days and the next one replaces it.

The timeout attribute, and why it is left to the project

The process definition schema supports deadlines. From platform/ext/processing/resources/processengine/processdefinition.xsd:

<complexType name="timeout">
  <annotation>
    <documentation>
      Allow to configure timeout for the wait node
    </documentation>
  </annotation>
  <attribute name="delay" type="duration" use="required">
    <annotation>
      <documentation>
        Delay after which wait will be timed out
      </documentation>
    </annotation>
  </attribute>
  <attribute name="then" type="tns:name" use="required">
    <annotation>
      <documentation>Transition on timeout</documentation>
    </annotation>
  </attribute>
</complexType>

delay is an XSD duration, so PT4H or P2D. then is the node to jump to when it expires. The wait node implementation declares SupportsTimeout, and the branch we saw earlier sets expirationTimeMillis on the task when a timeout is configured. The task engine's expiration handling then calls back into the node, which routes the process to the timeout target. The machinery is complete and has been there for years.

In the reference process definitions shipped with Order Management, none of the ten wait nodes configures a timeout out of the box.

This is easy to check on a fresh installation: grep -c timeout across the process definition ImpEx files returns zero. Out of the box, there is no pre-configured fraud review SLA and no deadline on an unpicked consignment. Until a project adds them, an external fulfillment system that accepts a consignment and never confirms leaves node 4 armed indefinitely, and a failed refund capture parks the return at node 9 until an operator intervenes.

As a platform default, this makes complete sense. SAP cannot know whether your fraud review SLA is thirty minutes or four days, and a reference timeout that fires at an arbitrary moment would be worse than no timeout at all, because it would move orders without the business understanding why. The shipped definitions in yacceleratorordermanagement are a starting template, and deadlines are a project-level responsibility. Still, because timeouts live in the underlying process engine schema rather than at the forefront of the Order Management guides, teams frequently take the reference definitions to production as-is and only think about deadlines the first time a warehouse integration quietly stops acknowledging.

What a reasonable set of deadlines looks like depends entirely on the business, but the shape is predictable:

Wait node Plausible deadline Reasonable destination
fraud review hours escalate to a second queue, or auto-cancel
external fulfillment confirmation minutes re-send, then decline and re-source
consignment unpicked the store's cut-off time decline the consignment, which re-sources it elsewhere
return goods not received the return window plus margin cancel the return
failed refund capture hours notify finance

The destination column matters more than the delay column. A timeout that routes to an end node is a way of losing orders quietly. A timeout that routes to an action which raises a visible exception record, or which triggers the existing decline-and-re-source path, turns a silent stall into a piece of work somebody owns.

The delay column is harder than it looks, and the reason is in the XSD. delay is an XSD duration, which measures wall-clock time. A business deadline such as "the store's cut-off time" is measured on a calendar, and in the consignment process a single wait node serves every warehouse, whatever its calendar. Running the same simulation with a fixed delay on waitForShipConsignment shows what that means:

Share of legitimate consignments a fixed timeout delay would decline, for DC and store fulfillment, plus a breakdown of store consignments declined at PT24H by weekday: about 9% Monday to Friday, 55% on Saturday and 40% on Sunday

PT24H sounds generous, and for the DC it is: only 0.3% of healthy consignments would time out. For stores, the same value declines 21% of consignments that would have been picked. The average hides the pattern. From Monday to Friday about 9% are declined, mostly evening orders that wait for the next opening. On Saturday the figure is 55%, because an order placed after the store closes on Saturday waits about 40 hours for Monday morning. Every one of these false declines re-sources an order that was on track and starts a race with an associate who may be picking it at that moment. To get store false declines under 1%, the delay has to go up to around PT72H. That is three days of silence before anyone hears about a DC consignment that was lost in its first hour.

A practical answer is to treat the timeout as a check interval rather than a deadline. Set a short delay and route it to an action that measures the consignment's age in its warehouse's working hours. That action sends the process back to the wait node if the business deadline has not passed, and declines it if it has. The calendar logic then lives in Java, where it belongs, and the XSD duration only sets how often the check runs.

How then and choices resolve

Every wait node has a mandatory then attribute, and reading the definitions it is natural to read it as the default successor. The real rule is narrower, and the difference shows up in integration code.

Events can be fired with a choice — a named branch. The order process uses this heavily:

<wait id='waitForOrderAction' prependProcessCode='true' then='failed'>
  <case event='OrderActionEvent'>
    <choice id='consignmentProcessEnded' then='verifyOrderCompletion'/>
    <choice id='cancelOrder' then='processOrderCancellation'/>
    <choice id='cancelled' then='success'/>
    <choice id='reSource' then='sourceOrder'/>
    <choice id='putOnHold' then='putOrderOnHold'/>
  </case>
</wait>

The resolution logic, reconstructed:

// illustrative
String nextNode(NodeExecutionContext ctx) {
    if (!ctx.hasAnyChoice()) {
        return then; // no choice supplied -> the 'then' attribute
    }
    Set<String> choices = ctx.getChoices();
    if (choices.size() > 1) {
        throw new IllegalStateException(
            "multiple choices are not supported");
    }
    String next = this.choices.get(choices.iterator().next());
    if (next == null) {
        throw new IllegalStateException(
            "choice was not declared in the process definition");
    }
    return next;
}

Some consequences for anyone firing these events from code.

Firing OrderActionEvent on an order without a choice sends the order process straight to the failed end node, which sets the process state to FAILED and stops it. An integration that nudges orders along by firing the event and omits the choice terminates the process. The order row survives, the consignments survive, the orchestration does not.

Firing it with a choice the definition does not declare raises IllegalStateException inside the task runner. The task fails, the process does not advance, and the wait node is not re-armed. The order is now stuck in a way that looks identical to "the event never came", except there is a stack trace in the log, which is the only thing that distinguishes them.

Firing it with two choices at once raises the same exception. This one is easy to hit if you build a generic "apply these state changes" integration endpoint.

The consignment waits handle this more gracefully, and the trick is worth copying:

<wait id='waitForShipConsignment' prependProcessCode='true'
      then='waitForShipConsignment'>

then points at the node itself. A ConsignmentActionEvent with no choice re-arms the wait instead of failing. The consignment stays where it was, which for an idempotent nudge is exactly right. Whether the asymmetry between the order process and the consignment process is deliberate, I cannot say, but if you are editing the order definition anyway, pointing its then at a dedicated "unexpected event" action rather than at failed costs nothing and removes a sharp edge.

Finding the orders that are stuck

Since a waiting process is a row, you can query it. The relevant tables are Tasks (ProcessTask extends Task and shares it), TaskConditions and Processes.

One caveat that will cost you an hour if you don't know it: Task.expirationDate and TaskCondition.expirationDate are dynamic attributes, computed in Java and absent from the database. The persisted column is expirationTimeMillis. Writing the obvious FlexibleSearch against expirationDate will not do what you want.

A serviceable starting point, in native SQL, to list waiting processes by age and by the node they are waiting in:

SELECT t.p_action                      AS wait_node,
       COUNT(*)                        AS stuck_count,
       MIN(p.createdTS)                AS oldest
FROM   tasks t
JOIN   processes p ON p.PK = t.p_process
WHERE  t.p_expirationtimemillis IS NULL
AND    p.createdTS < DATEADD(hour, -24, GETDATE())
GROUP  BY t.p_action
ORDER  BY stuck_count DESC;

Adjust the date function for your database. The output is the operational artifact I mentioned at the top — orders that have not moved in N hours, grouped by the state they are stuck in — and if you have not built a dedicated Backoffice view or alert for it yet, this query is the quickest way to get one. Running it once against a production-like dataset is usually instructive; waitForShipConsignment tends to dominate, and the long tail is where the integration bugs are.

When you run it matters too. In the simulated quarter above, the 24-hour query run on Thursday at 09:00 returns 939 consignments that will never move and 54 that are just slow. Run on Monday at 09:00, it returns 903 stuck and 605 healthy ones, which are the store orders from the weekend. The same threshold that is clean mid-week is 40% noise after a weekend. If the query feeds an alert, run it mid-week, or measure age in working hours, as the timeout action above does.

Changing a definition under running orders

Adding a timeout means editing the process definition ImpEx and re-importing it. The edit itself is unremarkable:

<wait id='waitForManualOrderCheckCSA' then='orderManualChecked'
      prependProcessCode='true'>
    <event>CSAOrderVerified</event>
    <timeout delay='PT4H' then='escalateFraudReview'/>
</wait>

with escalateFraudReview being an action bean you supply. Note that the node now needs a real destination — a timeout that points at an action which only logs is a way of marking the problem as handled while leaving it unhandled.

Versioning is where this gets interesting, because it behaves differently from what the data model suggests.

A running BusinessProcess stores the definition name and the definition version it started under, and the engine resolves every subsequent node against that pair. So far this reads like ordinary workflow versioning: change the definition, and in-flight instances keep running the shape they started with.

That isolation does not exist in practice, because nothing ever creates a second version. DynamicProcessDefinition.version is a Long defaulting to 0, with a unique index on (code, version, active), and the provider asserts that at most one active row exists per code. The shipped ImpEx declares code[unique = true] and no version column at all, so every re-import updates the same row — version 0 — in place. Every order ever started is pinned to version 0, and version 0 is whatever you imported most recently.

The switchover is governed by a cache rather than by the deployment. ProcessDefinitionsCache is a per-JVM map keyed by (name, version); a definition already in it is served from memory, and the database is consulted only on a miss. Putting those two facts together gives the actual behavior of a process definition change:

Usually this is harmless, because most edits add nodes rather than remove them. It stops being harmless when an order is parked in a wait node that the new content no longer defines: node lookup throws NoSuchNodeException, and the order is stuck with an exception that names a node you have already deleted from the file you are looking at.

If you want genuine isolation, you have to build it yourself — insert a new row with an explicit higher version, flip active, and leave the old row in place so in-flight processes can still resolve against it. No ImpEx in the distribution demonstrates this, which is a reasonable explanation for why almost nobody does it.

Where this leaves us

The process engine underneath Order Management is a solid piece of infrastructure: durable, restart-safe, defined in data, and perfectly capable of expressing deadlines. The reference Order Management definitions leave those deadlines unconfigured for the project to supply — which means that until you add them, the ten points where an order hands control to a human or an external system have no safety net under them. That is an essential checklist item for every implementation, and it is one you can close entirely from ImpEx.

Next in this series: how SAP Commerce picks a warehouse. The sourcing engine scores candidate locations with a weighted sum over a matrix — a clean model on paper whose relative normalization has some subtle consequences for how candidate sets behave in practice.