-
Notifications
You must be signed in to change notification settings - Fork 1
Storage
OpenPit policies often need internal state — accumulated P&L, rate-limit
timestamps, reserved margin, position counters. The Storage abstraction
lets that state work transparently in any synchronization environment the
SDK supports, with zero overhead when none is needed and full
thread-safety when the embedding requires it.
This page documents the abstraction, the available synchronization policies, and how to consume it from each SDK.
OpenPit supports embeddings where policy state stays on one thread, moves between
threads sequentially, or is accessed concurrently. A custom policy storing
state in a plain map handles only the single-thread case; the moment calls
cross threads - even sequentially - the policy needs a mechanism that
establishes happens-before ordering between writes on one thread and reads on
another. Storage provides that ordering without making the policy author
design cross-thread coordination.
Across the language bindings, every storage created by a policy is
account-keyed: the top-level key carries the AccountId. The per-account value
can be any structure the policy needs (counter, sliding-window log, nested map).
Sharding by account is part of the public storage contract - the chosen
synchronization mode is independent from the key-shape rule.
Cooperative scheduling adds another wrinkle. A scheduled task may resume on a different OS thread than the one it suspended on. This is not necessarily multithreading in the classical sense - calls may still be sequential at the host layer - but it still requires the same happens-before guarantee. The engine handle itself carries the matching capability through the selected sync policy: Full sync is the mode for thread-shareable handles, Account sync supports sequential cross-thread invocation, and Local sync keeps the handle single-threaded.
Storage solves the policy-state side of these cases with one abstraction:
- the synchronization policy is selected once when the storage is created;
- every read or write goes through a scoped access handle that owns the appropriate access rights for the duration of access;
- the user code only asks the storage to read or write a key;
- when no synchronization is needed, the no-sync policy compiles down to direct map access - zero overhead.
The storage picks one of three built-in policies at construction time. The policy is part of the storage's type, so runtime overhead matches the chosen guarantees and nothing more.
The host keeps every call on one thread. The storage performs no cross-thread coordination; sharing it across threads is rejected at the language level. This is the lowest-overhead choice - operations compile down to direct lookups.
Any thread may enter for any key in any order. The storage serializes reads and writes so the state stays consistent under concurrent load. This is the safe default when the host does not constrain the call pattern.
The caller commits to processing each account through a single chain: one queue or one worker at a time. The same account must never be processed by two threads in parallel; different accounts may run concurrently with no coordination.
The storage coordinates the boundary between adding or removing accounts and accessing per-account state, but does not serialize the per-account access itself - the single-chain-per-account discipline does. Throughput scales with the number of independent accounts.
The Rust API exposes the policy abstraction so that custom synchronization policies can be plugged in - for example, a sharded coordination strategy or a lock-free data structure.
The cost model differs between direct SDK use and the language bindings:
-
Direct Rust - No-sync policy compiles down to zero synchronization
overhead; Account sync adds one lock acquire on the index per access; Full
sync adds two lock acquires (index and values) per access. The engine handle
adds an atomic handle reference only for sync modes that produce a
SendRust handle (Full and Account); No-sync uses a non-atomic handle reference. - Language bindings - every mode goes through a thread-portable engine handle and a runtime sync-mode dispatch. The fixed binding overhead is small (a single atomic handle reference plus a runtime dispatch) compared to the per-mode storage cost above.
In all three modes, business-logic costs (policy evaluation, map operations) dominate the per-call wall time. The locking overhead matters only at very high throughput.
Each engine builder owns a storage builder whose policy type matches the engine's synchronization policy. Pass a reference to that storage builder to your custom policy's constructor; the custom policy creates one storage per internal table and keeps it for the lifetime of the engine.
Application code cannot create a StorageBuilder directly. The engine is the
only owner of the builder, and policies that need storage must receive
builder.storage_builder() from the engine builder during initialization.
The policy is fixed centrally once, when the engine is configured — every storage created from that builder shares the same locking guarantees.
Rust
use openpit::param::{AccountId, Asset, Pnl};
use openpit::storage::{LockingPolicyFactory, Storage, StorageBuilder};
pub struct MyPolicy<StorageLockingPolicyFactory>
where
StorageLockingPolicyFactory: LockingPolicyFactory,
{
realized: Storage<
(AccountId, Asset),
Pnl,
StorageLockingPolicyFactory::Policy,
>,
}
impl<StorageLockingPolicyFactory> MyPolicy<StorageLockingPolicyFactory>
where
StorageLockingPolicyFactory: LockingPolicyFactory
+ openpit::storage::CreateStorageFor<(AccountId, Asset)>,
{
pub fn new(
storage_builder: &StorageBuilder<StorageLockingPolicyFactory>,
) -> Self {
Self {
realized: storage_builder.create(),
}
}
pub fn record_pnl(
&self,
account: AccountId,
settlement: Asset,
delta: Pnl,
) {
self.realized.with_mut(
(account, settlement),
|| Pnl::ZERO,
|entry, _is_new| {
if let Ok(updated) = entry.checked_add(delta) {
*entry = updated;
}
},
);
}
pub fn current_pnl(
&self,
account: AccountId,
settlement: &Asset,
) -> Pnl {
let key = (account, settlement.clone());
self.realized
.with(&key, |entry| *entry)
.unwrap_or(Pnl::ZERO)
}
}The built-in policy types are NoLocking, FullLocking, and
IndexLocking. Access is closure-based: with provides read-only
scoped access, with_mut provides read/write scoped access with
on-demand insertion, and remove deletes an entry. The access scope ends when
the closure returns. Storage is intentionally not Clone - share it through
an explicit owner type or borrow &Storage<...>.
Rust
use openpit::Engine;
let builder = Engine::<(), ()>::builder().full_sync();
let counters = builder.storage_builder().create::<&'static str, u64>();
counters.with_mut("ticks", || 0, |value, _is_new| {
*value += 1;
});
assert_eq!(counters.with(&"ticks", |value| *value), Some(1));
assert!(counters.remove(&"ticks"));- Caller does not drive the engine from multiple threads → no-sync policy. It is the cheapest mode and adds no synchronization overhead by default.
- Shared engine, no scheduling guarantees from the host → full-sync policy. Safe default.
- Shared engine, each account is pinned to a single chain (queue or worker) → account sync. Trades a correctness obligation for unbounded per-account parallelism.
- Anything else → custom policy passed through the engine builder.
- Threading Contract: Per-call execution model the storage operates under
- Policies: Built-in policies that already use storage internally
- Policy API: Language-specific interfaces for custom policies