Skip to content

Commit 6b59dfc

Browse files
authored
Merge pull request #8463 from nightscout/wip/test-improvements
Wip/test improvements
2 parents 8cf4a1a + 246e46a commit 6b59dfc

File tree

7 files changed

+693
-5
lines changed

7 files changed

+693
-5
lines changed

lib/server/bootevent.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,12 +276,32 @@ function boot (env, language) {
276276
return next();
277277
}
278278

279-
function updateData () {
279+
// Strategy C: Leading-edge debounce + concurrency guard
280+
// - First event fires immediately (no delay for normal single updates)
281+
// - Rapid events (AAPS batch upload) are coalesced by debounce
282+
// - Concurrency guard prevents overlapping dataloader runs on shared ddata
283+
// - maxWait ensures data appears within 5s even under sustained load
284+
var dataloadRunning = false;
285+
var dataloadPending = false;
286+
287+
function runDataLoad () {
288+
if (dataloadRunning) {
289+
dataloadPending = true;
290+
return;
291+
}
292+
dataloadRunning = true;
280293
ctx.dataloader.update(ctx.ddata, function dataUpdated () {
294+
dataloadRunning = false;
281295
ctx.bus.emit('data-loaded');
296+
if (dataloadPending) {
297+
dataloadPending = false;
298+
runDataLoad();
299+
}
282300
});
283301
}
284302

303+
var updateData = _.debounce(runDataLoad, 1000, { leading: true, trailing: true, maxWait: 5000 });
304+
285305
ctx.bus.on('tick', function timedReloadData (tick) {
286306
console.info('tick', tick.now);
287307
updateData();

lib/server/cache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function cache (env, ctx) {
5151

5252
function filterForAge(data, ageLimit) {
5353
return _.filter(data, function hasId(object) {
54-
const hasId = !_.isEmpty(object._id);
54+
const hasId = object._id != null && object._id !== '';
5555
const age = getObjectAge(object);
5656
const isFresh = age >= ageLimit;
5757
return isFresh && hasId;

lib/server/query.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ function updateIdQuery (query, opts) {
100100
if (typeof query._id === 'string') {
101101
var result = normalizeIdValue(query._id, opts);
102102
if (result.searchByIdentifier) {
103-
// UUID detected with uuidHandling enabled - search by identifier instead
104-
query.identifier = result.value;
103+
// UUID detected with uuidHandling enabled
104+
// Use $or to match both new docs (identifier field) and legacy docs (UUID in _id)
105+
query.$or = [{ identifier: result.value }, { _id: result.value }];
105106
delete query._id;
106107
} else {
107108
query._id = result.value;

lib/server/treatments.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,13 @@ function storage (env, ctx) {
320320
if (obj.identifier) {
321321
// Remove _id from replacement - MongoDB will use existing _id on update,
322322
// or generate new one on insert
323+
var identifierValue = obj.identifier;
323324
delete obj._id;
324-
return { identifier: obj.identifier };
325+
// Use $or to match both new docs (identifier field) and legacy docs (UUID in _id)
326+
if (env.uuidHandling) {
327+
return { $or: [{ identifier: identifierValue }, { _id: identifierValue }] };
328+
}
329+
return { identifier: identifierValue };
325330
}
326331
// 2. Fall back to _id if present and valid
327332
if (Object.prototype.hasOwnProperty.call(obj, '_id') && obj._id !== null && obj._id !== '') {

tests/bootevent-debounce.test.js

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
'use strict';
2+
3+
/**
4+
* bootevent-debounce.test.js
5+
*
6+
* Tests for Strategy C: leading-edge debounce + concurrency guard
7+
* in bootevent.js setupListeners.
8+
*
9+
* The updateData pipeline must:
10+
* 1. Fire immediately on first data-received (no delay for normal case)
11+
* 2. Coalesce rapid data-received events (AAPS batch upload)
12+
* 3. Never run concurrent dataloader.update() calls (shared ddata guard)
13+
* 4. Always finish with one final run capturing the latest state
14+
*
15+
* These tests exercise the debounce/guard logic by mocking the dataloader
16+
* and bus, then verifying the number of dataloader.update() calls.
17+
*/
18+
19+
const should = require('should');
20+
const EventEmitter = require('events');
21+
22+
/**
23+
* Build a minimal bootevent-like setupListeners context.
24+
* We replicate the exact logic from bootevent.js setupListeners
25+
* so we can test it in isolation without booting the full server.
26+
*/
27+
function createTestContext (opts) {
28+
opts = opts || {};
29+
const _ = require('lodash');
30+
const bus = new EventEmitter();
31+
const updateLog = [];
32+
let updateDelay = opts.updateDelay || 0; // ms to simulate dataloader work
33+
34+
const ctx = {
35+
bus: bus,
36+
ddata: {},
37+
dataloader: {
38+
update: function (ddata, callback) {
39+
const entry = { startedAt: Date.now(), ddata: ddata };
40+
updateLog.push(entry);
41+
if (updateDelay > 0) {
42+
setTimeout(function () {
43+
entry.finishedAt = Date.now();
44+
callback();
45+
}, updateDelay);
46+
} else {
47+
entry.finishedAt = Date.now();
48+
// Use setImmediate to simulate async completion
49+
setImmediate(callback);
50+
}
51+
}
52+
}
53+
};
54+
55+
// Replicate the exact logic from bootevent.js setupListeners
56+
var dataloadRunning = false;
57+
var dataloadPending = false;
58+
59+
function runDataLoad () {
60+
if (dataloadRunning) {
61+
dataloadPending = true;
62+
return;
63+
}
64+
dataloadRunning = true;
65+
ctx.dataloader.update(ctx.ddata, function dataUpdated () {
66+
dataloadRunning = false;
67+
ctx.bus.emit('data-loaded');
68+
if (dataloadPending) {
69+
dataloadPending = false;
70+
runDataLoad();
71+
}
72+
});
73+
}
74+
75+
var updateData = _.debounce(runDataLoad, 1000, { leading: true, trailing: true, maxWait: 5000 });
76+
77+
ctx.bus.on('tick', function timedReloadData () {
78+
updateData();
79+
});
80+
81+
ctx.bus.on('data-received', function forceReloadData () {
82+
updateData();
83+
});
84+
85+
return {
86+
ctx: ctx,
87+
bus: bus,
88+
updateLog: updateLog,
89+
// Expose for direct testing
90+
updateData: updateData,
91+
runDataLoad: runDataLoad
92+
};
93+
}
94+
95+
describe('bootevent updateData debounce + concurrency guard', function () {
96+
97+
describe('leading-edge behavior (no delay for normal case)', function () {
98+
99+
it('first data-received fires dataloader immediately', function (done) {
100+
var t = createTestContext();
101+
t.bus.emit('data-received');
102+
// Leading edge: should have started synchronously
103+
t.updateLog.should.have.length(1);
104+
// Wait for async completion
105+
t.ctx.bus.once('data-loaded', function () {
106+
t.updateLog.should.have.length(1);
107+
done();
108+
});
109+
});
110+
111+
it('tick event also fires dataloader immediately', function (done) {
112+
var t = createTestContext();
113+
t.bus.emit('tick', { now: Date.now() });
114+
t.updateLog.should.have.length(1);
115+
t.ctx.bus.once('data-loaded', function () {
116+
done();
117+
});
118+
});
119+
});
120+
121+
describe('debounce coalescing (batch upload scenario)', function () {
122+
123+
it('N rapid data-received events produce ≤ 3 dataloader runs', function (done) {
124+
this.timeout(8000);
125+
var t = createTestContext({ updateDelay: 50 });
126+
127+
// Simulate AAPS V1 WebSocket uploading 20 entries rapidly
128+
for (var i = 0; i < 20; i++) {
129+
t.bus.emit('data-received');
130+
}
131+
132+
// Leading edge fires immediately (1 run)
133+
t.updateLog.should.have.length(1);
134+
135+
// Wait for debounce trailing edge + any re-runs
136+
setTimeout(function () {
137+
// Should be far fewer than 20 runs
138+
// Strategy C: 1 leading + at most 1 concurrency re-run + 1 trailing = ≤ 3
139+
t.updateLog.length.should.be.belowOrEqual(3);
140+
t.updateLog.length.should.be.above(0);
141+
done();
142+
}, 3000);
143+
});
144+
145+
it('burst of 50 events produces far fewer than 50 runs', function (done) {
146+
this.timeout(8000);
147+
var t = createTestContext({ updateDelay: 100 });
148+
149+
for (var i = 0; i < 50; i++) {
150+
t.bus.emit('data-received');
151+
}
152+
153+
setTimeout(function () {
154+
// Without debounce this would be 50 concurrent runs
155+
t.updateLog.length.should.be.belowOrEqual(4);
156+
console.log(' 50 rapid events → ' + t.updateLog.length + ' dataloader runs');
157+
done();
158+
}, 4000);
159+
});
160+
});
161+
162+
describe('concurrency guard (no overlapping dataloader runs)', function () {
163+
164+
it('dataloader runs never overlap', function (done) {
165+
this.timeout(8000);
166+
var t = createTestContext({ updateDelay: 100 });
167+
168+
// Fire several events with slight spacing
169+
t.bus.emit('data-received');
170+
setTimeout(function () { t.bus.emit('data-received'); }, 50);
171+
setTimeout(function () { t.bus.emit('data-received'); }, 120);
172+
setTimeout(function () { t.bus.emit('data-received'); }, 200);
173+
174+
setTimeout(function () {
175+
// Verify no overlapping runs
176+
for (var i = 1; i < t.updateLog.length; i++) {
177+
var prevEnd = t.updateLog[i - 1].finishedAt;
178+
var currStart = t.updateLog[i].startedAt;
179+
currStart.should.be.aboveOrEqual(prevEnd,
180+
'Run ' + i + ' started at ' + currStart + ' before run ' + (i - 1) + ' finished at ' + prevEnd);
181+
}
182+
done();
183+
}, 4000);
184+
});
185+
186+
it('pending flag ensures final run after concurrent requests', function (done) {
187+
this.timeout(8000);
188+
// Use a longer delay so events arrive while dataloader is running
189+
var t = createTestContext({ updateDelay: 200 });
190+
191+
t.bus.emit('data-received'); // starts run 1 (leading edge)
192+
193+
// These arrive while run 1 is in progress
194+
setTimeout(function () { t.bus.emit('data-received'); }, 50);
195+
setTimeout(function () { t.bus.emit('data-received'); }, 100);
196+
setTimeout(function () { t.bus.emit('data-received'); }, 150);
197+
198+
setTimeout(function () {
199+
// Should have: 1 initial + 1 re-run (from pending flag)
200+
// Debounce trailing may add 1 more
201+
t.updateLog.length.should.be.aboveOrEqual(2, 'should re-run at least once for pending events');
202+
t.updateLog.length.should.be.belowOrEqual(3);
203+
done();
204+
}, 4000);
205+
});
206+
});
207+
208+
describe('trailing edge (final state captured)', function () {
209+
210+
it('trailing debounce fires after burst quiets down', function (done) {
211+
this.timeout(8000);
212+
var t = createTestContext({ updateDelay: 50 });
213+
var dataLoadedCount = 0;
214+
215+
t.ctx.bus.on('data-loaded', function () {
216+
dataLoadedCount++;
217+
});
218+
219+
// Single event
220+
t.bus.emit('data-received');
221+
222+
// After debounce window, trailing edge should fire
223+
setTimeout(function () {
224+
// At least 1 data-loaded (from leading), possibly 2 (trailing)
225+
dataLoadedCount.should.be.aboveOrEqual(1);
226+
done();
227+
}, 3000);
228+
});
229+
230+
it('spaced events each get processed', function (done) {
231+
this.timeout(10000);
232+
var t = createTestContext({ updateDelay: 50 });
233+
234+
// Events spaced > 1s apart (outside debounce window)
235+
t.bus.emit('data-received');
236+
237+
setTimeout(function () {
238+
t.bus.emit('data-received');
239+
}, 1500);
240+
241+
setTimeout(function () {
242+
t.bus.emit('data-received');
243+
}, 3000);
244+
245+
setTimeout(function () {
246+
// Each event outside the debounce window should fire independently
247+
// At least 3 leading-edge fires + possible trailing
248+
t.updateLog.length.should.be.aboveOrEqual(3);
249+
done();
250+
}, 6000);
251+
});
252+
});
253+
254+
describe('maxWait guarantee', function () {
255+
256+
it('sustained events still produce a run within 5s', function (done) {
257+
this.timeout(12000);
258+
var t = createTestContext({ updateDelay: 50 });
259+
260+
// Leading edge fires at t=0
261+
t.bus.emit('data-received');
262+
var firstRunCount = t.updateLog.length;
263+
firstRunCount.should.equal(1);
264+
265+
// Sustained events every 500ms for 6 seconds (keeps resetting trailing edge)
266+
var interval = setInterval(function () {
267+
t.bus.emit('data-received');
268+
}, 500);
269+
270+
// At 6s, maxWait (5s) should have forced at least one additional run
271+
setTimeout(function () {
272+
clearInterval(interval);
273+
setTimeout(function () {
274+
// Should have more than just the initial leading-edge run
275+
t.updateLog.length.should.be.aboveOrEqual(2,
276+
'maxWait should force a run even with sustained events');
277+
console.log(' Sustained events over 6s → ' + t.updateLog.length + ' dataloader runs');
278+
done();
279+
}, 2000);
280+
}, 6000);
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)