Dedupe DataStore reads by porting SD Maid SE's DataStoreValue DSL - #26
Merged
Conversation
…e DSL Amply keeps one shared Preferences DataStore, and Preferences DataStore hands the entire snapshot to every collector on any write. None of the 14 preference flows deduplicated, so an unrelated write woke all of them. That was user-visible: ChargeStatsRecorder stamps lastCaptureWallMillis on every recorded sample (~20s while charging, always a new timestamp), which re-emitted captureEnabled unchanged, restarted the flatMapLatest in statsDashboardStates and replayed its loading marker — collapsing the dashboard's charging card to its loading size for a frame, every 20s. Settings are now declared with a createValue() DSL in common/datastore, which dedupes on the raw stored value before the reader runs. The guard lives in the primitive, so a facade cannot forget it, and it never depends on a domain type's equals. Settings read as a unit — a session and its provenance, the recovery target, the interruption event, the alarm config, theme and quick-access state — are now one @serializable record under one key (new dependency: kotlinx-serialization-json), so a partially-written state cannot exist and read-modify-write collapses to update {}. Independent scalars stay separate so a hot-path write doesn't wake unrelated collectors. Corruption semantics are chosen per record rather than globally. Records whose decode was already all-or-nothing keep a whole-record fallback, but ChargingPreferences parses field by field: its four facts degrade independently, and a wholesale fallback would silently downgrade a user's 90% protective baseline to the 80% default and then persist the downgrade. For the same reason every field except the charge policy itself is optional — a missing pid must not take an owed restore down with it. Also fixes a pre-existing torn read in InterruptionAssessor, which read recovery provenance, work id and session through separate suspend calls and could pair fields from either side of a concurrent adopt or clear. No migration: old keys are abandoned and new ones are suffixed .v2. Test users only. An update installed mid-session therefore loses that session's record — wipe app data before installing. Verified: 706 tests pass on both flavors; lintVital clean on all four beta/release variants; R8 release builds clean with no serialization stripping. Both blip regression tests were confirmed against a negative control (removing the dedupe makes them fail: 6 emissions instead of 2, providers recreated 4x instead of 1x), as were the per-field corruption, missing-metadata and alarm-normalization guards.
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.
What changed
The charging card on the dashboard no longer flickers. While plugged in with charge recording on, it used to visibly collapse and re-expand for a fraction of a second roughly every 20 seconds — losing its chart and elapsed time each time.
Under the hood this was one missing guard, so the fix also closes the whole class of bug: every stored setting now deduplicates itself, and a few settings that are only meaningful together (a charge session and who owns it, the restore Amply still owes you, the alarm config, theme and shortcut state) are stored as a single record instead of scattered keys, so they can never be read half-written.
Existing app data is dropped. Settings reset to defaults on first launch after installing this. Test users only, so no migration was written — but wipe app data before installing rather than updating in place, and don't update while a one-time full charge is running: that session's record won't carry over, and the protective limit wouldn't be restored automatically.
Technical Context
Root cause. Amply keeps one shared Preferences DataStore. Preferences DataStore emits the entire snapshot to every collector on any write, and none of the 14 preference flows had
distinctUntilChanged().ChargeStatsRecorderstampslastCaptureWallMillison every recorded sample (StatsCadence.CHARGING_MIN_INTERVAL_MILLIS = 20_000, plus every 1% change) — always a new timestamp, so a genuinely new snapshot. That re-emittedcaptureEnabledunchanged, restarting theflatMapLatestinstatsDashboardStatesand replaying itsonStartloading marker.Why the DSL rather than 14
distinctUntilChanged()calls. The guard is a property of the shared-store design, not of any one facade, so it belongs in the primitive. Ported from SD Maid SE'sDataStoreValue. It dedupes on the raw stored value, before the reader runs — so the comparison never depends on a domain type'sequals, and no decoding happens for a duplicate emission.What the record consolidation does and does not buy. It buys shorter facades and clean
update {}read-modify-write. It does not buy atomicity or fewer emissions — the old code already wrote each record's keys in a singleedit {}, and DataStore emits the whole snapshot regardless of how many keys changed. Worth stating explicitly since it's the intuitive-but-wrong justification.Corruption semantics are per record, deliberately. Records whose decode was already all-or-nothing (session, recovery, interruption) keep a whole-record fallback.
ChargingPreferencesdoes not: its four facts degrade independently today, so a wholesale fallback would swap a valid Adaptive/90% protective baseline for the 80% default — and the next write would persist that downgrade. It parses field by field from aJsonObject, so a wrong-typedlastRequestedAtcan't take a validprotectivedown with it.For the same reason every field except the charge policy itself is optional. Provenance is diagnostic metadata; making
pidrequired meant a record missing it decoded tonull— silently discarding a restore Amply still owed the user. There's a test for exactly that.Also fixed: a pre-existing torn read in
InterruptionAssessor.captureRecoveryPickup(), which read recovery provenance, work id and session through four separate suspend calls and could pair fields from either side of a concurrent adopt or clear. Now one read each.Review guidance. The risk is concentrated in
FullChargeStoreandChargingPreferences— that's the persistence behind restoring the user's charge limit.common/datastore/DataStoreValue.ktis the primitive everything else rests on. The rest is mechanical.Verification
lintVitalclean on all four beta/release variants; R8 release builds clean with no serialization stripping (newkotlinx-serialization-jsondependency).DataStoreValueTestis explicitly documented as not a guard — re-writing an equal value is suppressed by DataStore itself, so a regression test built that way would pass against the very bug it targets.Still outstanding
Device pass not yet done. Unit tests can't prove the HAL side. Before merge this needs: a full-charge session end-to-end, a force-stop mid-session (restore still happens + interruption card appears), and a reboot recovery (restore happens, no card). Plus confirming the card no longer blips while plugged in for >60s.