---
title: "Threading Contract"
description: "Canonical threading contract for OpenPit SDK handles across language bindings."
---

# 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 Go and C++ SDKs ship optional
  [Async Engine](Async-Engine.md) 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`.

## 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](#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 is the exception, for reservations and drop-copy operations
alike: neither async wrapper has a destructor hook, so the rollback that runs
when the last owner is released happens on that owner's thread rather than on
the account queue. Call an explicit `Close`-flavored method to keep
finalization in the lane. See [Async Engine](Async-Engine.md).

That off-lane rollback still runs mutation finalizers, and a finalizer has no
right to fail on any thread. One that fails there has no caller left to report
to, so the engine kill switch is the only signal; see
[Account Blocking - Mutation Finalizer Contract](Account-Blocking.md#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](Storage.md) - the synchronization-aware key-value abstraction that
matches the engine's sync policy automatically and removes the need for
external locking around policy state.
