Skip to content

Threading Contract

Eugene Palchukovsky edited this page Oct 25, 2026 · 10 revisions

Threading Contract

Canonical threading contract for OpenPit SDK handles across language bindings.

The SDK never spawns OS threads. Every public method executes on the OS thread that invoked it. The engine handle's threading capability follows from the chosen sync policy:

  • Full sync - concurrent invocation on the same handle is safe, including calls for the same account. Locks protect individual storage accesses rather than serializing an entire engine call, so same-account pipelines may interleave between accesses.
  • No sync - the handle stays on the OS thread that created the engine.
  • Account sync - concurrent invocation on the same handle from multiple threads is safe iff the caller guarantees that calls for the same account are never concurrent (sharded workers, one channel per account hash, or any equivalent pinning scheme). Without that guarantee, concurrent invocation is undefined behavior. Sequential cross-thread invocation is always safe. The exclusive operations are the exception. The Go and C++ SDKs ship optional Async Engine helpers that implement such per-account dispatch.

The pure-Rust AccountSyncEngine handle is deliberately Send + !Sync, so concurrent invocation through one handle is rejected by the type system. The caller-sharded rule above describes binding handles and dispatch adapters that provide their own per-account routing. It is not a hidden serialization guarantee supplied by FullSync.

Exclusive Operations

A few rare operations change state that every account reads:

  • account-group membership: register and unregister;
  • group currency: set and clear;
  • the Spot Funds retune: every Spot Funds configurator method except the one that replaces an account's P&L ledger;
  • account retirement.

They are exclusive operations:

  • Under full sync the engine orders each of them after the account-state writes already in flight and holds new ones until it returns. The hold covers the state-write scope of a built-in policy, not a whole engine call, so an exclusive operation may land between two such scopes of one call. For the Spot Funds retune it covers publishing the new settings and the sweep over accounts; the caller's settings update runs before it.
  • Under no sync and account sync the engine takes no lock for them, so ordinary calls pay nothing. The caller runs each one with no other call on the engine in flight on any thread, policy callbacks included. Breaking that rule is undefined behavior; debug builds of the Rust crate detect an overlap with an account-state write and panic.

The Go and C++ async engines provide this as one method, Exclusive: it stops every lane behind the work it already accepted, runs a callback with the engine, and then lets the lanes continue. Exclusive operations have no lane of their own there. Moving an account between groups is unregister followed by register; run both in one exclusive stretch, so no other call sees the account in the default group between them.

Under account sync, policy callbacks for distinct accounts may run concurrently, so a custom policy synchronizes any state of its own that is not keyed by account.

Selecting A Sync Policy In Each SDK

The policy is chosen once, when the engine is built, and cannot change afterwards.

  • Go - NoSync(), FullSync(), or AccountSync() on the engine builder. Goroutines migrate between OS threads by default, which the SDK supports on every policy; see Thread Migration And Callbacks.
  • Python - no_sync(), full_sync(), or account_sync() on the engine builder. Public methods acquire the GIL when needed, and the SDK does not release it across callback boundaries, so Python policies always execute on the calling thread. Synchronous code and an event loop pinned to one thread are both satisfied by no-sync; a synchronizing policy is needed only when the engine is genuinely shared across OS threads.
  • JavaScript - no selection is offered. The WebAssembly engine is single-threaded and always uses no-op locking, the engine and its handles cannot be transferred to another thread, there is no async engine, and every policy callback runs synchronously on the calling thread. Use one engine instance per worker or isolate when parallelism is needed.
  • C++ - openpit::SyncPolicy::None, openpit::SyncPolicy::Full, or openpit::SyncPolicy::Account, passed to the engine builder.
  • Rust - no_sync(), full_sync(), or account_sync() on the engine builder.

Drop Copy And Implicit Cleanup

Drop-copy apply and finalization do not turn a FullSync policy pipeline into one isolated critical section. Eager policy state may be observed by an interleaving same-account call before an evaluation failure compensates it or before the caller finalizes an accepted operation. Integrations that need pipeline isolation must serialize the whole same-account operation lifetime externally or use the Go or C++ async engine. Those helpers pin apply and every explicit commit, rollback, or close to the account queue, and they refuse an order with no readable account instead of queueing it.

Implicit cleanup follows the same order. A Go wrapper that is never closed is never rolled back. In C++, releasing the last reference to an unclosed reservation or drop-copy operation rolls it back in order with its account: inline when the releasing thread already serializes the account, otherwise queued on the account lane or, with no worker for the account, under its fence on the releasing thread - never beside another call for the account and never beside a running exclusive section's callback. See Async Engine.

That implicit rollback still runs mutation finalizers, and a finalizer has no right to fail on any thread. Nobody awaits its outcome, so the engine kill switch is the only signal of a failure; see Account Blocking - Mutation Finalizer Contract.

Thread Migration And Callbacks

Runtime migration of the caller between OS threads during one SDK call is supported (a goroutine moving across worker threads, a coroutine resuming on a different thread than it suspended on). Callbacks invoked by the SDK back into host code may run on a different OS thread than the caller, so callback code must not rely on thread-local OS state.

User Data Ownership

The user data fields on Reject, Order, ExecutionReport, AccountAdjustment, and AccountBlock are opaque caller tokens. The SDK never inspects, dereferences, or frees them. Their lifetime, thread-safety, and meaning are entirely the caller's responsibility.

AccountBlock user data is an opaque bit pattern and refers to nothing after a restart. In Go the token is an integer (uintptr) that the SDK carries verbatim, like every other binding's user-data token.

Policy State

Custom policies that need internal state across calls should use Storage - the synchronization-aware key-value abstraction that matches the engine's sync policy automatically and removes the need for external locking around policy state.

Clone this wiki locally