Skip to content

Commit 18a533d

Browse files
bewestCopilot
andcommitted
tools(aaps): add simulation for V1 profile sync timing race
Reproduces the lost-edit race in DataSyncSelectorV1.processChangedProfileStore (plugins/sync/src/main/kotlin/app/aaps/plugins/sync/nsclient/DataSyncSelectorV1.kt:786-805) and demonstrates that changing confirmLastProfileStore(dateUtil.now()) to confirmLastProfileStore(lastChangeAtSend) prevents the second edit from being permanently dropped when a user re-edits within the 60s ack window. This is a JS simulation for fast iteration; an upstream PR should add a Kotlin/JUnit DataSyncSelectorV1Test (none exists today; only V3 is covered). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a3d7d9a commit 18a533d

1 file changed

Lines changed: 101 additions & 0 deletions

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
'use strict';
2+
3+
/*
4+
* Simulation of AAPS V1 DataSyncSelectorV1.processChangedProfileStore()
5+
* to test whether changing `confirmLastProfileStore(now)` to
6+
* `confirmLastProfileStore(lastChangeAtSyncStart)` fixes the lost-edit race.
7+
*
8+
* Faithfully models (from DataSyncSelectorV1.kt:786-805):
9+
* 1. read lastChange = LocalProfileLastChange preference
10+
* 2. read lastSync = ProfileStoreLastSyncedId preference
11+
* 3. if lastChange == 0 -> return
12+
* 4. if lastChange > lastSync:
13+
* send profile, wait for ack (~60s)
14+
* on ack: confirmLastProfileStore(<TIMESTAMP>)
15+
*
16+
* The bug: <TIMESTAMP> is dateUtil.now() captured AFTER the ack returns.
17+
* The fix: <TIMESTAMP> should be the lastChange value captured BEFORE send.
18+
*
19+
* Race scenario: user edits at T1, sync starts and sends profile, while we
20+
* wait for the server ack the user edits AGAIN at T3, ack arrives at T4.
21+
* After buggy confirm, lastSync = T4 > T3, so the T3 edit is permanently
22+
* unsyncable until LocalProfileLastChange exceeds T4.
23+
*/
24+
25+
function makeStrategy(name, confirmTimestampFn) {
26+
return { name, confirmTimestampFn };
27+
}
28+
29+
async function runScenario(strategy) {
30+
const prefs = {
31+
LocalProfileLastChange: 0,
32+
ProfileStoreLastSyncedId: 0,
33+
};
34+
const sentProfiles = []; // each entry: {sentAt, lastChangeAtSend}
35+
36+
// Simulate: pretend initial state — never synced
37+
function storeSettings(t) { prefs.LocalProfileLastChange = t; }
38+
function userEdit(t) { storeSettings(t); }
39+
40+
// The sync loop, modeled as a single invocation taking a "now" generator.
41+
// Returns whether a profile was actually sent.
42+
async function processChangedProfileStore(nowAtStart, nowAtAck, concurrentEditAt) {
43+
if (prefs.LocalProfileLastChange === 0) return { sent: false };
44+
const lastChange = prefs.LocalProfileLastChange;
45+
const lastSync = prefs.ProfileStoreLastSyncedId;
46+
if (!(lastChange > lastSync)) return { sent: false };
47+
48+
sentProfiles.push({ sentAt: nowAtStart, lastChangeAtSend: lastChange });
49+
50+
// ---- 60s wait for ack window: simulate concurrent user edit ----
51+
if (concurrentEditAt != null) {
52+
userEdit(concurrentEditAt);
53+
}
54+
55+
// ack arrived
56+
const confirmTimestamp = strategy.confirmTimestampFn({
57+
lastChangeAtSend: lastChange,
58+
nowAtStart,
59+
nowAtAck,
60+
});
61+
prefs.ProfileStoreLastSyncedId = confirmTimestamp;
62+
return { sent: true, confirmTimestamp };
63+
}
64+
65+
// ---- Scenario timeline (ms) ----
66+
// T=1000 user edit #1
67+
// T=1100 sync poll fires, sends profile
68+
// T=1500 user edit #2 (concurrent, while ack pending)
69+
// T=2000 ack returns
70+
// T=2100 next sync poll fires
71+
userEdit(1000);
72+
const r1 = await processChangedProfileStore(1100, 2000, /*concurrentEditAt=*/1500);
73+
const r2 = await processChangedProfileStore(2100, 2200, /*concurrentEditAt=*/null);
74+
75+
return { strategy: strategy.name, prefs, sentProfiles, r1, r2 };
76+
}
77+
78+
(async function main() {
79+
const buggy = makeStrategy(
80+
'BUGGY: confirmLastProfileStore(dateUtil.now())',
81+
({ nowAtAck }) => nowAtAck
82+
);
83+
const fixed = makeStrategy(
84+
'FIXED: confirmLastProfileStore(lastChangeAtSend)',
85+
({ lastChangeAtSend }) => lastChangeAtSend
86+
);
87+
88+
for (const strat of [buggy, fixed]) {
89+
const out = await runScenario(strat);
90+
console.log('=== ' + out.strategy + ' ===');
91+
console.log(' sends:', out.sentProfiles);
92+
console.log(' final prefs:', out.prefs);
93+
const editsMade = [1000, 1500];
94+
const editsSynced = out.sentProfiles.map(p => p.lastChangeAtSend);
95+
const lostEdits = editsMade.filter(e => !editsSynced.includes(e));
96+
console.log(' edits made: ', editsMade);
97+
console.log(' edits sent: ', editsSynced);
98+
console.log(' LOST edits: ', lostEdits.length ? lostEdits : 'none');
99+
console.log();
100+
}
101+
})();

0 commit comments

Comments
 (0)