Skip to content

Workers and leases

A worker is a process that pulls datasets from the API, solves them with Timefold, and reports every improvement back. Workers are never called; they ask. The API therefore needs no way to address a particular worker, a busy worker simply does not ask, and a fixed-size pool degrades into a queue rather than into overload.

The solver service is a pool of workers. Each instance runs one claim loop per slot, on its own virtual thread, and a slot is one concurrent solve. Timefold Community solves single-threaded, so one slot per core is an honest capacity model; a worker keeps one core spare so the instance's cluster heartbeats stay on time. SAT_SLOTS overrides the count.

The claim loop

Each slot asks the API for work with a long poll, POST /internal/leases, naming the models this worker can solve. The API answers with a claim or with nothing after claim-wait; an empty answer makes the slot wait claimBackoff (one second) and ask again. A claim carries the dataset's specification, the lease epoch, the lease's time to live and a mode:

Mode The worker
full validates the input, scores it, records the input's metrics and analysis, then solves unless the operation was NONE
solve solves a dataset that was already validated and computed
warm decodes the dataset's best solution so far and continues from it, skipping construction

The runner's tests drive a worker against a scripted channel, which is the API in memory:

test("one slot works through a queue one dataset at a time") {
  val channel = ScriptedChannel()
  channel.enqueue(ScriptedChannel.claim("ds_a", spentLimit = "PT1S"), small)
  channel.enqueue(ScriptedChannel.claim("ds_b", spentLimit = "PT1S"), small)
  val worker = Worker(
    WorkerConfig("w1", slots = 1, claimBackoff = 100.millis, heartbeat = 500.millis),
    ModelCatalog.of(EmployeeScheduling.V1),
    channel
  )
  worker.start()
  try eventually(60.seconds)(channel.completes.size == 2)
  finally worker.stop()
  assertEquals(channel.completes.asScala.map(_._1).toSet, Set("ds_a", "ds_b"))
  assert(channel.calls.asScala.contains("register w1"))
  assert(channel.calls.asScala.contains("deregister w1"))
}

The lease epoch is a fencing token

When the API grants a claim it records a lease: the worker's id, an epoch, and an expiry. Every request the worker makes for that dataset carries the epoch. If the lease expires and the dataset is re-queued, the next claim gets a higher epoch, and a report from the old epoch is refused with 409 before anything is persisted. The worker that receives a 409 stops its solve, discards it and frees the slot.

This rule exists because a worker that was partitioned, presumed dead and replaced will come back and report. Without fencing it would overwrite its successor's progress. The rule is enforced in the dataset entity's fold, so replaying the journal agrees with what happened live.

Heartbeats carry control

Every five seconds per held dataset the worker sends a heartbeat with the epoch, the score calculation speed, the move count and its recent log lines. The heartbeat renews the lease, and the reply is how the API talks to a worker: it may say terminate, which stops the solve early and completes the dataset with its best solution, or drain, which makes the worker release the dataset back to the queue. Terminate latency is therefore the heartbeat interval.

Log lines are scrubbed before they leave the worker: a line longer than 512 characters, or one containing a brace, a bracket or a quote, is replaced with a note of its length, so a dataset's contents cannot leak through the logs.

Reports are throttled, and the final one always arrives

Timefold finds hundreds of new best solutions a second at the start of a solve. The worker keeps only the latest in a throttle and reports at most one per report-interval (one second by default, never under 250 milliseconds), and always reports the final one. The solver hands the throttle a planning clone on its own thread, so encoding the solution does not race the solver.

A report uploads the solution as a blob and then tells the API its score. The API records it only if the score is strictly better than the dataset's best, assigns the sequence number, and answers whether it was recorded. After a warm restart the new worker's first reports may equal the old best, and refusing them is what keeps the stream monotonic.

When a worker dies

A lease that is not renewed within lease-ttl (20 seconds) expires. The dataset is re-queued with a warm-start reference to its best solution, and the next worker continues from there. A crashed worker costs at most one report interval of progress plus the lease's time to live. After max-attempts lost leases (three) the dataset ends SOLVING_FAILED with its best solution still readable.

Timefold has no checkpoint of solver state, and none is needed: a solver given an already-initialised solution skips construction and goes straight to local search.

Rolling deploys drain

On SIGTERM a worker stops taking claims, terminates every solve it holds early, reports each one's final best, releases each dataset back to the queue with reason shutdown, and deregisters. This completes well inside the platform's grace period. An operator can do the same to one worker without stopping it, through the drain action described in Operate the service; the worker then releases with reason drained and keeps running without claiming.

Where the runner runs

The claim loop, the throttle and the solve session are one library, satisfactory-runner, with no Pekko and no HTTP types in it. The solver service wraps it in an ankka runtime extension and talks to the API over HTTP. For development the same runner runs inside api, talking to the dataset entities directly, and sbt api/run is then the whole system in one process. The seam is kept so that a dedicated pod could one day run the runner's own main with no actor system at all.