Bound upload bursts and align batching config with the other platform SDKs - #24
Merged
Conversation
Event uploads share the uplink with the app's own requests. Two properties of the drain loop decided how much they interfered with it, and neither was reachable from configuration: - The batch roll window was a fixed 5 s constant, so events could not be packed into fewer, better-compressed requests. - The drain loop had no bound at all: one successful upload immediately claimed the next batch, so a backlog accumulated while offline went out as a single uninterrupted burst. Add setBatchWindowMs and setMaxBatchesPerCycle to ConfigurationBuilder. These mirror Android's BatchSize and BatchProcessingLevel, as continuous values rather than enums, following setBatchUploadFrequencyMs which predates them. The per-cycle bound defaults to 10, matching BatchProcessingLevel.MEDIUM. Enforce the bound at every scheduling path, not only the timer. triggerNow() re-enters the drain directly on each force-flushed error or crash write, so a scheduler pacing only its own timer would drop the bound under exactly the load that needs it. A cycle that spends its budget records cycleYieldUntilMs, and both triggerNow() and the coalesced-retrigger path floor their delay at it. The first force-flush of a cycle with budget left is still immediate, which is what the call exists for. batchWindowMs also gates the cross-process claim guard for batches created by the deferred-upload process, so both processes must be built from the same Configuration. Noted on the field and in the README.
The Android, iOS, and Flutter SDKs all express upload pacing through the same three enums — UploadFrequency, BatchSize, BatchProcessingLevel — with identical case names and identical values. HarmonyOS was the only platform that did not, exposing a millisecond setter instead, so tuning advice written for one platform had to be translated by hand for this one. Adopt the shared vocabulary: add the three enums with the values the other SDKs use, and drop setBatchUploadFrequencyMs. Defaults now match the Android DEFAULT_CORE_CONFIG exactly (AVERAGE / MEDIUM / MEDIUM), so the same setting produces the same behaviour on every platform. This removes the range clamping the millisecond setters needed: an enum cannot carry an out-of-range value, so the checks had nothing left to guard. A test pins the enum values against the other SDKs, since silent drift there would invalidate cross-platform tuning advice without failing anything else. Also fixes the deferred-upload pipeline, which built its FilePersistenceStrategy without a batch window and so used the module fallback rather than the configured one. The cross-process claim guard is derived from that window: with the main process on a longer window, the deferred process would consider a batch claimable while the main process was still appending to it. Both pipelines now take the configured value, and the module fallback is defined as the configured default so an omitted argument cannot silently change behaviour. BREAKING CHANGE: setBatchUploadFrequencyMs is removed. Replace with setUploadFrequency(UploadFrequency.RARE) for the previous 5 s default, or drop the call to take the new AVERAGE default.
- UploadScheduler: floor every scheduling path at the retry backoff via a single scheduleAt choke point. A force-flush (an error/crash write, a manual flush, or backgrounding) can no longer cancel an in-progress backoff and retry a failing intake at the app's error rate. - Backgrounding and manual flushes waive the cycle budget: the process is about to freeze (a frozen process never fires the timer the budget would impose), or the app explicitly asked to ship now. The backoff is never waived. - flushForWork and flushAndWait default to the configured BatchProcessingLevel instead of a hard-coded 20, so the configured level bounds the deferred-upload and flush drains too. - FilePersistenceStrategy: the foreign-batch claim guard floors at the largest supported batch window, so independently configured processes can no longer claim a batch another process is still appending to. - Keep the new public enums and their setters in the obfuscation keep lists so release builds resolve them. - Document the removed setter, the migration path, and the two default changes in the changelog.
This was referenced Aug 18, 2026
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.
Why
Event uploads share the uplink with the app's own requests. On a narrow
connection that competition is measurable, and two properties of the drain loop
decided how much it hurt — neither reachable from configuration:
MAX_BATCH_AGE_MS), so eventscould not be packed into fewer, better-compressed requests.
scheduleNext(0)and immediately claimed the next batch, so a backlog built upwhile offline was emptied in one uninterrupted burst.
Separately, HarmonyOS was the only platform expressing upload pacing as a
millisecond setter. Android, iOS, and Flutter all use the same three enums with
identical case names and values, so tuning advice had to be hand-translated for
this SDK alone.
What
Adopt the shared vocabulary and drop
setBatchUploadFrequencyMs:setUploadFrequencyFREQUENT500 ms ·AVERAGE2 s ·RARE5 sAVERAGEsetBatchSizeSMALL3 s ·MEDIUM10 s ·LARGE35 sMEDIUMsetBatchProcessingLevelLOW1 ·MEDIUM20 ·HIGH100 batches/cycleMEDIUMValues and defaults match Android's
DEFAULT_CORE_CONFIGexactly, so the samesetting produces the same behaviour on every platform. A test pins the enum
values against the other SDKs — silent drift there would invalidate
cross-platform tuning advice without failing anything else.
BREAKING CHANGE
setBatchUploadFrequencyMsis removed. Replace withsetUploadFrequency(UploadFrequency.RARE)to keep the previous 5 s behaviour, ordrop the call to take the new
AVERAGEdefault. Customer-facing docs referenceit in 6 places and are updated with the release.
Notes for review
The per-cycle bound is enforced at every scheduling path, not just the timer.
triggerNow()re-enters the drain directly on each force-flushed error or crashwrite and on backgrounding. A scheduler that paced only its own timer would drop
the bound under exactly the load that needs it — an error storm. A cycle that
spends its budget records
cycleYieldUntilMs, and bothtriggerNow()and thecoalesced-retrigger path in
drainOnce()floor their delay at it. The firstforce-flush of a cycle with budget left is still immediate.
Deferred-upload pipeline fix. It built its
FilePersistenceStrategywithouta batch window, so it used the module fallback rather than the configured one.
The cross-process claim guard is derived from that window: with the main process
on
BatchSize.LARGE, the deferred process would have considered a batchclaimable while the main process was still appending to it. Both pipelines now
take the configured value, and the module fallback is defined as
BatchSize.MEDIUMso an omitted argument cannot silently change behaviour.Range clamping is gone — an enum cannot carry an out-of-range value, so the
checks the millisecond setters needed had nothing left to guard.
flushForWorkis deliberately left unbounded — the system already scheduledthat drain off the critical path under the app's chosen network/charging
constraints, so bursting there is free.
Verification
node scripts/unit-node/run.mjs— 143 passed, 0 failed (checked acrossrepeated runs; the cycle tests are timer-based).
./scripts/ci-check.sh— ALL CHECKS PASSED (HAR build, demo HAP build, ArkTSunit-test type check).
UploadThrottle.test.etscovers: enum values against the other platforms,defaults against Android's, the roll window driving the drain decision, the
per-cycle bound, a control where the budget exceeds the backlog, and a
force-flush pump that must not buy extra batches within a spent cycle.
fakes, not measured against real uplink contention.