-
Notifications
You must be signed in to change notification settings - Fork 1
Async Engine
The Go SDK's asyncengine subpackage and the C++ SDK's
openpit::asyncengine::TypedAsyncEngine turn an AccountSync engine into a
concurrent facade with per-account ordering guarantees. These helpers are one
possible integration pattern - applications may provide their own sharded
queues, actor model, or equivalent dispatcher.
Both SDKs provide sharded and dynamic strategies, futures, observer callbacks,
graceful stop, and hard stop. The Go examples below use goroutines and
context.Context; the C++ surface uses worker threads, Future<T>, and
std::chrono deadlines. The wrapped AccountSync engine receives at most one
operation per account at a time, and queued operations for an account reach it
in submission order.
Use a bundled async engine when the application:
- builds the engine with
AccountSyncto allow concurrent access across many accounts; - wants the SDK to guarantee that no two operations for the same account ever run inside the engine concurrently;
- prefers
Future-style return values over manual channel/wait-group bookkeeping; - needs graceful and hard stop modes with deadlines.
If FullSync or NoSync is sufficient, or if you already have a
custom per-account dispatcher you are happy with, you do not need this
package.
asyncengine ships two dispatch strategies. Each chosen at build time so
the hot path stays branch-free in steady state.
- Go:
asyncengine.NewBuilder(engine).Sharded(workers) - C++:
openpit::asyncengine::MakeTypedAsyncEngine(engine, workers)orTypedBuilder<Driver>(driver).Sharded(workers)
The two C++ forms differ only in who owns the engine adapter. The typed builder
borrows a driver that the caller keeps alive, which is what a custom driver
needs. MakeTypedAsyncEngine is the shortcut for the common case of wrapping a
plain engine: it owns the default adapter itself and exposes the typed async
methods - start pre-trade, execute pre-trade, apply execution report, apply
account adjustment, and apply drop copy - without any driver lifetime plumbing.
It does not own the source engine. The same engine object must outlive the async
wrapper at its original address and must not be moved while the wrapper exists;
rvalue engines are rejected at compile time.
- A fixed number of worker queues created up front. Go runs one goroutine per shard; C++ runs one worker thread per shard.
- Go hashes the full routing key - account, account-group, or engine-wide - by independently mixing its numeric ID and kind. C++ hashes the account ID with a Fibonacci mix.
- Routing is one multiply-shift per submit. There is no per-routing-key map lookup in Go or per-account map lookup in C++; synchronization is limited to ordering the submit against queue capacity and stop.
Strengths:
- The cheapest hot path: ~constant per submit.
- O(1) memory regardless of how many distinct accounts are active.
- Predictable worker count.
Trade-offs:
- A hot account saturates a single shard while the others stay idle.
- No routing-queue created or removed observer signals (the shard queues exist for the engine lifetime).
- In Go, distinct routing keys that hash to the same shard interleave through the same channel. In C++, different account IDs that hash to the same shard interleave through the same channel.
Pick Sharded when the active account set is broad and roughly balanced
or when raw throughput per submit matters more than routing-key isolation.
-
Go:
asyncengine.NewBuilder(engine).Dynamic().MaxQueues(n).IdleCleanupAfter(d) -
C++:
TypedBuilder<Driver>(driver).Dynamic().MaxQueues(n).IdleCleanupAfter(d) -
Go creates a queue on first submit for each account, account-group, or engine-wide routing key. C++ creates a per-account queue.
-
A background cleanup worker retires queues that have been empty and untouched for
IdleCleanupAfter. -
MaxQueuescaps the live queue count. In Go, the count includes account, account-group, and engine-wide routing queues; submits for an unknown routing key past the cap returnErrQueueLimit. In C++, the count covers per-account queues and failures returnErrorCode::QueueLimit.MaxQueues(0)removes the cap.
Strengths:
- Full routing-key isolation in Go: a slow key does not starve others that happen to share a shard. C++ provides the same isolation per account.
- Routing-queue observer signals (
OnQueueCreated,OnQueueRemoved, latency); callback IDs are numeric projections and are not unique across routing kinds. - Memory scales with the active set rather than the total population.
Trade-offs:
- A synchronized map lookup on every submit.
- A periodic cleanup worker.
- Slightly higher per-queue memory because each queue holds its own bounded buffer and worker.
Pick Dynamic when account activity is skewed, when routing-queue metrics with
non-unique cross-kind numeric IDs are sufficient, or when the population is
large enough that statically allocating shards would be wasteful.
MaxQueues defaults to runtime.NumCPU() * 32, a value designed to be
effectively non-restrictive on typical hosts while still bounding
pathological growth (e.g. ephemeral per-request accounts). In Go, the limit
counts all routing queues: MaxQueues(n) permits n usable account,
account-group, or engine-wide queues in total. In C++, it counts account queues
only. In both bindings, MaxQueues(1) permits one queue. Override with
MaxQueues(0) for unbounded growth.
Every asynchronous operation returns a future (pkg/future in Go,
openpit::asyncengine::Future<T> in C++) that resolves exactly once. Ordinary
validation and queue-submission failures may resolve synchronously. Not every
failure before enqueue does: a call that hands off ownership of a handle for
mandatory cleanup resolves only after that cleanup completes. This
includes AsyncRequest.Execute and AsyncRequest.Close, plus the
AsyncReservation and AsyncDropCopyOperation Close, CommitAndClose, and
RollbackAndClose calls. A Go reentrancy refusal transfers no ownership: when
the caller's context still carries an active lane marker for the target engine,
the future resolves synchronously with ErrReentrantLane and no cleanup runs,
leaving the handle with the caller. A context retained after its chain has
finished no longer refuses.
Go operations that mirror one return value use future.Future[T]; tuple-shaped
operations use future.Future2[A, B]. C++ typed operations return
Future<StartOutcome>, Future<ExecuteOutcome>, or the concrete result type.
AsyncRequest::Execute uses PairFuture for its reservation-or-rejects pair.
Drop copy carries the same accepted-or-rejected pair as the main stage:
future.Future2[*AsyncDropCopyOperation, []reject.Reject] in Go and
Future<asyncengine::DropCopyOutcome<Driver>> in C++, whose outcome holds a
std::shared_ptr<AsyncDropCopyOperation<Driver>> and the rejects.
The accepted reservation and drop-copy values are async wrappers, not
direct-engine handles. In both bindings, AsyncReservation and
AsyncDropCopyOperation wrap their direct-engine counterparts. Their terminal
paths are CommitAndClose, RollbackAndClose, or Close, and finalization
re-enters the producing account queue.
Both wrappers synchronously forward Lock and AccountAdjustments. The
drop-copy wrapper also forwards AccountBlock and IsAccountBlocked. These
reads do not enqueue a separate engine call.
Snapshot reads have three lifecycle states:
- Open: a read proceeds. A read admitted while open completes before native finalization starts.
-
Finalizing: a new read fails immediately with
asyncengine.ErrFinalizationInProgressin Go oropenpit::FinalizationInProgressErrorin C++. This signal is transient: it does not transfer ownership, and the caller still owes a terminal call. For a closing finalization, a later read does not become successful when finalization finishes; it receives the terminal closed error instead. -
Closed: Go returns
pretrade.ErrReservationClosedorpretrade.ErrDropCopyOperationClosed. C++ throws a plainopenpit::Errorwith the wrapper-specific closed message. This state is permanent: a later non-closing finalization cannot reopen the wrapper.
Go future operations:
-
f.Await(ctx)- block until resolved or ctx fires.Future[T]yields(T, error);Future2[A, B]yields(A, B, error). -
f.Done()- non-blocking check. -
f.TryGet()- non-blocking read. -
f.Wait()- channel that closes on resolution (forselect).
The future and the wrapped engine objects are decoupled: cancelling the
context passed to Await does not stop the underlying engine call; it only
stops the caller from waiting. In Go, call Await again or use TryGet after
resolution and finish any caller-owned handle that eventually arrives:
-
StartPreTrademay yield anAsyncRequest; callExecuteorClose. IfExecuteaccepts the order, finish the resultingAsyncReservationtoo. -
ExecutePreTradeorAsyncRequest.Executemay yield anAsyncReservation; callCommitAndClose,RollbackAndClose, orClose. -
ApplyDropCopymay yield anAsyncDropCopyOperation; callCommitAndClose,RollbackAndClose, orClose.
Do not resubmit an engine operation merely because its Await was cancelled.
Abandoning the future is not a commit or rollback.
In C++, Await() returns the value or throws the carried async error on the
waiting thread; Await(timeout) returns std::nullopt when the timeout expires.
For a move-only value, one Await call consumes it. A later Await() or
Await(timeout) throws ErrorCode::ValueConsumed. The underlying task
continues in both cases.
The submission bound controls only the wait for queue space, not the engine operation once the task is enqueued.
In Go, a submission context.Context that is already canceled is rejected
before enqueue. While waiting for queue space, the slot and ctx.Done() can
become ready together; Go may select the enqueue. Only the stored future
completion is authoritative. An error returned because Future.Await's own
wait context ended says nothing about enqueue; inspect TryGet or await again
with a separate live context. With the standard Engine driver, a stored
completion of context.Canceled or context.DeadlineExceeded comes from the
submission path and means enqueue did not happen, while a normal stored result
means it did even if the submission context is canceled by then. A capped
Dynamic strategy can store ErrQueueLimit. Submitting to a stopped engine,
or a hard stop aborting a task before its engine call starts, stores
ErrStopped. Custom driver errors also propagate unchanged. To preserve this
retry provenance, an executed custom-driver call must not return any of those
four submission-control errors, including wrappers matched by errors.Is; it
must use a distinct execution error instead.
In C++, a positive submission timeout can fail before enqueue with
ErrorCode::SubmitCancelled. A capped Dynamic strategy can fail with
ErrorCode::QueueLimit. Submitting to a stopped engine, or a hard stop aborting
a task before its engine call starts, fails with ErrorCode::Stopped.
For ApplyDropCopy, ApplyExecutionReport, and ApplyAccountAdjustment, any
of those submission or stop errors before the engine call means that an
already-occurred fact was not registered with the engine. The caller MUST
retry. For an execution report, failure to retry leaves its reservation
unreleased and its spot funds unsettled.
The C++ value overloads of ApplyDropCopy and ApplyExecutionReport copy an
lvalue or const rvalue and move a non-const rvalue. Pass an lvalue when a retry
may be required so the caller retains the original. A named const object cast
to an rvalue is copied, but only that named original remains available for
retry. An unnamed const rvalue temporary is destroyed at the end of the full
expression and does not provide a retained payload. Copying an execution report
deep-clones a fill's pre-trade lock through the C ABI when one is present and
can therefore throw.
For those value overloads, a payload whose decayed static type is exactly the
root openpit::Order or openpit::ExecutionReport is rejected at compile time.
A reference whose static type is a concrete intermediate base such as
openpit::model::Order or openpit::model::ExecutionReport, but whose dynamic
type is more derived, throws openpit::Error synchronously before ownership
transfer. A type-erased caller must use the corresponding
std::unique_ptr<const openpit::Order> or
std::unique_ptr<const openpit::ExecutionReport> overload. Those overloads
always transfer ownership. Before submission, retain the upstream payload or
an independent copy of the exact concrete value; otherwise a submission or
stop failure consumes the only payload and leaves nothing to retry.
For C++ ApplyAccountAdjustment, pass an lvalue batch to its by-value parameter
to retain the original for retry. The parameter is moved into the queued task,
so a pre-enqueue failure destroys its copy; passing std::move(...) forfeits
the caller's batch. The default non-positive timeout waits indefinitely for
queue space, but a hard stop can still abort an already-enqueued task before it
starts. Retention is therefore required even with the default timeout.
AsyncEngine requires the wrapped engine to be built with AccountSync.
Per-account correctness holds in both strategies: no two operations for
the same account ever run inside the engine concurrently, and within one
account every queued operation runs in submit order.
Go account operations use account routing keys. Account-group membership uses
the first supplied account's key; group blocking and group currency operations
use account-group routing keys; UnblockAll uses the engine-wide key.
Parallelism across different routing keys depends on the strategy. Dynamic
gives full routing-key isolation, so distinct keys are always processed in
parallel. Sharded gives per-shard serialization: distinct keys that hash to
the same shard share one worker and are processed one after another (a hot key
can block others on its shard). The C++ facade has account routing only.
Neither relaxes the per-account invariant above.
The Threading Contract of the engine itself is respected end-to-end.
AsyncRequest, AsyncReservation, and AsyncDropCopyOperation keep the same
per-account queue across the boundary. Executing a started request, or
committing, rolling back, or closing a reservation or drop-copy operation
through its wrapper, re-enters the same per-account chain.
That routing covers explicit calls only. Neither wrapper installs a destructor
hook: Go has no finalizer at all, and the C++ wrapper's implicit release runs
when the last shared_ptr owner goes away, on whatever thread drops it. So
releasing a reservation or drop-copy operation without an explicit
Close-flavored call performs its implicit rollback off the account queue.
Finish every wrapper with CommitAndClose, RollbackAndClose, or Close when
that lane matters.
Finalizing through a wrapper does not change what a mutation finalizer owes the
engine. In Go, mutation finalizers are void: a failure or panic is reported to
core, arms the engine kill switch, and does not become an async finalizer future
error. In C++, a callback exception both arms the kill switch and makes the
async wrapper future fail with ErrorCode::TaskFailed. Because every mutation
a Go or C++ policy registers is a custom-policy mutation, the block covers
every account. Drop copy is not stopped by the block, and work already in
flight on another lane may have passed the entry check before the switch was
armed. Go can clear the block in the lane with
asyncEngine.Accounts().UnblockAll(ctx); in C++ clear it on the wrapped
engine's own Accounts() handle. See
Account Blocking.
ApplyDropCopy uses the same account queue as every other operation for that
account. A readable account ID is required: an order that does not expose one
is refused up front without being queued. Go resolves the future immediately
with ErrMissingAccountID. C++ resolves the future immediately with
ErrorCode::MissingAccountId. StartPreTrade and ExecutePreTrade already
behave this way. The core would reject such an order with
MissingRequiredField before any policy callback runs, so there is nothing to
gain by queueing it. Existing account and account-group blocks do not prevent a
queued drop copy from reaching the engine.
Go also refuses an uninitialized param.AccountGroupID before queuing a direct
account-group operation or a chain sourced by that group. Those paths return
ErrUninitializedAccountGroupID. Handle 0 is a legal, initialized group that
selects the global default currency tier, so an unset group must be rejected at
the public boundary rather than caught lower in the engine. C++ has no
equivalent error because it has no account-group routing lanes.
The C++ typed facade is non-copyable but move-constructible. Its dispatch state keeps a stable address, so already-issued request and reservation wrappers stay valid after a move. Move assignment is disabled because replacing a live target could invalidate wrappers created by that target. Keep the resulting engine alive until all wrappers are released.
- Go
StopGraceful(ctx)refuses new submissions and waits for every already-queued task to run to completion. Returnsctx.Err()if ctx fires before workers drain; the engine is then partially stopped andStopHardmay be invoked to complete the shutdown. - Go
StopHard(ctx)refuses new submissions, aborts every task that has not yet started withErrStopped, and waits for the currently-running task in each worker to finish. Returnsctx.Err()if ctx fires before the in-flight task in each worker finishes. - C++
StopGraceful(timeout)andStopHard(timeout)have the same queue behavior and returntruewhen workers drained before the deadline. A hard stop resolves queued tasks withErrorCode::Stopped.
When the builder was wired via AccountSyncReadyEngineBuilder.BuildAsync,
the underlying *Engine is released when the stop completes successfully
(returns nil). If StopGraceful or StopHard returns ctx.Err()
(timeout), the engine is not yet released; the caller must complete
the shutdown (call StopHard) before the release happens. When the
caller used asyncengine.NewBuilder(engine) and did not wire
WithStopUnderlying, the engine handle remains owned by the caller.
asyncengine.Observer in Go and openpit::asyncengine::Observer in C++ are
optional diagnostic interfaces. The default is NoopObserver.
Wire only the methods you need - the package itself
has zero external observability dependencies (no OpenTelemetry,
Prometheus, or logging in the import graph), so you decide where the
signals go.
Available callbacks: OnEnqueue, OnDequeue, OnComplete,
OnSlowSubmit, OnQueueFullBlocked, OnQueueCreated, OnQueueRemoved,
OnSubmitCancelled.
Observer callbacks are diagnostic only. In C++, exceptions thrown by an observer are ignored so observability cannot change queue or task semantics.
Callback ID semantics differ by binding. In Go, each callback projects the
routing key's numeric ID into AccountID: account lanes carry the account ID,
account-group lanes carry the group ID, and the engine-wide lane carries 0.
Equal numeric IDs are not unique across routing kinds, so AccountID(0) is not
account-zero-only in Go. In C++, every callback ID is a real account ID, and
AccountID(0) means account zero only.
package main
import (
"context"
"log"
"time"
"go.openpit.dev/openpit"
"go.openpit.dev/openpit/model"
"go.openpit.dev/openpit/param"
"go.openpit.dev/openpit/pretrade/policies"
)
func main() {
// Build an AccountSync engine and wrap it into an async facade in one
// chain. BuildAsync is only available on the AccountSync builder; for
// FullSync or NoSync engines the bundled async helper is not used.
asyncBuilder, err := openpit.NewEngineBuilder().
AccountSync().
Builtin(policies.BuildOrderValidation()).
Builtin(
policies.BuildRateLimit().BrokerBarrier(
policies.RateLimitBrokerBarrier{
Limit: policies.RateLimit{
MaxOrders: 100,
Window: time.Second,
},
},
),
).
BuildAsync()
if err != nil {
log.Fatal(err)
}
// Pick a dispatch strategy. Use Sharded for cheap routing across a
// balanced routing-key population; use Dynamic for routing-key isolation
// and routing-queue metrics.
async, err := asyncBuilder.Dynamic().
MaxQueues(0).
IdleCleanupAfter(5 * time.Minute).
Build()
if err != nil {
log.Fatal(err)
}
defer func() {
if err := async.StopGraceful(context.Background()); err != nil {
log.Printf("StopGraceful: %v", err)
}
}()
// Submit a start-stage call. The future resolves once the worker has
// executed the call. AsyncRequest.Execute and Close are queued in the
// same per-account chain so AccountSync is never violated.
usd, err := param.NewAsset("USD")
if err != nil {
log.Fatal(err)
}
aapl, err := param.NewAsset("AAPL")
if err != nil {
log.Fatal(err)
}
order := model.NewOrder()
op := order.EnsureOperationView()
op.SetInstrument(param.NewInstrument(aapl, usd))
op.SetAccountID(param.NewAccountIDFromUint64(99224416))
op.SetSide(param.SideBuy)
price, err := param.NewPriceFromString("185")
if err != nil {
log.Fatal(err)
}
qty, err := param.NewQuantityFromString("100")
if err != nil {
log.Fatal(err)
}
op.SetTradeAmount(param.NewQuantityTradeAmount(qty))
op.SetPrice(price)
request, rejects, err := async.StartPreTrade(
context.Background(),
order,
).Await(context.Background())
if err != nil {
log.Fatal(err)
}
if request == nil {
// Rejected at the start stage; inspect rejects.
_ = rejects
return
}
// The async request preserves AccountSync across the Start - Execute
// boundary by routing Execute through the same per-account queue. The
// future yields the same (reservation, rejects, error) tuple the
// synchronous main stage returns.
reservation, rejects, err := request.Execute(
context.Background(),
).Await(context.Background())
if err != nil {
log.Fatal(err)
}
if reservation == nil {
_ = rejects
return
}
if _, err := reservation.CommitAndClose(
context.Background(),
).Await(context.Background()); err != nil {
log.Fatal(err)
}
}In Go, Submit(ctx, accountID, fn) enqueues caller-owned work into the same
per-account queue. In C++, Submit(accountId, std::function<void()>, timeout)
enqueues caller-owned work into the same per-account queue. Use it to run
client-side work atomically with respect to engine calls on the same account -
for example, to persist an order before Execute, or update a strategy book
after Commit.
Splitting a logical transaction into two Submit calls "surfaces"
between them: tasks for the same account from elsewhere in the system
can interleave between the two halves. To keep that boundary closed,
bundle the work into a single Submit whose fn does both halves.
Chains are a Go-binding feature that replaces the manual Submit pattern when
one account-lane task must combine caller work with several engine operations.
The C++ binding has no equivalent. Chain(source, begin) makes one
ChainBuilder[State]: a model.Order source routes by that order's account
and enables order-only steps, while a param.AccountID source enables report
and account-adjustment steps. begin creates the one caller-owned State
inside the account lane, with its type inferred from the function.
Use named hook structs for operations that produce engine outcomes:
PreTradeHooks (OnRejected, OnReserved), DropCopyHooks (OnRejected,
OnApplied), ExecutionReportHooks (Report, OnSettled), and
AccountAdjustmentHooks (Adjustments, OnAdjusted). CheckOrder and
Then take functions directly. An accepted-operation hook returns
DecisionCommit to apply the operation or DecisionRollback to end the
chain. An unsupported decision also rolls the operation back and fails the
chain.
Hooks receive caller State plus only a narrow operation result, never an
engine, driver, queue, future, or lifecycle object. OperationResult,
BlockingOperationResult, and DropCopyResult are readable only while their
hook runs; after that their reads return ErrChainResultUnavailable.
ApplyDropCopy does not stop because an account was already blocked.
DropCopyResult.IsAccountBlocked returns the apply-time blocked-state
snapshot for the order account, including a block that existed before drop
copy ran; the snapshot is captured before ApplyDropCopy returns and does
not track later registry changes. OrderCheckResult keeps Passed and
Rejects readable after its hook, but its operation reads have the same
hook-only lifetime.
A hook context retains the submitted context's values but not its cancellation or deadline. The submitted context bounds queue admission only.
Chain.CheckOrder is the only asynchronous full-pipeline dry-run path.
Start-stage dry run remains synchronous-only. A failed verdict itself does not
end the chain. Run resolves a ChainOutcome with or without a terminal hook,
and its Err agrees with the error returned by Await whenever Await
returned an outcome. When Await returns ctx.Err() instead, it yields the zero
ChainOutcome - status ChainOutcomeUnknown, no Err, RetryUnsafe false -
and that outcome must not be interpreted. It reports only that the wait ended:
waiting never cancels the chain, and the chain may still be running or may
already have finished, so the caller re-awaits or uses TryGet. The
outcome passed to Finally describes the chain before that hook runs; the
outcome resolved into the future describes the whole run, including
Finally.
Finally returns a ChainRunner, and that runner's Run is the only way to
run a chain with a terminal hook. Finally runs after a successful begin and
a started chain - on completion, rejection, failure, or panic - after native
cleanup and before the future resolves. If begin fails or panics, there is no
State and Finally does not run. Validation and queue-submission failures, plus
any pre-start abort including hard stop and idle-queue retirement or drain,
invoke no chain callbacks: Begin, step hooks, and Finally do not run.
AsyncEngine observer callbacks may still run. Mandatory cleanup cannot live
in Finally alone. Run itself carries that guarantee: with or without a
terminal hook, it resolves the ChainOutcome only after every engine call,
hook, and required handle cleanup - a pending reservation or drop-copy
operation is already rolled back and closed by the time the future resolves.
A whole chain is one account-lane task, so its caller work and engine calls
cannot interleave with other work for that account. It is not atomic across
steps, so the caller tracks its own progress in State. RetryUnsafe is
conservative: it reports that the chain entered an engine call that can
change state, not that a mutation is known to have happened. It is
meaningful on ChainOutcomeCompleted, ChainOutcomeRejected, and
ChainOutcomeFailed; on a rejected or completed outcome it is the only
signal that rerunning may be unsafe, because neither carries an error to wrap
the sentinel in. It is not meaningful on ChainOutcomeUnknown, the zero value
Await yields on ctx.Err(): its RetryUnsafe: false is never permission to
rerun a chain that may still be running or may already have changed state.
On a failed outcome, Err wraps ErrChainRetryUnsafe when the SDK cannot
prove nothing changed. Rerunning is unsafe; the sentinel does not claim a
mutation happened.
In Go, submitting another operation to the same engine with a context that
contains an active lane marker for that engine anywhere in its marker stack,
including below inactive or other-engine inner markers, resolves its future with
ErrReentrantLane rather than waiting on the hook's own lane. A retained
context whose target-engine marker has become inactive no longer refuses. The
marker is diagnostic only: caller code that supplies an unrelated context
bypasses it. A hook must never synchronously re-enter the engine with any
context, because its running chain occupies the account lane.
package main
import (
"context"
"fmt"
"log"
"go.openpit.dev/openpit"
"go.openpit.dev/openpit/asyncengine"
"go.openpit.dev/openpit/model"
"go.openpit.dev/openpit/param"
"go.openpit.dev/openpit/pretrade"
"go.openpit.dev/openpit/pretrade/policies"
"go.openpit.dev/openpit/reject"
)
func main() {
asyncBuilder, err := openpit.NewEngineBuilder().
AccountSync().
Builtin(policies.BuildOrderValidation()).
BuildAsync()
if err != nil {
log.Fatal(err)
}
async, err := asyncBuilder.Dynamic().Build()
if err != nil {
log.Fatal(err)
}
defer func() {
if err := async.StopGraceful(context.Background()); err != nil {
log.Printf("StopGraceful: %v", err)
}
}()
aapl, err := param.NewAsset("AAPL")
if err != nil {
log.Fatal(err)
}
usd, err := param.NewAsset("USD")
if err != nil {
log.Fatal(err)
}
accountID := param.NewAccountIDFromUint64(99224416)
instrument := param.NewInstrument(aapl, usd)
order := model.NewOrder()
orderOperation := order.EnsureOperationView()
orderOperation.SetAccountID(accountID)
orderOperation.SetInstrument(instrument)
orderOperation.SetSide(param.SideBuy)
quantity, err := param.NewQuantityFromString("100")
if err != nil {
log.Fatal(err)
}
orderOperation.SetTradeAmount(param.NewQuantityTradeAmount(quantity))
price, err := param.NewPriceFromString("185")
if err != nil {
log.Fatal(err)
}
orderOperation.SetPrice(price)
type state struct {
report model.ExecutionReport
reservationLock pretrade.Lock
instrument param.Instrument
accountID param.AccountID
adjustmentCount int
settled bool
persisted bool
}
runner := asyncengine.Chain(order, func(context.Context) (*state, error) {
return &state{
accountID: accountID,
instrument: instrument,
}, nil
}).
ExecutePreTrade(asyncengine.PreTradeHooks[*state]{
OnRejected: func(
context.Context,
*state,
[]reject.Reject,
) error {
return fmt.Errorf("pre-trade order was rejected")
},
OnReserved: func(
_ context.Context,
got *state,
result asyncengine.OperationResult,
) (asyncengine.Decision, error) {
lock, err := result.Lock()
if err != nil {
return asyncengine.DecisionRollback, err
}
adjustments, err := result.AccountAdjustments()
if err != nil {
return asyncengine.DecisionRollback, err
}
got.reservationLock = lock
got.adjustmentCount = len(adjustments)
return asyncengine.DecisionCommit, nil
},
}).
ApplyExecutionReport(asyncengine.ExecutionReportHooks[*state]{
Report: func(
_ context.Context,
got *state,
) (model.ExecutionReport, error) {
report := model.NewExecutionReport()
reportOperation := report.EnsureOperationView()
reportOperation.SetAccountID(got.accountID)
reportOperation.SetInstrument(got.instrument)
reportOperation.SetSide(param.SideBuy)
got.report = report
return report, nil
},
OnSettled: func(
_ context.Context,
got *state,
_ pretrade.PostTradeResult,
) error {
got.settled = true
return nil
},
}).
Then(func(_ context.Context, got *state) error {
// Persist caller-owned metadata after the execution report settles.
got.persisted = true
return nil
}).
Finally(func(
_ context.Context,
got *state,
outcome asyncengine.ChainOutcome,
) error {
if outcome.Status != asyncengine.ChainOutcomeCompleted {
return fmt.Errorf("chain finished with %v", outcome.Status)
}
log.Printf("settled=%t persisted=%t", got.settled, got.persisted)
return nil
})
chainFuture := runner.Run(context.Background(), async)
// context.Background cannot be cancelled, so Await always yields a verdict
// and ChainOutcomeUnknown is unreachable here. With a bounded context,
// Unknown means only that the wait ended: the chain may still be running or
// may already have finished. Re-await with another caller-controlled context
// or use TryGet before interpreting the outcome.
outcome, err := chainFuture.Await(context.Background())
if outcome.Status == asyncengine.ChainOutcomeUnknown {
log.Fatal("chain Await() returned no outcome with a non-cancellable context")
}
if err != nil {
if outcome.RetryUnsafe {
log.Fatalf("chain failed and must not be retried: %v", err)
} else {
log.Fatalf("chain failed before retry became unsafe: %v", err)
}
}
log.Printf(
"chain status=%v retryUnsafe=%t",
outcome.Status,
outcome.RetryUnsafe,
)
}- Aborted tasks resolve their future with
ErrStoppedin Go andErrorCode::Stoppedin C++. - In both bindings,
AsyncReservationandAsyncDropCopyOperationmethods that "and close" (Close,CommitAndClose,RollbackAndClose) release the underlying handle as a safety net even on abort. An aborted plainCommitorRollbackdoes not release it, so the caller must still close the wrapper. - In both bindings, an aborted
AsyncRequest.Executereleases the underlying request before resolving the future. The caller does not need a separate cleanup path. - The Go
ctxand C++timeoutpassed to a submit method control how long the producer waits for queue space. Once queued, the worker runs or aborts the task.
- Threading Contract: per-mode sync contract that AsyncEngine respects.
- Pre-trade Pipeline: the Request/Reservation lifecycle that AsyncRequest/AsyncReservation wraps.
- Getting Started: the synchronous flow that AsyncEngine layers on top of.
- Account Blocking: the engine-wide block and the mutation finalizer contract that queued finalization is subject to.