Skip to content

feat(data): D5 opt-in bounded concurrency with a global rate limiter - #51

Merged
StackOverFlow11 merged 1 commit into
mainfrom
feat/data-layer-d5-global-rate-limited-concurrency
Jun 19, 2026
Merged

feat(data): D5 opt-in bounded concurrency with a global rate limiter#51
StackOverFlow11 merged 1 commit into
mainfrom
feat/data-layer-d5-global-rate-limited-concurrency

Conversation

@StackOverFlow11

Copy link
Copy Markdown
Owner

Summary

Data Layer D5 — opt-in bounded concurrency for the data-update / cache-warm fetch stage, guarded by one shared global rate limiter. Speeds up wide-universe gap fetching when explicitly enabled, without violating the Tushare quota and without changing existing serial behavior.

Default is fully serial: old configs validate unchanged, default max_workers=1, data.cache.enabled=false / research / backtest paths behave as before, no live Tushare call is needed for acceptance.

What changed

  • data/feed/scheduler.py (new) GlobalRateLimiter — a thread-safe ticket limiter: reserves the next evenly-spaced slot under a lock, sleeps outside the lock, so N workers funnel through one per-minute budget (quota never multiplied per thread). monotonic/sleep injectable; sees only an integer budget — no token / kwargs / exception payload.
  • data/feed/throttle.py request_with_retry gains scheduler: when set, acquire a global slot before every attempt (incl. retries) and skip the per-call rate_limit sleep; when unset, the historical per-call throttle (unchanged).
  • feeds (tushare_feed/flags/fina/covariates/intraday + index_feed): optional scheduler ctor param (default None = unchanged), forwarded to request_with_retry.
  • data/cache/tushare_cache.py TushareCache(max_workers=1): ==1 keeps the byte-identical serial read-through; >1 (and >1 symbols) fans the planned dense per-symbol gap FETCH onto a bounded ThreadPoolExecutor, then upserts/records on the main thread in deterministic plan order. Each successful gap is durable before the first failure is re-raised; a failed gap records no coverage and stays retryable. Store/ledger contents are independent of fetch completion order. Snapshot / index_weight paging / intraday remain serial in D5.
  • config data_update.concurrency.max_workers (default 1; <1 rejected readably); config/data_update.yaml documents the block at max_workers: 1.
  • updater run_data_update builds one GlobalRateLimiter (from rate_limit_per_min) only when max_workers>1, shared by all feeds via _build_feeds; cache gets max_workers; UpdateResult + format_summary surface max_workers and the global rate_limit_per_min (non-secret).

Proof global ≠ per-thread

GlobalRateLimiter reserves slots under a lock; with a frozen fake clock, sequential acquire()s record escalating waits [0.5, 1.0, 1.5] (a per-thread limiter would record a constant wait). A real-threads test (N=8, barrier-synchronized) asserts next_allowed == N*interval and sum(sleeps) == interval*N*(N-1)/2 — N reservations consumed N global slots regardless of interleaving.

Invariants preserved

Cache public methods / return frames; ledger paths/columns/coverage semantics (ok/empty count; failed/not_ready do not); retry semantics (transient retry, permanent failure leaves gap uncovered+retryable, earlier successes stay recorded, empty counts); no factor/alpha/portfolio/backtest math change; no config behavior change unless explicitly opted in. phase0 anchor 0.9600/0.8408 unchanged.

Tests / gates

  • tests/test_scheduler.py + tests/test_data_update_concurrency.py (network-free, fake clocks/spies/synthetic feeds, 21 tests): global spacing escalation; global-not-per-thread under real threads; retries acquire per attempt; scheduler-mode skips per-call sleep; secret-safe failure; feed forwards its scheduler; config validation (max_workers<1 rejected, default 1); serial==concurrent frames/ledger/request-counts; failure leaves gap uncovered+retryable while successes stay durable; empty / not_ready unchanged; single-symbol stays serial.
  • Full suite 675 → 677 passed (was 656; +21). ruff clean. All 17 configs validate-config. run-phase0 anchor 0.9600 / 0.8408. git diff --check clean; no merge markers. Secret scan over changed files: real token value 0 hits (the only tushare.token/.config.json literals are pre-existing token_key/secret-path config defaults). scheduler.py is transport-agnostic (no secret).

Out of scope (not started)

Endpoint schema registry, PanelStore append/partition, data-quality changes, factor/alpha/portfolio/runtime research changes. index_weight paging + intraday cache + snapshots remain serial in D5.

Live data-update against Tushare was not run (acceptance is network-free).

Opt-in bounded concurrency for the data-update / cache-warm fetch stage, guarded
by ONE shared global rate limiter. Default stays fully serial: every existing
config validates and behaves byte-identically, default max_workers is 1, no live
Tushare call is needed for acceptance.

- scheduler: new data/feed/scheduler.py GlobalRateLimiter — a thread-safe ticket
  limiter that reserves the next evenly-spaced slot under a lock and sleeps
  outside it, so N workers funnel through ONE per-minute budget (the quota is
  never multiplied per thread). monotonic/sleep are injectable for fake-clock
  tests; it sees only an integer budget (no token / kwargs / exception payload).
- throttle: request_with_retry gains a `scheduler` param — when set it acquires a
  global slot before EVERY attempt (including retries) and skips the per-call
  rate_limit sleep; when unset, behavior is the historical per-call throttle.
- feeds: tushare_feed/flags/fina/covariates/intraday + index_feed take an optional
  `scheduler` (default None = unchanged) and forward it to request_with_retry.
- cache: TushareCache gains `max_workers` (default 1). max_workers==1 keeps the
  byte-identical serial read-through; >1 (and >1 symbols) fans the planned dense
  per-symbol gap FETCH onto a bounded ThreadPoolExecutor, then performs store
  upserts + ledger writes on the MAIN thread in deterministic plan order. Each
  successful gap is made durable before the first failure is re-raised; a failed
  gap records no coverage and stays retryable. Store/ledger contents are
  independent of fetch completion order. Snapshot / index_weight paging / intraday
  remain serial in D5.
- config: data_update.concurrency.max_workers (default 1; <1 rejected readably);
  config/data_update.yaml documents the block at max_workers: 1.
- updater: run_data_update builds ONE GlobalRateLimiter (from rate_limit_per_min)
  only when max_workers>1, shared by all feeds via _build_feeds; the cache gets
  max_workers; UpdateResult + format_summary surface max_workers and the global
  rate_limit_per_min (non-secret).

Invariants: cache public methods/return frames, ledger paths/columns/coverage
semantics (ok/empty count; failed/not_ready do not), retry semantics, and
factor/alpha/portfolio/backtest math all unchanged. phase0 anchor (0.9600/0.8408)
unchanged. No endpoint schema registry, no PanelStore append/partition, no
data-quality / research change.

tests/test_scheduler.py + tests/test_data_update_concurrency.py (network-free):
global spacing escalates under contention (fake clock); global-not-per-thread
under real threads; retries acquire a slot per attempt; scheduler-mode skips the
per-call sleep; secret-safe failure; feed forwards its scheduler; config
validation; serial==concurrent frames/ledger/request-counts; failure leaves the
gap uncovered+retryable while successes stay durable; empty/not_ready unchanged.
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.

1 participant