Adaptive batch size tuning under load
Skill HimanshuJ16/Algo-Trading-Skills/skills/adaptive-batch-size-tuning-under-load
Use when writing market data or order logs to downstream databases (TimescaleDB, ClickHouse) or message brokers to dynamically adapt write batch sizes and flush timeouts based on queue pressure and sink write latency. Hysteresis-shaped, EWMA-smoothed, back-pressure-aware. Single-writer / multi-producer thread-safe engine, not a generic embedder.From its SKILL.md
npx -y skills add HimanshuJ16/Algo-Trading-Skills --skill adaptive-batch-size-tuning-under-loadAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its file declares
Copied from the file, not written here
The file declares its own license as Apache-2.0. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
10.2 KB, as published. Nobody here has run it
When to Use
Invoke this skill when persistently writing high-volume tick feeds or trading logs into downstream databases (TimescaleDB, ClickHouse, InfluxDB) or message queues (Kafka, Redis Streams). Hardcoding a static batch size is wrong across regimes — it causes high persistence latency when markets are quiet (waiting for fixed batch limits to fill) and DB I/O overload during flash crashes.
The skill produces an AdaptiveBatchTunerEngine whose job is to scale the
batch size B_t and flush timeout T_flush in response to two signals:
- Smoothed queue fill ratio
R_ewma = EWMA(Q_current / Q_capacity) - Smoothed downstream write latency (target:
target_write_latency_ms)
When NOT to Use
- Single-shot or batch-mode loads — if the total record count is bounded and known upfront, fine-tune one batch size statically. The adaptive engine adds no value when input is finite.
- Synchronous request/response flows — this is fire-and-forget throughput tuning, not per-request optimisation. Use RPC-style tuning instead.
- Distributed stream processing stages (Flink, ksqlDB, Spark Structured Streaming) — they have their own internal flush predicates; this engine is for the client-side write path into the sink, not for the consumer side of an internal stage.
- Latency-critical tick-to-trade paths — those need synchronously bounded
writes; the entire batch-and-flush taxonomy is wrong. See
tick-to-trade-latency-measurementinstead. - When the sink is a queue with built-in batching (Kafka producer with
linger.ms/batch.size) AND the queue is the only consumer — let Kafka's producer do the tuning.
Prerequisites
- A downstream sink write function accepting batches of records (caller loop).
- Capacity constants declared up front:
B_min— minimum batch size (records; default 10)B_max— maximum batch size (records; default 1000)T_min/T_max— flush-timeout bounds (default 50ms / 1000ms; aligned with ClickHouse's adaptive-busy-timeout range)L_target— downstream write-latency target (default 50ms)
- Queue capacity consistent throughout the lifecycle; pass via
TuningConfig.queue_capacity. - Sink latency instrumentation — caller must call
record_write_latency(ms)on every successful (or failed) write.
Workflow
-
Construct the engine with a
TuningConfig:from batch_tuner import AdaptiveBatchTunerEngine, TuningConfig tuner = AdaptiveBatchTunerEngine(TuningConfig( min_batch_size=10, max_batch_size=1000, initial_batch_size=100, target_write_latency_ms=50.0, queue_capacity=2000, max_queue_size=5000, )) -
Produce-loop pattern (the contract):
try: batch = tuner.add_item(item) except QueueFullError as exc: handle_overload() # backpressure / degrade / drop continue if batch is None: continue # not yet a flush boundary t0 = time.monotonic() try: sink_write(batch) finally: tuner.record_write_latency((time.monotonic() - t0) * 1000.0) -
Adapt batch size (under the hood):
- High load (
R_ewma > 0.70):B_{t+1} = min(B_max, ⌊B_t × 1.5⌋); reduceT_flush. - Low load (
R_ewma < 0.10):B_{t+1} = max(B_min, ⌊B_t / 1.2⌋); extendT_flush. - Deadband (
0.10 ≤ R_ewma ≤ 0.70): no tuning — EWMA smoothing prevents oscillation around the boundary.
- High load (
-
Apply latency feedback (overrides fill-ratio tuning under sustained pressure):
- If
EWMA(L) > L_target:B_{t+1} = max(B_min, ⌊B_t × 0.8⌋). Latency throttle triggers even inside the deadband, which is its purpose.
- If
-
Flush triggers:
- Threshold:
#items ≥ current_batch_size. - Timeout:
elapsed ≥ current_flush_timeout_sec. - Forced flush:
tuner.flush_now()for shutdown / checkpoint boundaries.
- Threshold:
-
Shutdown:
leftover = tuner.close() if leftover: sink_write(leftover)
Full procedure with rationale:
references/workflows.md. Concrete numeric thresholds:references/standards.md. Operational checklist:assets/checklist.md.
Decision Points
| Situation | Action |
|---|---|
Sink write latency consistently > L_target | Engine is auto-throttling (×0.8). If still failing, lower target_write_latency_ms to force aggressive throttle, or activate circuit breaker upstream. |
QueueFullError raised | Caller is producing faster than sink drains. Implement back-pressure (block producer), degradation (drop new items), or fall back to a slower sink. |
| Fill ratio hovers at boundary (0.10 or 0.70) | Deadband is working — there should be no batch-size oscillation. Verify via tuner.get_status().total_tuning_transitions. |
Overflow .record_write_latency(0) — i.e. hot cache made flush look instant | Engine still trusts the smoothed EWMA — single low-variance samples won't reset the throttle. |
| Sustained high fill with no rule violations | Lower max_batch_size for the workload, or migrate to a faster sink. |
Test opposing add_item() from many threads | Engine is thread-safe; one engine per sink. Do not instantiate per add. |
Common Pitfalls
- Unbounded max batch size → queued memory can exceed sink RAM. Always set
max_queue_size; the engine raisesQueueFullErrorat the cap. - Ignoring latency feedback → batch escalates under DB lock contention,
exhausting the connection pool. The
target_write_latency_msknob is the tripwire; tune it to your sink's healthy floor. - Rapid oscillations (solved by the new deadband / EWMA combination): if
fill ratio flickers around one threshold and the engine thrashes, increase
fill_ewma_alpha(lower alpha = more smoothing) — but never go to 0. - Latency oscillation under repeated
record_write_latency()spikes: if smoothed latency is atL_target - epsilonand individual writes push it slightly above, the engine is repeatedly halving-and-climbing. Adjusttarget_write_latency_msto align with the typical sink latency, not the ideal. - Synchronous batch-handling logic: do not block on a slow sink while holding the queue; return the batch and flush outside any locks. The engine holds its lock only for batch extraction + tuning decisions.
- Caller-driven capacity drift: the previous API required
queue_capacityon every call. The new API bakes it intoTuningConfig; verify upstream that the call sites have been migrated. - Cold-start EWMA (
fill_ewma_ratio == 0): smoothing starts at zero and the first burst of adds looks like "low load" until the EWMA warms up. Setinitial_fill_ewma(deferred) or accept that the first minute of operation is slightly micro-tuned. - ClickHouse async_insert
wait_for_async_insert = 0(fire-and-forget): the engine'srecord_write_latency()will see 0 ms for "successful" flushes, masking real DB-side problems. Usewait_for_async_insert = 1.
Verification
Run the unit tests:
python -m unittest discover -s skills/adaptive-batch-size-tuning-under-load/scripts -v
What they assert:
- Low-load regime shrinks batch size below initial.
- High-load regime expands batch size above initial.
- Latency throttle fires at and above target.
- EWMA smoothing with
alpha = 0.5produces the expected smoothed value. - Bounded queue:
add_itempastmax_queue_sizeraisesQueueFullError. - Deadband: in the [0.10, 0.70] zone, no tuning transitions occur.
flush_now()extracts up tocurrent_batch_sizeitems;close()drains.reset()returns to initial state.RecordingWriteLatencyrejects negative values.- Configuration validation:
min > max,alpha in (0, 1], threshold ordering. - Status JSON-serializable (Prometheus-ready).
Confirm with the operational checklist in assets/checklist.md before deploying.
Success Criteria
A tuning engine is considered healthy in production when:
total_tuning_transitionsis bounded — under steady load it should be O(tens per hour), not O(thousands). Sustained high transition count ⇒ noisy upstream or mis-tunedtarget_write_latency_ms.- Sink write latency P99 <
L_targetover a rolling 1-hour window. QueueFullErrorrate is 0 (upstream throughput matches or exceeds sink drain). If non-zero, page on-call.current_batch_sizeandcurrent_flush_timeout_secsettle inside their ranges within 5 minutes of traffic beginning.get_status()exports cleanly to a JSON metrics pipeline.
Related Skills
kafka-based-tick-distribution-at-scale— the parent batch architecture for Kafka paths; this skill is the client-side tuning companion.producer-consumer-tick-pipeline— the broader produce/consume pipeline; this skill is the leaf that decides when to flush to the sink.tick-buffering-burst-handling— what to do when the queue genuinely overflows;QueueFullErrorfrom this skill should be the trigger.backpressure-drop-degrade-policy— design the policy that decides what to do whenQueueFullErrorfires.kill-switch-and-drawdown-circuit-breakers— strategy-level circuit breaker upstream of this engine; pair them so strategy stops suppress writes.latency-monitoring-percentile-based-slas— for monitoring the sink's P99/P999 againstL_target.model-inference-latency-budget-for-live-trading— analogous pattern for model inference instead of DB writes.