feat(data): D5 opt-in bounded concurrency with a global rate limiter - #51
Merged
StackOverFlow11 merged 1 commit intoJun 19, 2026
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/sleepinjectable; sees only an integer budget — no token / kwargs / exception payload.data/feed/throttle.pyrequest_with_retrygainsscheduler: when set, acquire a global slot before every attempt (incl. retries) and skip the per-callrate_limitsleep; when unset, the historical per-call throttle (unchanged).tushare_feed/flags/fina/covariates/intraday+index_feed): optionalschedulerctor param (defaultNone= unchanged), forwarded torequest_with_retry.data/cache/tushare_cache.pyTushareCache(max_workers=1):==1keeps the byte-identical serial read-through;>1(and>1symbols) fans the planned dense per-symbol gap FETCH onto a boundedThreadPoolExecutor, 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_weightpaging / intraday remain serial in D5.data_update.concurrency.max_workers(default 1;<1rejected readably);config/data_update.yamldocuments the block atmax_workers: 1.run_data_updatebuilds oneGlobalRateLimiter(fromrate_limit_per_min) only whenmax_workers>1, shared by all feeds via_build_feeds; cache getsmax_workers;UpdateResult+format_summarysurfacemax_workersand the globalrate_limit_per_min(non-secret).Proof global ≠ per-thread
GlobalRateLimiterreserves slots under a lock; with a frozen fake clock, sequentialacquire()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) assertsnext_allowed == N*intervalandsum(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/emptycount;failed/not_readydo 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 anchor0.9600/0.8408unchanged.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<1rejected, 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.ruffclean. All 17 configsvalidate-config.run-phase0anchor0.9600 / 0.8408.git diff --checkclean; no merge markers. Secret scan over changed files: real token value 0 hits (the onlytushare.token/.config.jsonliterals are pre-existingtoken_key/secret-path config defaults).scheduler.pyis transport-agnostic (no secret).Out of scope (not started)
Endpoint schema registry,
PanelStoreappend/partition, data-quality changes, factor/alpha/portfolio/runtime research changes.index_weightpaging + intraday cache + snapshots remain serial in D5.Live
data-updateagainst Tushare was not run (acceptance is network-free).