Skip to content

Commit dafae52

Browse files
authoredJan 27, 2025··
Discard earliest heartbeat once there are 30 heartbeats (#8724)
* Delete earliest heartbeats only once there are 30 heartbeats * Add changeset * Use max heartbeat const in tests * Fix test name in all caps * Fix test names * Formatting
1 parent 99766e0 commit dafae52

File tree

3 files changed

+101
-16
lines changed

3 files changed

+101
-16
lines changed
 

‎.changeset/yellow-rice-kneel.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/app': patch
3+
---
4+
5+
Discard the earliest heartbeat once a limit of 30 heartbeats in storage has been hit.

‎packages/app/src/heartbeatService.test.ts

+59-7
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ import '../test/setup';
2020
import {
2121
countBytes,
2222
HeartbeatServiceImpl,
23-
extractHeartbeatsForHeader
23+
extractHeartbeatsForHeader,
24+
getEarliestHeartbeatIdx,
25+
MAX_NUM_STORED_HEARTBEATS
2426
} from './heartbeatService';
2527
import {
2628
Component,
2729
ComponentType,
2830
ComponentContainer
2931
} from '@firebase/component';
30-
import { PlatformLoggerService } from './types';
32+
import { PlatformLoggerService, SingleDateHeartbeat } from './types';
3133
import { FirebaseApp } from './public-types';
3234
import * as firebaseUtil from '@firebase/util';
3335
import { SinonStub, stub, useFakeTimers } from 'sinon';
@@ -173,7 +175,6 @@ describe('HeartbeatServiceImpl', () => {
173175
let writeStub: SinonStub;
174176
let userAgentString = USER_AGENT_STRING_1;
175177
const mockIndexedDBHeartbeats = [
176-
// Chosen so one will exceed 30 day limit and one will not.
177178
{
178179
agent: 'old-user-agent',
179180
date: '1969-12-01'
@@ -236,15 +237,14 @@ describe('HeartbeatServiceImpl', () => {
236237
});
237238
}
238239
});
239-
it(`triggerHeartbeat() writes new heartbeats and retains old ones newer than 30 days`, async () => {
240+
it(`triggerHeartbeat() writes new heartbeats and retains old ones`, async () => {
240241
userAgentString = USER_AGENT_STRING_2;
241242
clock.tick(3 * 24 * 60 * 60 * 1000);
242243
await heartbeatService.triggerHeartbeat();
243244
if (firebaseUtil.isIndexedDBAvailable()) {
244245
expect(writeStub).to.be.calledWith({
245246
heartbeats: [
246-
// The first entry exceeds the 30 day retention limit.
247-
mockIndexedDBHeartbeats[1],
247+
...mockIndexedDBHeartbeats,
248248
{ agent: USER_AGENT_STRING_2, date: '1970-01-04' }
249249
]
250250
});
@@ -260,6 +260,7 @@ describe('HeartbeatServiceImpl', () => {
260260
);
261261
if (firebaseUtil.isIndexedDBAvailable()) {
262262
expect(heartbeatHeaders).to.include('old-user-agent');
263+
expect(heartbeatHeaders).to.include('1969-12-01');
263264
expect(heartbeatHeaders).to.include('1969-12-31');
264265
}
265266
expect(heartbeatHeaders).to.include(USER_AGENT_STRING_2);
@@ -273,14 +274,47 @@ describe('HeartbeatServiceImpl', () => {
273274
const emptyHeaders = await heartbeatService.getHeartbeatsHeader();
274275
expect(emptyHeaders).to.equal('');
275276
});
277+
it('triggerHeartbeat() removes the earliest heartbeat once the max number of heartbeats is exceeded', async () => {
278+
// Trigger heartbeats until we reach the limit
279+
const numHeartbeats =
280+
heartbeatService._heartbeatsCache?.heartbeats.length!;
281+
for (let i = numHeartbeats; i <= MAX_NUM_STORED_HEARTBEATS; i++) {
282+
await heartbeatService.triggerHeartbeat();
283+
clock.tick(24 * 60 * 60 * 1000);
284+
}
285+
286+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(
287+
MAX_NUM_STORED_HEARTBEATS
288+
);
289+
const earliestHeartbeatDate = getEarliestHeartbeatIdx(
290+
heartbeatService._heartbeatsCache?.heartbeats!
291+
);
292+
const earliestHeartbeat =
293+
heartbeatService._heartbeatsCache?.heartbeats[earliestHeartbeatDate]!;
294+
await heartbeatService.triggerHeartbeat();
295+
expect(heartbeatService._heartbeatsCache?.heartbeats.length).to.equal(
296+
MAX_NUM_STORED_HEARTBEATS
297+
);
298+
expect(
299+
heartbeatService._heartbeatsCache?.heartbeats.indexOf(earliestHeartbeat)
300+
).to.equal(-1);
301+
});
302+
it('triggerHeartbeat() never causes the heartbeat count to exceed the max', async () => {
303+
for (let i = 0; i <= 50; i++) {
304+
await heartbeatService.triggerHeartbeat();
305+
clock.tick(24 * 60 * 60 * 1000);
306+
expect(
307+
heartbeatService._heartbeatsCache?.heartbeats.length
308+
).to.be.lessThanOrEqual(MAX_NUM_STORED_HEARTBEATS);
309+
}
310+
});
276311
});
277312

278313
describe('If IndexedDB records that a header was sent today', () => {
279314
let heartbeatService: HeartbeatServiceImpl;
280315
let writeStub: SinonStub;
281316
const userAgentString = USER_AGENT_STRING_1;
282317
const mockIndexedDBHeartbeats = [
283-
// Chosen so one will exceed 30 day limit and one will not.
284318
{
285319
agent: 'old-user-agent',
286320
date: '1969-12-01'
@@ -426,4 +460,22 @@ describe('HeartbeatServiceImpl', () => {
426460
);
427461
});
428462
});
463+
464+
describe('getEarliestHeartbeatIdx()', () => {
465+
it('returns -1 if the heartbeats array is empty', () => {
466+
const heartbeats: SingleDateHeartbeat[] = [];
467+
const idx = getEarliestHeartbeatIdx(heartbeats);
468+
expect(idx).to.equal(-1);
469+
});
470+
471+
it('returns the index of the earliest date', () => {
472+
const heartbeats = [
473+
{ agent: generateUserAgentString(2), date: '2022-01-02' },
474+
{ agent: generateUserAgentString(1), date: '2022-01-01' },
475+
{ agent: generateUserAgentString(3), date: '2022-01-03' }
476+
];
477+
const idx = getEarliestHeartbeatIdx(heartbeats);
478+
expect(idx).to.equal(1);
479+
});
480+
});
429481
});

‎packages/app/src/heartbeatService.ts

+37-9
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ import {
3636
import { logger } from './logger';
3737

3838
const MAX_HEADER_BYTES = 1024;
39-
// 30 days
40-
const STORED_HEARTBEAT_RETENTION_MAX_MILLIS = 30 * 24 * 60 * 60 * 1000;
39+
export const MAX_NUM_STORED_HEARTBEATS = 30;
4140

4241
export class HeartbeatServiceImpl implements HeartbeatService {
4342
/**
@@ -109,14 +108,19 @@ export class HeartbeatServiceImpl implements HeartbeatService {
109108
} else {
110109
// There is no entry for this date. Create one.
111110
this._heartbeatsCache.heartbeats.push({ date, agent });
111+
112+
// If the number of stored heartbeats exceeds the maximum number of stored heartbeats, remove the heartbeat with the earliest date.
113+
// Since this is executed each time a heartbeat is pushed, the limit can only be exceeded by one, so only one needs to be removed.
114+
if (
115+
this._heartbeatsCache.heartbeats.length > MAX_NUM_STORED_HEARTBEATS
116+
) {
117+
const earliestHeartbeatIdx = getEarliestHeartbeatIdx(
118+
this._heartbeatsCache.heartbeats
119+
);
120+
this._heartbeatsCache.heartbeats.splice(earliestHeartbeatIdx, 1);
121+
}
112122
}
113-
// Remove entries older than 30 days.
114-
this._heartbeatsCache.heartbeats =
115-
this._heartbeatsCache.heartbeats.filter(singleDateHeartbeat => {
116-
const hbTimestamp = new Date(singleDateHeartbeat.date).valueOf();
117-
const now = Date.now();
118-
return now - hbTimestamp <= STORED_HEARTBEAT_RETENTION_MAX_MILLIS;
119-
});
123+
120124
return this._storage.overwrite(this._heartbeatsCache);
121125
} catch (e) {
122126
logger.warn(e);
@@ -303,3 +307,27 @@ export function countBytes(heartbeatsCache: HeartbeatsByUserAgent[]): number {
303307
JSON.stringify({ version: 2, heartbeats: heartbeatsCache })
304308
).length;
305309
}
310+
311+
/**
312+
* Returns the index of the heartbeat with the earliest date.
313+
* If the heartbeats array is empty, -1 is returned.
314+
*/
315+
export function getEarliestHeartbeatIdx(
316+
heartbeats: SingleDateHeartbeat[]
317+
): number {
318+
if (heartbeats.length === 0) {
319+
return -1;
320+
}
321+
322+
let earliestHeartbeatIdx = 0;
323+
let earliestHeartbeatDate = heartbeats[0].date;
324+
325+
for (let i = 1; i < heartbeats.length; i++) {
326+
if (heartbeats[i].date < earliestHeartbeatDate) {
327+
earliestHeartbeatDate = heartbeats[i].date;
328+
earliestHeartbeatIdx = i;
329+
}
330+
}
331+
332+
return earliestHeartbeatIdx;
333+
}

0 commit comments

Comments
 (0)
Please sign in to comment.