Skip to content

Vehicle stops receiving transport orders while idle at a known position #275

Description

@muhammetaydinn

Summary

A vehicle can stop receiving transport orders entirely. Orders pinned to it stay
in DISPATCHABLE forever while the vehicle sits IDLE at a known position with
a route available, holding nothing that blocks anyone. Orders pinned to other
vehicles are picked up immediately, so the dispatcher itself is alive.

Only a kernel restart, or toggling that vehicle's integration level, brings it
back.

Reproduces within a few order cycles on the bundled Demo-01 model with the
loopback driver, on both v7.5.0 and master.

Expected

An idle vehicle at a known position, integrated as TO_BE_UTILIZED, should be
assigned a dispatchable order that has a valid route to its destination.

Actual

Vehicle-02  state=IDLE  procState=IDLE  position=Point-0004
  allocatedResources = ['Point-0004']        <- just its own point, nothing blocked
  orders pinned to it: DISPATCHABLE, indefinitely

Vehicle-03  state=IDLE  procState=IDLE  position=Point-0027
  allocatedResources = ['Point-0027']

Vehicle-04  state=EXECUTING  procState=PROCESSING_ORDER
  working normally at the same time

Every documented assignment precondition holds for Vehicle-02:

  • order state DISPATCHABLE
  • procState IDLE, state IDLE
  • integrationLevel TO_BE_UTILIZED
  • reported at a known position
  • a route exists (checked with routeComputationQuery)
  • acceptableOrderTypes is *, vehicle not paused, energy level 100

POST /v1/dispatcher/trigger changes nothing. Waiting does not help either; the
order was still DISPATCHABLE after several minutes.

Steps to reproduce

  1. Start the kernel with the bundled Demo-01 plant model.
  2. Attach the loopback adapter to Vehicle-02, Vehicle-03 and Vehicle-04,
    set integrationLevel to TO_BE_UTILIZED, initialise their positions.
  3. Repeatedly: pick a vehicle that is IDLE / TO_BE_UTILIZED at a known
    position, create a single-destination order pinned to it via
    intendedVehicle, wait for it to reach a final state.
  4. Within a few cycles one order stays DISPATCHABLE for good.

The attached script does this over the web API and reports the cycle where it
stalls. Python 3, no dependencies:

python3 repro_dispatch_stall.py --cycles 12 --no-withdraw
[  1] ok (Vehicle-02 -> Goods in north 02)
[  2] ok (Vehicle-03 -> Goods in south 01)
[  3] NOT ASSIGNED -> dispatcher stalled
      order=REPRO-0003-... vehicle=Vehicle-02 pos=Point-0025

Reproducibility

Reproduced on v7.5.0 (tag) and on master @ 391c842e (7.6.0-SNAPSHOT),
multiple runs each, kernel restarted between runs.

What was ruled out

  • Not withdrawal-related. A control run that never withdraws, letting every
    order finish, stalls just as reliably.
  • Not resource contention. With
    kernelapp.vehicleResourceManagementType = LENGTH_IGNORED the affected vehicle
    holds only its own point, nothing waits on it, and the stall still happens.
    (With the default LENGTH_RESPECTED the 1000 mm loopback vehicle also keeps
    the short path behind it, which looks similar but is separate and appears to be
    working as designed.)
  • Not a general dispatcher failure. Orders pinned to other vehicles are
    assigned and executed while one order sits in DISPATCHABLE.

Workaround

Setting the affected vehicle's integrationLevel to TO_BE_IGNORED and back to
TO_BE_UTILIZED restores it without a kernel restart. That it is recoverable
per vehicle, without touching anything else, suggests some per-vehicle state in
the dispatcher rather than resource accounting.

One place worth a look: OrderReservationPool releases reservations in only two
spots (AssignReservedOrdersPhase), plus a full clear on dispatcher shutdown.
Not verified, since the pool is not observable through the web API.

Environment

  • openTCS v7.5.0, also reproduced on master @ 391c842e
  • Plant model: bundled Demo-01
  • Driver: loopback (LoopbackCommunicationAdapter)
  • JDK: Temurin 21.0.12.1, Linux (Ubuntu 22.04)
  • Web API v1 only, no GUI actions

Reproduction script

repro_dispatch_stall.py (Python 3, no dependencies)
#!/usr/bin/env python3
"""Reproduce: openTCS stops assigning transport orders to a vehicle.

Orders sit in DISPATCHABLE while the vehicle they are pinned to is IDLE /
TO_BE_UTILIZED at a known position with a route available, and an explicit
dispatcher trigger changes nothing. Only a kernel restart clears it.

Root cause observed: after finishing an order, a vehicle can keep an allocation
on the *path* it arrived on, not just its current point. Anything routed through
that path then waits indefinitely.

Withdrawals were the original suspicion and are not the cause: --no-withdraw,
which lets every order finish normally, stalls just as reliably. Both modes are
kept so the control run stays easy to repeat.

Every few cycles the script probes whether dispatching still works at all, and
reports the cycle where it breaks.

    python3 repro_dispatch_stall.py --cycles 12 --no-withdraw   # control run
    python3 repro_dispatch_stall.py --cycles 20                 # with withdrawals
"""
from __future__ import annotations

import argparse
import json
import sys
import time
import urllib.error
import urllib.request

DEFAULT_API = "http://localhost:55200/v1"
TERMINAL = {"FINISHED", "FAILED", "WITHDRAWN", "UNROUTABLE"}


def call(api, path, payload=None, method=None):
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(
        f"{api}{path}", data=data,
        method=method or ("POST" if data is not None else "GET"),
        headers={"Content-Type": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            body = resp.read().decode()
            return json.loads(body) if body.strip() else {}
    except urllib.error.HTTPError as exc:
        return {"__error": f"HTTP {exc.code}: {exc.read().decode()[:120]}"}
    except urllib.error.URLError as exc:
        raise SystemExit(f"cannot reach kernel: {exc.reason}")


def vehicles(api):
    """Loopback vehicles only; the VDA5050 one waits for a real vehicle."""
    return [v for v in call(api, "/vehicles") if "VDA5050" not in v["name"]]


def free_vehicle(api):
    for v in vehicles(api):
        if (
            v.get("procState") == "IDLE"
            and v.get("state") in ("IDLE", "CHARGING")
            and v.get("integrationLevel") == "TO_BE_UTILIZED"
            and v.get("currentPosition")
        ):
            return v
    return None


def wait_until(predicate, timeout, interval=1.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        value = predicate()
        if value:
            return value
        time.sleep(interval)
    return None


def order_state(api, name):
    o = call(api, f"/transportOrders/{name}")
    return o.get("state") if "__error" not in o else None


def dispatch_works(api, jobs, timeout=25) -> bool:
    """Ground truth: can a fresh order still be assigned to a free vehicle?"""
    v = free_vehicle(api)
    if not v:
        return True  # No free vehicle to test with; not evidence of a stall.
    name = f"PROBE-{int(time.time() * 1000) % 10**9}"
    loc, op = jobs[0]
    res = call(api, f"/transportOrders/{name}", {
        "destinations": [{"locationName": loc, "operation": op}],
        "intendedVehicle": v["name"],
    })
    if "__error" in res:
        print(f"    probe could not be created: {res['__error']}")
        return False

    assigned = wait_until(
        lambda: order_state(api, name) not in (None, "RAW", "ACTIVE", "DISPATCHABLE"),
        timeout,
    )
    call(api, f"/transportOrders/{name}/withdrawal", {})
    return bool(assigned)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--api", default=DEFAULT_API)
    ap.add_argument("--cycles", type=int, default=40)
    ap.add_argument("--no-withdraw", action="store_true",
                    help="control run: never withdraw, let each order finish")
    ap.add_argument("--drive-seconds", type=float, default=3.0,
                    help="seconds to let the vehicle drive before withdrawing")
    args = ap.parse_args()

    model = call(args.api, "/plantModel")
    ops = {t["name"]: [o for o in t.get("allowedOperations", []) if o.upper() != "NOP"]
           for t in model["locationTypes"]}
    jobs = [(l["name"], ops[l["typeName"]][0])
            for l in model["locations"] if ops.get(l["typeName"])]
    if len(jobs) < 2:
        raise SystemExit("model has too few usable locations")

    mode = "without withdrawals" if args.no_withdraw else "with withdrawals"
    print(f"model {model['name']}: {len(jobs)} jobs | {args.cycles} cycles, {mode}\n")

    for cycle in range(1, args.cycles + 1):
        v = wait_until(lambda: free_vehicle(args.api), 60)
        if not v:
            print(f"[{cycle:3d}] no free vehicle within 60s -> STALLED")
            return 1

        loc, op = jobs[cycle % len(jobs)]
        name = f"REPRO-{cycle:04d}-{int(time.time()) % 100000}"
        res = call(args.api, f"/transportOrders/{name}", {
            "destinations": [{"locationName": loc, "operation": op}],
            "intendedVehicle": v["name"],
        })
        if "__error" in res:
            print(f"[{cycle:3d}] could not create: {res['__error']}")
            continue

        got = wait_until(
            lambda: order_state(args.api, name) not in ("RAW", "ACTIVE", "DISPATCHABLE"),
            30,
        )
        if not got:
            print(f"[{cycle:3d}] NOT ASSIGNED -> dispatcher stalled")
            print(f"      order={name} vehicle={v['name']} pos={v['currentPosition']}")
            return 1

        if args.no_withdraw:
            wait_until(lambda: order_state(args.api, name) in TERMINAL, 90)
        else:
            # The point of the test: cut in while the vehicle is driving.
            time.sleep(args.drive_seconds)
            call(args.api, f"/transportOrders/{name}/withdrawal", {})

        if cycle % 5 == 0:
            ok = dispatch_works(args.api, jobs)
            print(f"[{cycle:3d}] dispatch still healthy: {'yes' if ok else 'NO'}")
            if not ok:
                print("\n>>> REPRODUCED: the dispatcher no longer assigns orders")
                print(f">>> after {cycle} cycles, {mode}")
                return 1
        else:
            print(f"[{cycle:3d}] ok ({v['name']} -> {loc})")

    print(f"\n{args.cycles} cycles completed, no stall observed.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions