Skip to content

feat(sdk): add an opt-in order batcher with bounded queues and per-order results - #170

Merged
joeblau merged 2 commits into
mainfrom
feat/159-order-batcher
Oct 7, 2026
Merged

joeblau merged 2 commits into
mainfrom
feat/159-order-batcher

Conversation

@joeblau

@joeblau joeblau commented Oct 7, 2026

Copy link
Copy Markdown

Summary

Adds createOrderBatcher (@bloxwap/hyperliquid/actions/orderBatcher, also re-exported from actions): an opt-in helper that collects independent single-order callers into shared order actions, signs and posts once per batch, and settles each caller with its own status. Existing order APIs are untouched.

  • Lifecycle: enqueue(order, options), flush(), close({ drain }), pending. Configurable maxQueueSize (queued + in-flight, default 1000), maxBatchSize (default 100), flushIntervalMs (default 1), and an injectable runtime (Inject transport runtime dependencies for isolated, deterministic tests #158).
  • Partitioning: grouping, builder, vault, expiresAfter, and order class. ALO is kept apart from IOC/GTC to preserve its prioritization. Under priority grouping ({ p }), where the exchange requires all-IOC or all non-reduce-only ALO, orders are also split by tif and reduce-only, so one caller's order cannot invalidate another's batch. Signer/network is fixed per batcher (bound to one config); the only action type is order.
  • Per-order outcomes: an ApiRequestError from mixed statuses is unwrapped from its retained response, every status is shape-checked, and each caller gets its own status (resting/filled/waitingFor*/{ error }). A top-level rejection, cardinality mismatch, or transport failure rejects every caller in that batch.
  • Backpressure / cancellation / shutdown: a full queue rejects immediately. Aborting before dispatch removes the order. Aborting after dispatch only stops that caller waiting, and its slot stays occupied until the batch settles. An expired expiresAfter rejects before sending. close() drains; close({ drain: false }) rejects waiters without implying server cancellation. Ambiguous batches are never retried.
  • Execution core (Expose a typed build → sign → submit API for exchange actions #157): each order is validated and canonicalized per caller at enqueue (so a malformed order rejects only its own call). The batch is assembled with canonicalAction from those validated parts and run through executeAction, so it uses the same nonce, dispatch-policy (Add an explicit bounded dispatch policy for out-of-order signing completion #160), and transport paths as exchange.execute, and orders are not validated a second time. A test checks wire parity with buildOrder for the same orders.

Acceptance criteria

  • Compatible enqueue calls share one signature/request; incompatible options and order classes are separated. Covered by the flush batches compatible callers... test (1 request, 1 signature), plus a partitioning test covering builder, vault, expiresAfter, grouping, ALO vs GTC/IOC, and the priority-grouping class split.
  • Every queued caller settles exactly once, with the status matching its original order. _settle is guarded; statuses map by index; _batcherCounts.test.ts checks that cancelled/flushed generations do not skew thresholds.
  • Mixed success/error responses preserve successful outcomes; whole-batch failure settles every affected caller. Both have tests, including cardinality mismatch and malformed statuses.
  • Size/time thresholds, explicit flush, queue overflow, cancellation (queued and post-dispatch), expiry, and shutdown (drain and abort) are covered on FakeRuntime, with pendingTimers == 0 checks.
  • Nonces are allocated per submitted batch (the test asserts one distinct nonce per request), and existing quota accounting is respected. A new test runs the batcher over HttpTransport with rateLimit and shows an 80-order batch charges exactly 3 weight (1 + floor(80/40)), not 80.
  • Existing order APIs keep their immediate-dispatch and error behavior. A new test runs ExchangeClient.order next to a batcher with queued orders: it dispatches immediately, and still rejects mixed responses with ApiRequestError, while the batcher returns per-order outcomes.
  • Throughput, signature/request counts, queue depth, and caller p50/p99 were compared across batch sizes and flush intervals: .dev/perf/order_batching.ts (results below).
  • The throughput/latency tradeoff and the partial-result contract are documented in the new "Optional order batching" section of clients.md, along with cancellation semantics, close/drain, no retry, REST weight 1 + floor(n/40) vs. address-based limits, and ALO separation.

Measurements

bun .dev/perf/order_batching.ts: 300 single-order callers, real secp256k1 signing (viem account), 5 ms mock transport, median of 5 alternating rounds. Bun 1.4.0-canary.1, Apple M3 Max. These are wall-clock timings with mocks, not production latency. "direct" means one order() call per caller. peakQueue is batcher.pending (queued plus in-flight), or outstanding calls for direct.

arrival mode maxBatchSize flushIntervalMs orders/s p50 ms p99 ms peakQueue signatures requests weight
burst direct - - 3797.87 73.92 75.63 300 300 300 300
burst batcher 1 0 3872.70 70.38 71.34 300 300 300 300
burst batcher 1 1 4552.40 64.36 64.65 300 300 300 300
burst batcher 1 5 4370.25 66.47 66.83 300 300 300 300
burst batcher 5 0 15451.14 18.10 18.73 300 60 60 60
burst batcher 5 1 16017.41 13.12 17.89 300 60 60 60
burst batcher 5 5 14619.85 18.95 19.55 300 60 60 60
burst batcher 25 0 30269.53 8.96 9.34 300 12 12 12
burst batcher 25 1 20760.35 8.70 13.64 300 12 12 12
burst batcher 25 5 37110.92 7.28 7.64 300 12 12 12
burst batcher 100 0 43311.14 6.15 6.72 300 3 3 9
burst batcher 100 1 28725.66 9.72 10.31 300 3 3 9
burst batcher 100 5 42768.04 6.31 6.91 300 3 3 9
1/ms direct - - 906.13 5.12 6.51 6 300 300 300
1/ms batcher 1 0 940.99 5.11 6.03 6 300 300 300
1/ms batcher 1 1 926.78 5.11 5.91 6 300 300 300
1/ms batcher 1 5 933.14 5.11 5.93 6 300 300 300
1/ms batcher 5 0 744.90 6.37 7.28 6 300 300 300
1/ms batcher 5 1 740.25 6.40 7.27 6 300 300 300
1/ms batcher 5 5 801.39 8.09 10.53 9 67 67 67
1/ms batcher 25 0 735.33 6.42 7.28 6 300 300 300
1/ms batcher 25 1 735.01 6.42 7.30 6 300 300 300
1/ms batcher 25 5 791.49 8.16 10.69 10 69 69 69
1/ms batcher 100 0 738.61 6.42 7.35 6 300 300 300
1/ms batcher 100 1 684.11 6.46 9.17 6 300 300 300
1/ms batcher 100 5 675.95 9.12 12.31 9 75 75 75

How to read it:

  • Bursts: batching cuts signatures, requests, and weight by up to 100x and improves throughput about 11x and p50 about 12x at batch size 100.
  • Steady arrivals (1/ms): a flush interval shorter than the gap between orders sends batches of one and only adds a timer hop (about +1.3 ms p50). A 5 ms interval cuts signatures and requests about 4x for about +4 ms p50.
  • Throughput at 1/ms is bounded by the arrival rate. The lower figures for batcher rows reflect that rate plus the queueing delay, not lost capacity.

Notes:

  • Why not tests/perf: the measurements live in .dev/perf/order_batching.ts, not in the tests/perf transaction suite the issue links. The Performance workflow fingerprints the whole tests/perf source tree and fails closed when it differs between base and head, so adding a scenario there would fail the gate for this PR. The .dev/perf script follows the existing remote_signing.ts pattern.
  • Excluded from this PR: the snapshot's hill_climb.ts queue block and the combined api_design.ts harness are not on main and are not needed by this issue.

Test plan

  • bun run check (format, lint, docs, types, ts7, jsdoc, exports, import budgets)
  • bun run test:offline: 2071 pass, 0 fail
  • bun run build, including the published-consumer type check for createOrderBatcher / OrderOutcome
  • bun .dev/perf/order_batching.ts

Closes #159

🤖 Generated with Claude Code

joeblau and others added 2 commits October 7, 2026 08:21
…der results

createOrderBatcher (actions/orderBatcher) collects independent single-order
callers into shared order actions: one signature, one nonce, and one request
per batch, with each caller settling exactly once with its own status.

- Size, time, and explicit flush thresholds; bounded queue with immediate
  rejection when full; queued and post-dispatch cancellation; expiry; close
  with or without drain. Never retries an ambiguous batch.
- Partitions by grouping, builder, vault, expiresAfter, and order class (ALO
  apart from IOC/GTC; split by tif and reduce-only under priority grouping).
- Mixed responses keep successful items; whole-batch rejection, cardinality
  mismatch, and transport failure reject every caller in the batch.
- Orders are validated per caller at enqueue and the batch is executed through
  the shared executeAction core without validating each order again.
- Tests on FakeRuntime, including wire parity with order(), immediate dispatch
  of ExchangeClient.order next to a batcher, and HTTP weight charged once per
  batch. Benchmark in .dev/perf/order_batching.ts; docs in clients.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TP/SL groupings link the orders of one action, so batching independent
callers under them could attach one caller's stop-loss to another
caller's entry order. enqueue now accepts only "na" and { p }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@joeblau
joeblau merged commit b83a626 into main Oct 7, 2026
6 checks passed
@joeblau
joeblau deleted the feat/159-order-batcher branch October 7, 2026 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an opt-in order batching helper with bounded queues and per-order results

1 participant