Skip to content

Commit a922bd5

Browse files
author
Beatriz Rizental
authored
Merge pull request #580 from brizental/1712921-ping-storage-size-limit
Bug 1712921 - Implement ping storage size limit
2 parents e991563 + c89c2d3 commit a922bd5

File tree

10 files changed

+333
-55
lines changed

10 files changed

+333
-55
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
[Full changelog](https://github.com/mozilla/glean.js/compare/v0.18.1...main)
44

55
* [#534](https://github.com/mozilla/glean.js/pull/534): Expose `Uploader` base class through `@mozilla/glean/<platform>/uploader` entry point.
6+
* [#580](https://github.com/mozilla/glean.js/pull/580): Limit size of pings database to 250 pings or 10MB.
7+
* [#580](https://github.com/mozilla/glean.js/pull/580): BUGFIX: Pending pings at startup up are uploaded from oldest to newest.
68

79
# v0.18.1 (2021-07-22)
810

glean/.eslintrc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"semi": "off",
2121
"@typescript-eslint/semi": ["error", "always"],
2222
"@typescript-eslint/consistent-type-imports": "error",
23+
"no-unused-vars": "off",
24+
"@typescript-eslint/no-unused-vars": [ "error", {
25+
"argsIgnorePattern": "^_",
26+
"varsIgnorePattern": "^_"
27+
}],
2328
"no-debugger": ["error"],
2429
"no-multi-spaces": "error",
2530
"jsdoc/no-types": "off",

glean/src/core/dispatcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ const enum Commands {
4747
// The dispatcher will clear the queue and go into the Shutdown state.
4848
Shutdown,
4949
// Exactly like a normal Task, but spawned for tests.
50-
TestTask = "TestTask",
50+
TestTask,
5151
}
5252

5353
// A task the dispatcher knows how to execute.

glean/src/core/glean.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,7 @@ class Glean {
218218
Context.pingsDatabase = new PingsDatabase(Glean.platform.Storage);
219219
Context.errorManager = new ErrorManager();
220220

221-
Glean.instance._pingUploader = new PingUploader(
222-
correctConfig,
223-
Glean.platform,
224-
Context.pingsDatabase
225-
);
221+
Glean.instance._pingUploader = new PingUploader(correctConfig, Glean.platform);
226222

227223
Context.pingsDatabase.attachObserver(Glean.pingUploader);
228224

@@ -289,6 +285,10 @@ class Glean {
289285
}
290286
}
291287

288+
// We only scan the pendings pings **after** dealing with the upload state.
289+
// If upload is disabled, we delete all pending pings files
290+
// and we need to do that **before** scanning the pending pings
291+
// to ensure we don't enqueue pings before their files are deleted.
292292
await Context.pingsDatabase.scanPendingPings();
293293
});
294294
}

glean/src/core/pings/database.ts

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,33 @@ import type { JSONObject} from "../utils.js";
77
import { isObject, isJSONValue, isString } from "../utils.js";
88
import type { StorageBuilder } from "../../platform/index.js";
99
import log, { LoggingLevel } from "../log.js";
10+
import { DELETION_REQUEST_PING_NAME } from "../constants.js";
11+
import { strToU8 } from "fflate";
1012

1113
const LOG_TAG = "core.Pings.Database";
1214

15+
/**
16+
* Whether or not a given ping is a deletion-request ping.
17+
*
18+
* @param ping The ping to verify.
19+
* @returns Whether or not the ping is a deletion-request ping.
20+
*/
21+
export function isDeletionRequest(ping: PingInternalRepresentation): boolean {
22+
return ping.path.split("/")[3] === DELETION_REQUEST_PING_NAME;
23+
}
24+
25+
/**
26+
* Gets the size of a ping in bytes.
27+
*
28+
* @param ping The ping to get the size of.
29+
* @returns Size of the given ping in bytes.
30+
*/
31+
function getPingSize(ping: PingInternalRepresentation): number {
32+
return strToU8(JSON.stringify(ping)).length;
33+
}
34+
1335
export interface PingInternalRepresentation extends JSONObject {
36+
collectionDate: string,
1437
path: string,
1538
payload: JSONObject,
1639
headers?: Record<string, string>
@@ -95,6 +118,7 @@ class PingsDatabase {
95118
headers?: Record<string, string>
96119
): Promise<void> {
97120
const ping: PingInternalRepresentation = {
121+
collectionDate: (new Date()).toISOString(),
98122
path,
99123
payload
100124
};
@@ -119,11 +143,16 @@ class PingsDatabase {
119143
}
120144

121145
/**
122-
* Gets all pings from the pings database. Deletes any data in unexpected format that is found.
146+
* Gets all pings from the pings database.
147+
* Deletes any data in unexpected format that is found.
148+
*
149+
* # Note
150+
*
151+
* The return value of this function can be turned into an object using Object.fromEntries.
123152
*
124-
* @returns List of all currently stored pings.
153+
* @returns List of all currently stored pings in ascending order by date.
125154
*/
126-
async getAllPings(): Promise<{ [id: string]: PingInternalRepresentation }> {
155+
async getAllPings(): Promise<[ string, PingInternalRepresentation ][]> {
127156
const allStoredPings = await this.store._getWholeStore();
128157
const finalPings: { [ident: string]: PingInternalRepresentation } = {};
129158
for (const identifier in allStoredPings) {
@@ -136,22 +165,117 @@ class PingsDatabase {
136165
}
137166
}
138167

139-
return finalPings;
168+
return Object.entries(finalPings)
169+
.sort(([_idA, { collectionDate: dateA }], [_idB, { collectionDate: dateB }]): number => {
170+
const timeA = (new Date(dateA)).getTime();
171+
const timeB = (new Date(dateB)).getTime();
172+
return timeA - timeB;
173+
});
174+
}
175+
176+
/**
177+
* Delete surplus of pings in the database by count or database size
178+
* and return list of remaining pings. Pings are deleted from oldest to newest.
179+
*
180+
* The size of the database will be calculated
181+
* (by accumulating each ping's size in bytes)
182+
* and in case the quota is exceeded, outstanding pings get deleted.
183+
*
184+
* Note: `deletion-request` pings are never deleted.
185+
*
186+
* @param maxCount The max number of pings in the database. Default: 250.
187+
* @param maxSize The max size of the database (in bytes). Default: 10MB.
188+
* @returns List of all currently stored pings, in ascending order by date.
189+
* `deletion-request` pings are always in the front of the list.
190+
*/
191+
private async getAllPingsWithoutSurplus(
192+
maxCount = 250,
193+
maxSize = 10 * 1024 * 1024, // 10MB
194+
): Promise<[ string, PingInternalRepresentation ][]> {
195+
const allPings = await this.getAllPings();
196+
// Separate deletion-request from other pings.
197+
const pings = allPings
198+
.filter(([_, ping]) => !isDeletionRequest(ping))
199+
// We need to calculate the size of the pending pings database
200+
// and delete the **oldest** pings in case quota is reached.
201+
// So, we sort them in descending order (newest -> oldest).
202+
.reverse();
203+
const deletionRequestPings = allPings.filter(([_, ping]) => isDeletionRequest(ping));
204+
205+
const total = pings.length;
206+
// TODO (bug 1722682): Record `glean.pending_pings` metric.
207+
if (total > maxCount) {
208+
log(
209+
LOG_TAG,
210+
[
211+
`More than ${maxCount} pending pings in the pings database,`,
212+
`will delete ${total - maxCount} old pings.`
213+
],
214+
LoggingLevel.Warn
215+
);
216+
}
217+
218+
let deleting = false;
219+
let pendingPingsCount = 0;
220+
let pendingPingsDatabaseSize = 0;
221+
const remainingPings: [ string, PingInternalRepresentation ][] = [];
222+
for (const [identifier, ping] of pings) {
223+
pendingPingsCount++;
224+
pendingPingsDatabaseSize += getPingSize(ping);
225+
226+
if (!deleting && pendingPingsDatabaseSize > maxSize) {
227+
log(
228+
LOG_TAG,
229+
[
230+
`Pending pings database has reached the size quota of ${maxSize} bytes,`,
231+
"outstanding pings will be deleted."
232+
],
233+
LoggingLevel.Warn
234+
);
235+
deleting = true;
236+
}
237+
238+
// Once we reach the number of allowed pings we start deleting,
239+
// no matter what size. We already log this before the loop.
240+
if (pendingPingsCount > maxCount) {
241+
deleting = true;
242+
}
243+
244+
if (deleting) {
245+
// Delete ping from database.
246+
await this.deletePing(identifier);
247+
248+
// TODO (bug 1722685): Record `deleted_pings_after_quota_hit` metric.
249+
} else {
250+
// Add pings in reverse order so the final array is in ascending order again.
251+
remainingPings.unshift([identifier, ping]);
252+
}
253+
}
254+
255+
// TODO(bug 1722686): Record `pending_pings_directory_size` metric.
256+
257+
// Return pings to original order.
258+
return [ ...deletionRequestPings, ...remainingPings ];
140259
}
141260

142261
/**
143262
* Scans the database for pending pings and enqueues them.
263+
*
264+
* # Important
265+
*
266+
* This function will also clear off pings in case
267+
* the database is exceeding the ping or size quota.
144268
*/
145269
async scanPendingPings(): Promise<void> {
146270
// If there's no observer, then there's no point in iterating.
147271
if (!this.observer) {
148272
return;
149273
}
150274

151-
const pings = await this.getAllPings();
152-
for (const identifier in pings) {
275+
const pings = await this.getAllPingsWithoutSurplus();
276+
for (const [identifier, ping] of pings) {
153277
// Notify the observer that a new ping has been added to the pings database.
154-
this.observer.update(identifier, pings[identifier]);
278+
this.observer.update(identifier, ping);
155279
}
156280
}
157281

glean/src/core/upload/index.ts

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@ import { gzipSync, strToU8 } from "fflate";
66

77
import type Platform from "../../platform/index.js";
88
import type { Configuration } from "../config.js";
9-
import { DELETION_REQUEST_PING_NAME, GLEAN_VERSION } from "../constants.js";
9+
import { GLEAN_VERSION } from "../constants.js";
1010
import { Context } from "../context.js";
1111
import Dispatcher from "../dispatcher.js";
1212
import log, { LoggingLevel } from "../log.js";
13-
import type { Observer as PingsDatabaseObserver, PingInternalRepresentation } from "../pings/database.js";
14-
import type PingsDatabase from "../pings/database.js";
13+
import type {
14+
Observer as PingsDatabaseObserver,
15+
PingInternalRepresentation
16+
} from "../pings/database.js";
17+
import {
18+
isDeletionRequest
19+
} from "../pings/database.js";
1520
import type PlatformInfo from "../platform_info.js";
1621
import { UploadResult } from "./uploader.js";
1722
import type Uploader from "./uploader.js";
@@ -51,16 +56,6 @@ interface QueuedPing extends PingInternalRepresentation {
5156
retries: number,
5257
}
5358

54-
/**
55-
* Whether or not a given queued ping is a deletion-request ping.
56-
*
57-
* @param ping The ping to verify.
58-
* @returns Whether or not the ping is a deletion-request ping.
59-
*/
60-
function isDeletionRequest(ping: QueuedPing): boolean {
61-
return ping.path.split("/")[3] === DELETION_REQUEST_PING_NAME;
62-
}
63-
6459
// Error to be thrown in case the final ping body is larger than MAX_PING_BODY_SIZE.
6560
class PingBodyOverflowError extends Error {
6661
constructor(message?: string) {
@@ -95,16 +90,15 @@ class PingUploader implements PingsDatabaseObserver {
9590
constructor(
9691
config: Configuration,
9792
platform: Platform,
98-
private readonly pingsDatabase: PingsDatabase,
99-
private readonly policy = new Policy
93+
private readonly pingsDatabase = Context.pingsDatabase,
94+
private readonly policy = new Policy()
10095
) {
10196
this.processing = [];
10297
// Initialize the ping uploader with either the platform defaults or a custom
10398
// provided uploader from the configuration object.
10499
this.uploader = config.httpClient ? config.httpClient : platform.uploader;
105100
this.platformInfo = platform.info;
106101
this.serverEndpoint = config.serverEndpoint;
107-
this.pingsDatabase = pingsDatabase;
108102

109103
// Initialize the dispatcher immediatelly.
110104
this.dispatcher = createAndInitializeDispatcher();
@@ -129,6 +123,7 @@ class PingUploader implements PingsDatabaseObserver {
129123

130124
// If the ping is a deletion-request ping, we want to enqueue it as a persistent task,
131125
// so that clearing the queue does not clear it.
126+
//
132127
// eslint-disable-next-line @typescript-eslint/unbound-method
133128
const launchFn = isDeletionRequest(ping) ? this.dispatcher.launchPersistent : this.dispatcher.launch;
134129

@@ -303,7 +298,6 @@ class PingUploader implements PingsDatabaseObserver {
303298
if (status && status >= 200 && status < 300) {
304299
log(LOG_TAG, `Ping ${identifier} succesfully sent ${status}.`, LoggingLevel.Info);
305300
await this.pingsDatabase.deletePing(identifier);
306-
this.processing;
307301
return false;
308302
}
309303

glean/tests/unit/core/glean.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -501,8 +501,8 @@ describe("Glean", function() {
501501
await Context.dispatcher.testBlockOnQueue();
502502
const storedPings = await Context.pingsDatabase.getAllPings();
503503
const counterValues = [];
504-
for (const ident in storedPings) {
505-
const metrics = storedPings[ident].payload.metrics;
504+
for (const [_, ping] of storedPings) {
505+
const metrics = ping.payload.metrics;
506506
const counterValue = isObject(metrics) && isObject(metrics.counter) ? metrics.counter["aCategory.aCounterMetric"] : undefined;
507507
// Get the value of `aCounterMetric` inside each submitted ping.
508508
counterValues.push(Number(counterValue));

0 commit comments

Comments
 (0)