@hot-updater/test-utils 1.0.0-rc.15 → 1.0.0-rc.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -43
- package/dist/index.d.mts +98 -51
- package/dist/index.mjs +581 -815
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { NIL_UUID, createReleaseCatalogScopeKey, encodeChannelKey, selectDesiredRelease } from "@hot-updater/core";
|
|
3
|
-
import { DatabaseAdapterContractError, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
|
|
3
|
+
import { DatabaseAdapterContractError, addDistinct, countDistinct, createMemoryAdapter, findPhysicalIndex, indexOrderTuple, verifyAdapter } from "@hot-updater/plugin-core/internal";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
//#region src/adminApiTestClient.ts
|
|
6
6
|
const jsonRequest = (method, body) => ({
|
|
@@ -100,27 +100,6 @@ const createBundlePatchRowFixture = (suffix, bundleId, baseBundleId, orderIndex
|
|
|
100
100
|
byte_size: 3000000002,
|
|
101
101
|
order_index: orderIndex
|
|
102
102
|
});
|
|
103
|
-
const createBundleEventRowFixture = (suffix, receivedAtMs) => ({
|
|
104
|
-
id: fixtureId(suffix),
|
|
105
|
-
type: "UPDATE_APPLIED",
|
|
106
|
-
install_id: `install-${suffix}`,
|
|
107
|
-
user_id: null,
|
|
108
|
-
metadata: {
|
|
109
|
-
username: null,
|
|
110
|
-
cohort: "0",
|
|
111
|
-
update_strategy: "appVersion",
|
|
112
|
-
fingerprint_hash: null,
|
|
113
|
-
sdk_version: null
|
|
114
|
-
},
|
|
115
|
-
from_bundle_id: fixtureId(`${Number(suffix) + 1e3}`),
|
|
116
|
-
from_release_id: null,
|
|
117
|
-
to_bundle_id: fixtureId(`${Number(suffix) + 2e3}`),
|
|
118
|
-
to_release_id: null,
|
|
119
|
-
platform: "ios",
|
|
120
|
-
app_version: "1.0.0",
|
|
121
|
-
channel: "production",
|
|
122
|
-
received_at_ms: receivedAtMs
|
|
123
|
-
});
|
|
124
103
|
const createBundleFixture = (suffix, _channel = "production") => ({
|
|
125
104
|
id: fixtureId(suffix),
|
|
126
105
|
platform: "ios",
|
|
@@ -374,710 +353,6 @@ const setupCoreAdminTestSuite = (options) => {
|
|
|
374
353
|
});
|
|
375
354
|
};
|
|
376
355
|
//#endregion
|
|
377
|
-
//#region src/expectInsightsIndex.ts
|
|
378
|
-
/** Allow committed secondary indexes to catch up; query failures still fail. */
|
|
379
|
-
const expectInsightsIndex = async (query, expected) => {
|
|
380
|
-
const deadline = Date.now() + 5e3;
|
|
381
|
-
for (;;) {
|
|
382
|
-
const actual = await query();
|
|
383
|
-
try {
|
|
384
|
-
expect(actual).toEqual(expected);
|
|
385
|
-
return;
|
|
386
|
-
} catch (error) {
|
|
387
|
-
if (Date.now() >= deadline) throw error;
|
|
388
|
-
}
|
|
389
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
//#endregion
|
|
393
|
-
//#region src/setupInsightsHttpTestSuite.ts
|
|
394
|
-
/**
|
|
395
|
-
* The Insights plugin's routes on a database: a client reports an event, and
|
|
396
|
-
* the admin routes read it back. The server must run `insights()`.
|
|
397
|
-
*/
|
|
398
|
-
const setupInsightsHttpTestSuite = (options) => {
|
|
399
|
-
describe("Insights HTTP routes", () => {
|
|
400
|
-
it("records a reported event and reads it back", async () => {
|
|
401
|
-
const client = options.getClient();
|
|
402
|
-
const installId = `install-${crypto.randomUUID()}`;
|
|
403
|
-
const userId = `user-${crypto.randomUUID()}`;
|
|
404
|
-
const channel = `insights-${crypto.randomUUID()}`;
|
|
405
|
-
const event = {
|
|
406
|
-
appVersion: "1.0.0",
|
|
407
|
-
channel,
|
|
408
|
-
cohort: "default",
|
|
409
|
-
fingerprintHash: null,
|
|
410
|
-
fromBundleId: null,
|
|
411
|
-
fromReleaseId: null,
|
|
412
|
-
installId,
|
|
413
|
-
platform: "ios",
|
|
414
|
-
toBundleId: "00000000-0000-7000-8000-000000000001",
|
|
415
|
-
toReleaseId: null,
|
|
416
|
-
type: "UNCHANGED",
|
|
417
|
-
updateStrategy: null,
|
|
418
|
-
userId,
|
|
419
|
-
username: "Jane",
|
|
420
|
-
sdkVersion: "2.0.0"
|
|
421
|
-
};
|
|
422
|
-
const reported = await client.client("/events", jsonRequest("POST", event));
|
|
423
|
-
expect(reported.status).toBe(204);
|
|
424
|
-
expect(reported.headers.get("x-hot-updater-insights")).toBeNull();
|
|
425
|
-
await reported.text();
|
|
426
|
-
const installation = await client.admin(`/installations/${encodeURIComponent(installId)}`);
|
|
427
|
-
expect(installation.status).toBe(200);
|
|
428
|
-
expect(await installation.json()).toMatchObject({
|
|
429
|
-
installId,
|
|
430
|
-
latestStatus: "UNCHANGED",
|
|
431
|
-
lastKnownBundleId: event.toBundleId,
|
|
432
|
-
channel
|
|
433
|
-
});
|
|
434
|
-
await expectInsightsIndex(async () => {
|
|
435
|
-
return (await (await client.admin(`/installations?userId=${encodeURIComponent(userId)}`)).json()).data.map(({ installId: id }) => id);
|
|
436
|
-
}, [installId]);
|
|
437
|
-
await expectInsightsIndex(async () => {
|
|
438
|
-
return (await (await client.admin(`/overview?platform=ios&channel=${encodeURIComponent(channel)}&window=24h`)).json()).reportingInstallations.count;
|
|
439
|
-
}, 1);
|
|
440
|
-
});
|
|
441
|
-
it("refuses a malformed event", async () => {
|
|
442
|
-
const response = await options.getClient().client("/events", jsonRequest("POST", { type: "UNCHANGED" }));
|
|
443
|
-
expect(response.status).toBe(400);
|
|
444
|
-
await response.text();
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
};
|
|
448
|
-
//#endregion
|
|
449
|
-
//#region src/databaseTestRunner.ts
|
|
450
|
-
var DatabaseUnavailableError = class extends Error {
|
|
451
|
-
constructor() {
|
|
452
|
-
super("The database is unavailable outside the test lifecycle");
|
|
453
|
-
this.name = "DatabaseUnavailableError";
|
|
454
|
-
}
|
|
455
|
-
};
|
|
456
|
-
const setupDatabaseTestRunner = (lifecycle, registerTests) => {
|
|
457
|
-
describe(lifecycle.name, () => {
|
|
458
|
-
let database;
|
|
459
|
-
const getDatabase = () => {
|
|
460
|
-
if (database === void 0) throw new DatabaseUnavailableError();
|
|
461
|
-
return database;
|
|
462
|
-
};
|
|
463
|
-
beforeAll(async () => {
|
|
464
|
-
await lifecycle.migrate();
|
|
465
|
-
database = await lifecycle.createDatabase();
|
|
466
|
-
});
|
|
467
|
-
beforeEach(async () => {
|
|
468
|
-
await lifecycle.reset(getDatabase());
|
|
469
|
-
});
|
|
470
|
-
afterAll(async () => {
|
|
471
|
-
await lifecycle.dispose(getDatabase());
|
|
472
|
-
database = void 0;
|
|
473
|
-
});
|
|
474
|
-
registerTests({ getDatabase });
|
|
475
|
-
});
|
|
476
|
-
};
|
|
477
|
-
//#endregion
|
|
478
|
-
//#region src/insightsModelTests.ts
|
|
479
|
-
const record = (model, event) => model.recordEvent({ event });
|
|
480
|
-
const createMovementEvent = (suffix, receivedAtMs, type, installId) => {
|
|
481
|
-
const row = createBundleEventRowFixture(suffix, receivedAtMs);
|
|
482
|
-
return {
|
|
483
|
-
...row,
|
|
484
|
-
type,
|
|
485
|
-
install_id: installId,
|
|
486
|
-
from_bundle_id: row.to_bundle_id,
|
|
487
|
-
metadata: {
|
|
488
|
-
...row.metadata,
|
|
489
|
-
update_strategy: "appVersion"
|
|
490
|
-
}
|
|
491
|
-
};
|
|
492
|
-
};
|
|
493
|
-
/**
|
|
494
|
-
* The Insights report contract on `InsightsModel`: what the Insights plugin
|
|
495
|
-
* records and reads, through `createInsightsModel`, on one database.
|
|
496
|
-
*/
|
|
497
|
-
const registerInsightsModelTests = (state) => {
|
|
498
|
-
describe("Insights report contract", () => {
|
|
499
|
-
it("preserves ancillary JSON through event history and latest-event reads", async () => {
|
|
500
|
-
const model = state.getDatabase();
|
|
501
|
-
const base = createBundleEventRowFixture("979", 100);
|
|
502
|
-
const event = {
|
|
503
|
-
...base,
|
|
504
|
-
metadata: {
|
|
505
|
-
...base.metadata,
|
|
506
|
-
device: {
|
|
507
|
-
locale: "ko-KR",
|
|
508
|
-
tags: [
|
|
509
|
-
"minor",
|
|
510
|
-
null,
|
|
511
|
-
true,
|
|
512
|
-
2
|
|
513
|
-
]
|
|
514
|
-
},
|
|
515
|
-
diagnostic: null
|
|
516
|
-
}
|
|
517
|
-
};
|
|
518
|
-
await record(model, event);
|
|
519
|
-
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
|
|
520
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
521
|
-
filter: { kind: "all" },
|
|
522
|
-
beforeReceivedAtMs: 101,
|
|
523
|
-
limit: 10
|
|
524
|
-
}), [event]);
|
|
525
|
-
});
|
|
526
|
-
it("selects the latest complete event across download, replacement, apply, and delayed reports", async () => {
|
|
527
|
-
const model = state.getDatabase();
|
|
528
|
-
const download = {
|
|
529
|
-
...createBundleEventRowFixture("980", 100),
|
|
530
|
-
type: "UPDATE_DOWNLOADED",
|
|
531
|
-
from_bundle_id: "00000000-0000-7000-8000-000000001980",
|
|
532
|
-
metadata: {
|
|
533
|
-
...createBundleEventRowFixture("980", 100).metadata,
|
|
534
|
-
update_strategy: "appVersion"
|
|
535
|
-
},
|
|
536
|
-
to_release_id: "00000000-0000-7000-8000-000000004980"
|
|
537
|
-
};
|
|
538
|
-
await record(model, download);
|
|
539
|
-
await record(model, download);
|
|
540
|
-
await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([download]);
|
|
541
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
542
|
-
filter: {
|
|
543
|
-
kind: "bundle",
|
|
544
|
-
type: "UPDATE_DOWNLOADED",
|
|
545
|
-
toBundleId: download.to_bundle_id,
|
|
546
|
-
platform: "ios",
|
|
547
|
-
channel: "production"
|
|
548
|
-
},
|
|
549
|
-
beforeReceivedAtMs: 200,
|
|
550
|
-
limit: 10
|
|
551
|
-
}), [download]);
|
|
552
|
-
const replacement = {
|
|
553
|
-
...download,
|
|
554
|
-
id: createBundleEventRowFixture("981", 110).id,
|
|
555
|
-
received_at_ms: 110,
|
|
556
|
-
to_bundle_id: "00000000-0000-7000-8000-000000002981"
|
|
557
|
-
};
|
|
558
|
-
await record(model, replacement);
|
|
559
|
-
await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([replacement]);
|
|
560
|
-
const applied = {
|
|
561
|
-
...replacement,
|
|
562
|
-
type: "UPDATE_APPLIED",
|
|
563
|
-
id: createBundleEventRowFixture("982", 120).id,
|
|
564
|
-
received_at_ms: 120
|
|
565
|
-
};
|
|
566
|
-
await record(model, applied);
|
|
567
|
-
await record(model, {
|
|
568
|
-
...download,
|
|
569
|
-
id: createBundleEventRowFixture("983", 105).id,
|
|
570
|
-
received_at_ms: 105
|
|
571
|
-
});
|
|
572
|
-
await expect(model.findLatestEvents({ installId: download.install_id })).resolves.toEqual([applied]);
|
|
573
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
574
|
-
filter: {
|
|
575
|
-
kind: "installationMovement",
|
|
576
|
-
installId: download.install_id
|
|
577
|
-
},
|
|
578
|
-
beforeReceivedAtMs: 200,
|
|
579
|
-
limit: 10
|
|
580
|
-
}), [
|
|
581
|
-
applied,
|
|
582
|
-
replacement,
|
|
583
|
-
{
|
|
584
|
-
...download,
|
|
585
|
-
id: createBundleEventRowFixture("983", 105).id,
|
|
586
|
-
received_at_ms: 105
|
|
587
|
-
},
|
|
588
|
-
download
|
|
589
|
-
]);
|
|
590
|
-
});
|
|
591
|
-
it("treats duplicate IDs as complete no-ops, including changed retry payloads", async () => {
|
|
592
|
-
const model = state.getDatabase();
|
|
593
|
-
const event = createBundleEventRowFixture("901", 100);
|
|
594
|
-
await Promise.all([
|
|
595
|
-
record(model, event),
|
|
596
|
-
record(model, event),
|
|
597
|
-
record(model, event)
|
|
598
|
-
]);
|
|
599
|
-
await record(model, event);
|
|
600
|
-
const changed = {
|
|
601
|
-
...event,
|
|
602
|
-
received_at_ms: 300,
|
|
603
|
-
user_id: "changed-user"
|
|
604
|
-
};
|
|
605
|
-
await record(model, changed);
|
|
606
|
-
const otherInstallation = {
|
|
607
|
-
...changed,
|
|
608
|
-
install_id: "different-install"
|
|
609
|
-
};
|
|
610
|
-
await record(model, otherInstallation);
|
|
611
|
-
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([event]);
|
|
612
|
-
await expect(model.findLatestEvents({ installId: otherInstallation.install_id })).resolves.toEqual([]);
|
|
613
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
614
|
-
filter: { kind: "all" },
|
|
615
|
-
beforeReceivedAtMs: 400,
|
|
616
|
-
limit: 10
|
|
617
|
-
}), [event]);
|
|
618
|
-
});
|
|
619
|
-
it("keeps all concurrent events and the greatest timestamp/ID state, including logout", async () => {
|
|
620
|
-
const model = state.getDatabase();
|
|
621
|
-
const older = {
|
|
622
|
-
...createBundleEventRowFixture("912", 100),
|
|
623
|
-
install_id: "concurrent",
|
|
624
|
-
user_id: "previous"
|
|
625
|
-
};
|
|
626
|
-
const tied = {
|
|
627
|
-
...createBundleEventRowFixture("910", 200),
|
|
628
|
-
install_id: "concurrent",
|
|
629
|
-
user_id: "previous"
|
|
630
|
-
};
|
|
631
|
-
const newest = {
|
|
632
|
-
...createBundleEventRowFixture("911", 200),
|
|
633
|
-
install_id: "concurrent",
|
|
634
|
-
user_id: null
|
|
635
|
-
};
|
|
636
|
-
await Promise.all([
|
|
637
|
-
record(model, newest),
|
|
638
|
-
record(model, older),
|
|
639
|
-
record(model, tied)
|
|
640
|
-
]);
|
|
641
|
-
await record(model, older);
|
|
642
|
-
await expect(model.findLatestEvents({ installId: "concurrent" })).resolves.toEqual([newest]);
|
|
643
|
-
await expect(model.findLatestEvents({
|
|
644
|
-
userId: "previous",
|
|
645
|
-
limit: 10
|
|
646
|
-
})).resolves.toEqual([]);
|
|
647
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
648
|
-
filter: { kind: "all" },
|
|
649
|
-
beforeReceivedAtMs: 201,
|
|
650
|
-
limit: 10
|
|
651
|
-
}), [
|
|
652
|
-
newest,
|
|
653
|
-
tied,
|
|
654
|
-
older
|
|
655
|
-
]);
|
|
656
|
-
});
|
|
657
|
-
it("rejects invalid event metadata before persisting a report", async () => {
|
|
658
|
-
const model = state.getDatabase();
|
|
659
|
-
const event = createBundleEventRowFixture("920", 100);
|
|
660
|
-
await expect(model.recordEvent({ event: {
|
|
661
|
-
...event,
|
|
662
|
-
metadata: {
|
|
663
|
-
...event.metadata,
|
|
664
|
-
cohort: 123
|
|
665
|
-
}
|
|
666
|
-
} })).rejects.toMatchObject({ code: "invalid-data" });
|
|
667
|
-
await expect(model.findLatestEvents({ installId: event.install_id })).resolves.toEqual([]);
|
|
668
|
-
await expect(model.listEvents({
|
|
669
|
-
filter: { kind: "all" },
|
|
670
|
-
beforeReceivedAtMs: 200,
|
|
671
|
-
limit: 10
|
|
672
|
-
})).resolves.toEqual([]);
|
|
673
|
-
});
|
|
674
|
-
it("shares scoped list/count predicates, recovery attribution, and half-open time bounds", async () => {
|
|
675
|
-
const model = state.getDatabase();
|
|
676
|
-
const bundleA = createBundleEventRowFixture("1", 0).to_bundle_id;
|
|
677
|
-
const bundleB = createBundleEventRowFixture("2", 0).to_bundle_id;
|
|
678
|
-
const bundleC = createBundleEventRowFixture("3", 0).to_bundle_id;
|
|
679
|
-
const applied = {
|
|
680
|
-
...createBundleEventRowFixture("930", 100),
|
|
681
|
-
install_id: "target",
|
|
682
|
-
to_bundle_id: bundleB
|
|
683
|
-
};
|
|
684
|
-
const recovered = {
|
|
685
|
-
...createBundleEventRowFixture("931", 120),
|
|
686
|
-
install_id: "target",
|
|
687
|
-
type: "RECOVERED",
|
|
688
|
-
from_bundle_id: bundleB,
|
|
689
|
-
to_bundle_id: bundleA,
|
|
690
|
-
metadata: {
|
|
691
|
-
...createBundleEventRowFixture("931", 120).metadata,
|
|
692
|
-
update_strategy: "appVersion"
|
|
693
|
-
}
|
|
694
|
-
};
|
|
695
|
-
const unchanged = {
|
|
696
|
-
...createBundleEventRowFixture("932", 130),
|
|
697
|
-
type: "UNCHANGED",
|
|
698
|
-
from_bundle_id: null,
|
|
699
|
-
to_bundle_id: bundleB,
|
|
700
|
-
metadata: {
|
|
701
|
-
...createBundleEventRowFixture("932", 130).metadata,
|
|
702
|
-
update_strategy: null
|
|
703
|
-
}
|
|
704
|
-
};
|
|
705
|
-
const excluded = [
|
|
706
|
-
{
|
|
707
|
-
...applied,
|
|
708
|
-
id: createBundleEventRowFixture("933", 99).id,
|
|
709
|
-
install_id: "before",
|
|
710
|
-
received_at_ms: 99
|
|
711
|
-
},
|
|
712
|
-
{
|
|
713
|
-
...applied,
|
|
714
|
-
id: createBundleEventRowFixture("934", 200).id,
|
|
715
|
-
install_id: "after",
|
|
716
|
-
received_at_ms: 200
|
|
717
|
-
},
|
|
718
|
-
{
|
|
719
|
-
...applied,
|
|
720
|
-
id: createBundleEventRowFixture("935", 110).id,
|
|
721
|
-
install_id: "android",
|
|
722
|
-
platform: "android"
|
|
723
|
-
},
|
|
724
|
-
{
|
|
725
|
-
...applied,
|
|
726
|
-
id: createBundleEventRowFixture("936", 110).id,
|
|
727
|
-
install_id: "preview",
|
|
728
|
-
channel: "preview"
|
|
729
|
-
},
|
|
730
|
-
{
|
|
731
|
-
...recovered,
|
|
732
|
-
id: createBundleEventRowFixture("937", 125).id,
|
|
733
|
-
install_id: "other-source",
|
|
734
|
-
from_bundle_id: bundleC,
|
|
735
|
-
to_bundle_id: bundleB
|
|
736
|
-
}
|
|
737
|
-
];
|
|
738
|
-
for (const row of [
|
|
739
|
-
applied,
|
|
740
|
-
recovered,
|
|
741
|
-
unchanged,
|
|
742
|
-
...excluded
|
|
743
|
-
]) await record(model, row);
|
|
744
|
-
await expect(async () => record(model, {
|
|
745
|
-
...applied,
|
|
746
|
-
type: "RELEASE_ADOPTED"
|
|
747
|
-
})).rejects.toThrow();
|
|
748
|
-
await expect(async () => record(model, {
|
|
749
|
-
...applied,
|
|
750
|
-
type: "UNCHANGED",
|
|
751
|
-
from_bundle_id: bundleB,
|
|
752
|
-
metadata: {
|
|
753
|
-
...applied.metadata,
|
|
754
|
-
update_strategy: "appVersion"
|
|
755
|
-
}
|
|
756
|
-
})).rejects.toThrow();
|
|
757
|
-
await expect(model.findLatestEvents({ installId: "target" })).resolves.toEqual([recovered]);
|
|
758
|
-
const cases = [
|
|
759
|
-
[{
|
|
760
|
-
platform: "ios",
|
|
761
|
-
channel: "production",
|
|
762
|
-
type: "UPDATE_APPLIED",
|
|
763
|
-
toBundleId: bundleB
|
|
764
|
-
}, applied],
|
|
765
|
-
[{
|
|
766
|
-
platform: "ios",
|
|
767
|
-
channel: "production",
|
|
768
|
-
type: "RECOVERED",
|
|
769
|
-
fromBundleId: bundleB
|
|
770
|
-
}, recovered],
|
|
771
|
-
[{
|
|
772
|
-
platform: "ios",
|
|
773
|
-
channel: "production",
|
|
774
|
-
type: "UNCHANGED",
|
|
775
|
-
toBundleId: bundleB
|
|
776
|
-
}, unchanged]
|
|
777
|
-
];
|
|
778
|
-
for (const [filter, expected] of cases) {
|
|
779
|
-
await expectInsightsIndex(() => model.countEvents({
|
|
780
|
-
filter,
|
|
781
|
-
sinceMs: 100,
|
|
782
|
-
beforeReceivedAtMs: 200
|
|
783
|
-
}), 1);
|
|
784
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
785
|
-
filter: {
|
|
786
|
-
kind: "bundle",
|
|
787
|
-
...filter
|
|
788
|
-
},
|
|
789
|
-
sinceMs: 100,
|
|
790
|
-
beforeReceivedAtMs: 200,
|
|
791
|
-
limit: 1
|
|
792
|
-
}), [expected]);
|
|
793
|
-
}
|
|
794
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
795
|
-
platform: "ios",
|
|
796
|
-
channel: "production",
|
|
797
|
-
sinceMs: 100,
|
|
798
|
-
bundle: [{
|
|
799
|
-
field: "to_bundle_id",
|
|
800
|
-
value: bundleA,
|
|
801
|
-
types: [
|
|
802
|
-
"UNCHANGED",
|
|
803
|
-
"UPDATE_APPLIED",
|
|
804
|
-
"RECOVERED"
|
|
805
|
-
]
|
|
806
|
-
}]
|
|
807
|
-
}), 1);
|
|
808
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
809
|
-
platform: "ios",
|
|
810
|
-
channel: "production",
|
|
811
|
-
sinceMs: 100
|
|
812
|
-
}), 4);
|
|
813
|
-
});
|
|
814
|
-
it("uses exact identity and UTF-8 cursor order for user installations", async () => {
|
|
815
|
-
const model = state.getDatabase();
|
|
816
|
-
const ids = [
|
|
817
|
-
"Install-a",
|
|
818
|
-
"é",
|
|
819
|
-
"install-a",
|
|
820
|
-
"install-a ",
|
|
821
|
-
"é",
|
|
822
|
-
"",
|
|
823
|
-
"😀"
|
|
824
|
-
];
|
|
825
|
-
const events = ids.map((install_id, index) => ({
|
|
826
|
-
...createBundleEventRowFixture(String(950 + index), 100),
|
|
827
|
-
install_id,
|
|
828
|
-
user_id: "User-é"
|
|
829
|
-
}));
|
|
830
|
-
for (const event of events.toReversed()) await record(model, event);
|
|
831
|
-
const spacedUser = {
|
|
832
|
-
...createBundleEventRowFixture("960", 100),
|
|
833
|
-
install_id: "spaced-user-install",
|
|
834
|
-
user_id: "User-é "
|
|
835
|
-
};
|
|
836
|
-
await record(model, spacedUser);
|
|
837
|
-
await expectInsightsIndex(async () => {
|
|
838
|
-
const found = [];
|
|
839
|
-
let afterInstallId;
|
|
840
|
-
for (let pageIndex = 0; pageIndex <= ids.length; pageIndex++) {
|
|
841
|
-
const page = await model.findLatestEvents({
|
|
842
|
-
userId: "User-é",
|
|
843
|
-
afterInstallId,
|
|
844
|
-
limit: 2
|
|
845
|
-
});
|
|
846
|
-
found.push(...page.map((row) => row.install_id));
|
|
847
|
-
if (page.length < 2) return found;
|
|
848
|
-
const nextCursor = page[page.length - 1].install_id;
|
|
849
|
-
expect(nextCursor, "Insights cursor must advance").not.toBe(afterInstallId);
|
|
850
|
-
afterInstallId = nextCursor;
|
|
851
|
-
}
|
|
852
|
-
throw new Error("Insights pagination did not terminate");
|
|
853
|
-
}, ids);
|
|
854
|
-
await expectInsightsIndex(() => model.findLatestEvents({
|
|
855
|
-
userId: "User-é ",
|
|
856
|
-
limit: 10
|
|
857
|
-
}), [spacedUser]);
|
|
858
|
-
await expect(model.findLatestEvents({
|
|
859
|
-
userId: "user-é",
|
|
860
|
-
limit: 10
|
|
861
|
-
})).resolves.toEqual([]);
|
|
862
|
-
await expect(model.findLatestEvents({ installId: "INSTALL-a" })).resolves.toEqual([]);
|
|
863
|
-
await expect(model.findLatestEvents({ installId: "Install-a" })).resolves.toEqual([events[0]]);
|
|
864
|
-
await expect(model.findLatestEvents({ installId: "install-a " })).resolves.toEqual([events[3]]);
|
|
865
|
-
await expect(model.findLatestEvents({ installId: "install-a" })).resolves.toEqual([events[2]]);
|
|
866
|
-
});
|
|
867
|
-
it("counts overlapping bundle predicates once per latest installation", async () => {
|
|
868
|
-
const model = state.getDatabase();
|
|
869
|
-
const event = createBundleEventRowFixture("9700", 100);
|
|
870
|
-
await record(model, event);
|
|
871
|
-
const predicate = {
|
|
872
|
-
field: "to_bundle_id",
|
|
873
|
-
value: event.to_bundle_id,
|
|
874
|
-
types: [event.type]
|
|
875
|
-
};
|
|
876
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
877
|
-
platform: event.platform,
|
|
878
|
-
channel: event.channel,
|
|
879
|
-
sinceMs: 0,
|
|
880
|
-
bundle: [predicate, predicate]
|
|
881
|
-
}), 1);
|
|
882
|
-
});
|
|
883
|
-
it("returns zero for successful empty scalar queries", async () => {
|
|
884
|
-
const model = state.getDatabase();
|
|
885
|
-
await expect(model.countLatestEvents({
|
|
886
|
-
platform: "ios",
|
|
887
|
-
channel: "production",
|
|
888
|
-
sinceMs: 0
|
|
889
|
-
})).resolves.toBe(0);
|
|
890
|
-
await expect(model.countEvents({
|
|
891
|
-
filter: {
|
|
892
|
-
platform: "ios",
|
|
893
|
-
channel: "production",
|
|
894
|
-
type: "RECOVERED",
|
|
895
|
-
fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
|
|
896
|
-
},
|
|
897
|
-
sinceMs: 0,
|
|
898
|
-
beforeReceivedAtMs: 100
|
|
899
|
-
})).resolves.toBe(0);
|
|
900
|
-
});
|
|
901
|
-
it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
|
|
902
|
-
const model = state.getDatabase();
|
|
903
|
-
const releaseA = "00000000-0000-7000-8000-000000008001";
|
|
904
|
-
const releaseB = "00000000-0000-7000-8000-000000008002";
|
|
905
|
-
const download = {
|
|
906
|
-
...createBundleEventRowFixture("9801", 1e3),
|
|
907
|
-
type: "UPDATE_DOWNLOADED",
|
|
908
|
-
install_id: "summary-install-a",
|
|
909
|
-
from_release_id: releaseA,
|
|
910
|
-
to_release_id: releaseB
|
|
911
|
-
};
|
|
912
|
-
const appliedA = {
|
|
913
|
-
...download,
|
|
914
|
-
id: createBundleEventRowFixture("9802", 2e3).id,
|
|
915
|
-
type: "UPDATE_APPLIED",
|
|
916
|
-
received_at_ms: 2e3
|
|
917
|
-
};
|
|
918
|
-
const appliedB = {
|
|
919
|
-
...appliedA,
|
|
920
|
-
id: createBundleEventRowFixture("9803", 2100).id,
|
|
921
|
-
install_id: "summary-install-b",
|
|
922
|
-
received_at_ms: 2100
|
|
923
|
-
};
|
|
924
|
-
const recovered = {
|
|
925
|
-
...appliedA,
|
|
926
|
-
id: createBundleEventRowFixture("9804", 3e3).id,
|
|
927
|
-
type: "RECOVERED",
|
|
928
|
-
from_release_id: releaseB,
|
|
929
|
-
to_release_id: releaseA,
|
|
930
|
-
received_at_ms: 3e3
|
|
931
|
-
};
|
|
932
|
-
for (const event of [
|
|
933
|
-
download,
|
|
934
|
-
appliedA,
|
|
935
|
-
appliedB,
|
|
936
|
-
appliedB,
|
|
937
|
-
recovered
|
|
938
|
-
]) await record(model, event);
|
|
939
|
-
const releases = [{
|
|
940
|
-
releaseId: releaseA,
|
|
941
|
-
platform: "ios",
|
|
942
|
-
channel: "production"
|
|
943
|
-
}, {
|
|
944
|
-
releaseId: releaseB,
|
|
945
|
-
platform: "ios",
|
|
946
|
-
channel: "production"
|
|
947
|
-
}];
|
|
948
|
-
const lifetime = await model.getReleaseActivity({ releases });
|
|
949
|
-
expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
|
|
950
|
-
downloads: 0,
|
|
951
|
-
launches: 1,
|
|
952
|
-
failedLaunches: 0
|
|
953
|
-
}, {
|
|
954
|
-
downloads: 1,
|
|
955
|
-
launches: 2,
|
|
956
|
-
failedLaunches: 1
|
|
957
|
-
}]);
|
|
958
|
-
const scope = await model.getReleaseActivity({
|
|
959
|
-
scope: {
|
|
960
|
-
platform: "ios",
|
|
961
|
-
channel: "production"
|
|
962
|
-
},
|
|
963
|
-
timeRange: {
|
|
964
|
-
start: 0,
|
|
965
|
-
end: 36e5
|
|
966
|
-
}
|
|
967
|
-
});
|
|
968
|
-
expect(scope.data[0]?.metrics).toMatchObject({
|
|
969
|
-
downloads: 1,
|
|
970
|
-
launches: 3,
|
|
971
|
-
failedLaunches: 1,
|
|
972
|
-
uniqueUsers: 2
|
|
973
|
-
});
|
|
974
|
-
const usage = await model.getAppUsage({
|
|
975
|
-
channel: "production",
|
|
976
|
-
platform: "all",
|
|
977
|
-
timeRange: {
|
|
978
|
-
start: 0,
|
|
979
|
-
end: 36e5
|
|
980
|
-
},
|
|
981
|
-
intervalMs: 36e5
|
|
982
|
-
});
|
|
983
|
-
expect(usage.activeInstallations).toBe(2);
|
|
984
|
-
expect(usage.points).toEqual([{
|
|
985
|
-
startMs: 0,
|
|
986
|
-
installations: 2
|
|
987
|
-
}]);
|
|
988
|
-
expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
|
|
989
|
-
appVersion: "1.0.0",
|
|
990
|
-
platform: "ios",
|
|
991
|
-
releaseId: releaseA,
|
|
992
|
-
installations: 1
|
|
993
|
-
}, {
|
|
994
|
-
appVersion: "1.0.0",
|
|
995
|
-
platform: "ios",
|
|
996
|
-
releaseId: releaseB,
|
|
997
|
-
installations: 1
|
|
998
|
-
}]));
|
|
999
|
-
});
|
|
1000
|
-
it("pages insights events newest first with a stable cursor", async () => {
|
|
1001
|
-
const model = state.getDatabase();
|
|
1002
|
-
const first = createBundleEventRowFixture("701", 100);
|
|
1003
|
-
const second = createBundleEventRowFixture("702", 100);
|
|
1004
|
-
const third = createBundleEventRowFixture("703", 200);
|
|
1005
|
-
await model.recordEvent({ event: third });
|
|
1006
|
-
await model.recordEvent({ event: second });
|
|
1007
|
-
await model.recordEvent({ event: first });
|
|
1008
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1009
|
-
filter: { kind: "all" },
|
|
1010
|
-
beforeReceivedAtMs: 201,
|
|
1011
|
-
limit: 2
|
|
1012
|
-
}), [third, second]);
|
|
1013
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1014
|
-
filter: { kind: "all" },
|
|
1015
|
-
after: {
|
|
1016
|
-
receivedAtMs: second.received_at_ms,
|
|
1017
|
-
id: second.id
|
|
1018
|
-
},
|
|
1019
|
-
beforeReceivedAtMs: 201,
|
|
1020
|
-
limit: 2
|
|
1021
|
-
}), [first]);
|
|
1022
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1023
|
-
filter: { kind: "all" },
|
|
1024
|
-
beforeReceivedAtMs: 200,
|
|
1025
|
-
limit: 10
|
|
1026
|
-
}), [second, first]);
|
|
1027
|
-
});
|
|
1028
|
-
it("filters installation movements before applying the page limit", async () => {
|
|
1029
|
-
const model = state.getDatabase();
|
|
1030
|
-
const unchanged = {
|
|
1031
|
-
...createBundleEventRowFixture("711", 300),
|
|
1032
|
-
type: "UNCHANGED",
|
|
1033
|
-
install_id: "install-target",
|
|
1034
|
-
from_bundle_id: null,
|
|
1035
|
-
metadata: {
|
|
1036
|
-
...createBundleEventRowFixture("711", 300).metadata,
|
|
1037
|
-
update_strategy: null
|
|
1038
|
-
}
|
|
1039
|
-
};
|
|
1040
|
-
const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
|
|
1041
|
-
const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
|
|
1042
|
-
const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
|
|
1043
|
-
for (const row of [
|
|
1044
|
-
unchanged,
|
|
1045
|
-
unrelated,
|
|
1046
|
-
applied,
|
|
1047
|
-
recovered
|
|
1048
|
-
]) await model.recordEvent({ event: row });
|
|
1049
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1050
|
-
filter: {
|
|
1051
|
-
kind: "installationMovement",
|
|
1052
|
-
installId: "install-target"
|
|
1053
|
-
},
|
|
1054
|
-
beforeReceivedAtMs: 301,
|
|
1055
|
-
limit: 1
|
|
1056
|
-
}), [applied]);
|
|
1057
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1058
|
-
filter: {
|
|
1059
|
-
kind: "installationMovement",
|
|
1060
|
-
installId: "install-target"
|
|
1061
|
-
},
|
|
1062
|
-
after: {
|
|
1063
|
-
receivedAtMs: applied.received_at_ms,
|
|
1064
|
-
id: applied.id
|
|
1065
|
-
},
|
|
1066
|
-
beforeReceivedAtMs: 301,
|
|
1067
|
-
limit: 1
|
|
1068
|
-
}), [recovered]);
|
|
1069
|
-
});
|
|
1070
|
-
});
|
|
1071
|
-
};
|
|
1072
|
-
//#endregion
|
|
1073
|
-
//#region src/setupInsightsModelTestSuite.ts
|
|
1074
|
-
/**
|
|
1075
|
-
* The Insights report contract on one database: pass the Insights plugin's
|
|
1076
|
-
* model over it, `createInsightsModel(createDatabasePluginApis(database,
|
|
1077
|
-
* [insights()]).insights)`, as `createDatabase`.
|
|
1078
|
-
*/
|
|
1079
|
-
const setupInsightsModelTestSuite = (lifecycle) => setupDatabaseTestRunner(lifecycle, registerInsightsModelTests);
|
|
1080
|
-
//#endregion
|
|
1081
356
|
//#region src/setupSemverSatisfiesTestSuite.ts
|
|
1082
357
|
/**
|
|
1083
358
|
*
|
|
@@ -1288,6 +563,35 @@ const setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
|
|
|
1288
563
|
});
|
|
1289
564
|
};
|
|
1290
565
|
//#endregion
|
|
566
|
+
//#region src/databaseTestRunner.ts
|
|
567
|
+
var DatabaseUnavailableError = class extends Error {
|
|
568
|
+
constructor() {
|
|
569
|
+
super("The database is unavailable outside the test lifecycle");
|
|
570
|
+
this.name = "DatabaseUnavailableError";
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const setupDatabaseTestRunner = (lifecycle, registerTests) => {
|
|
574
|
+
describe(lifecycle.name, () => {
|
|
575
|
+
let database;
|
|
576
|
+
const getDatabase = () => {
|
|
577
|
+
if (database === void 0) throw new DatabaseUnavailableError();
|
|
578
|
+
return database;
|
|
579
|
+
};
|
|
580
|
+
beforeAll(async () => {
|
|
581
|
+
await lifecycle.migrate();
|
|
582
|
+
database = await lifecycle.createDatabase();
|
|
583
|
+
});
|
|
584
|
+
beforeEach(async () => {
|
|
585
|
+
await lifecycle.reset(getDatabase());
|
|
586
|
+
});
|
|
587
|
+
afterAll(async () => {
|
|
588
|
+
await lifecycle.dispose(getDatabase());
|
|
589
|
+
database = void 0;
|
|
590
|
+
});
|
|
591
|
+
registerTests({ getDatabase });
|
|
592
|
+
});
|
|
593
|
+
};
|
|
594
|
+
//#endregion
|
|
1291
595
|
//#region src/releaseCatalogHttpFixtures.ts
|
|
1292
596
|
const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
|
|
1293
597
|
const targetId = createBundleRowFixture("302").id;
|
|
@@ -1682,10 +986,11 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
1682
986
|
const otherPath = strategy === "APP_VERSION" ? `/release-catalogs/fingerprint/ios/${scope().channelKey}/fingerprint-a` : `/release-catalogs/app-version/ios/${scope().channelKey}/1.2.3`;
|
|
1683
987
|
expect((await request(otherPath)).status).toBe(404);
|
|
1684
988
|
});
|
|
1685
|
-
it("
|
|
989
|
+
it("caches an unknown scope's 404 like a catalog", async () => {
|
|
1686
990
|
const response = await request(catalogPath());
|
|
1687
991
|
expect(response.status).toBe(404);
|
|
1688
|
-
expect(response.headers.get("cache-control")).toBe("
|
|
992
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=0, s-maxage=5");
|
|
993
|
+
expect(response.headers.get("x-hot-updater-catalog")).toBe("none");
|
|
1689
994
|
await publish("121");
|
|
1690
995
|
expect((await readCatalog()).releases).toHaveLength(1);
|
|
1691
996
|
});
|
|
@@ -1997,9 +1302,8 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
1997
1302
|
//#region src/setupDatabaseTestSuite.ts
|
|
1998
1303
|
/**
|
|
1999
1304
|
* A provider's database under Hot Updater's server: core's operations, the
|
|
2000
|
-
* Release Catalog contract,
|
|
2001
|
-
*
|
|
2002
|
-
* Insights report contract in process.
|
|
1305
|
+
* Release Catalog contract, and bundles, all through HTTP, on the tables the
|
|
1306
|
+
* provider's tooling creates, then each opted-in plugin's suite.
|
|
2003
1307
|
*/
|
|
2004
1308
|
const setupDatabaseTestSuite = (options) => {
|
|
2005
1309
|
setupDatabaseTestRunner(options, ({ getDatabase }) => {
|
|
@@ -2021,10 +1325,11 @@ const setupDatabaseTestSuite = (options) => {
|
|
|
2021
1325
|
setupCoreAdminTestSuite({ getClient });
|
|
2022
1326
|
setupBundleMethodsTestSuite({ getClient });
|
|
2023
1327
|
setupReleaseCatalogTestSuite({ getClient });
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
1328
|
+
for (const suite of options.plugins ?? []) describe(suite.name, () => {
|
|
1329
|
+
suite.register({
|
|
1330
|
+
getClient,
|
|
1331
|
+
getDatabase
|
|
1332
|
+
});
|
|
2028
1333
|
});
|
|
2029
1334
|
});
|
|
2030
1335
|
};
|
|
@@ -2151,7 +1456,71 @@ const conformanceCounters = {
|
|
|
2151
1456
|
sort: ["shard"]
|
|
2152
1457
|
}]
|
|
2153
1458
|
};
|
|
2154
|
-
|
|
1459
|
+
/** Rows expire 1,000 ms after `at`; a null `at` never expires. */
|
|
1460
|
+
const conformanceExpiring = {
|
|
1461
|
+
name: "conformance_expiring",
|
|
1462
|
+
columns: [
|
|
1463
|
+
{
|
|
1464
|
+
name: "id",
|
|
1465
|
+
type: "string",
|
|
1466
|
+
nullable: false,
|
|
1467
|
+
maxLength: 64
|
|
1468
|
+
},
|
|
1469
|
+
{
|
|
1470
|
+
name: "grp",
|
|
1471
|
+
type: "string",
|
|
1472
|
+
nullable: false,
|
|
1473
|
+
maxLength: 64
|
|
1474
|
+
},
|
|
1475
|
+
{
|
|
1476
|
+
name: "at",
|
|
1477
|
+
type: "integer",
|
|
1478
|
+
nullable: true
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
name: "tags",
|
|
1482
|
+
type: "string",
|
|
1483
|
+
nullable: true,
|
|
1484
|
+
maxLength: 64,
|
|
1485
|
+
multi: true
|
|
1486
|
+
},
|
|
1487
|
+
{
|
|
1488
|
+
name: "_v",
|
|
1489
|
+
type: "integer",
|
|
1490
|
+
nullable: false
|
|
1491
|
+
}
|
|
1492
|
+
],
|
|
1493
|
+
key: ["id"],
|
|
1494
|
+
indexes: [{
|
|
1495
|
+
name: "byGroup",
|
|
1496
|
+
eq: ["grp"],
|
|
1497
|
+
sort: ["id"]
|
|
1498
|
+
}, {
|
|
1499
|
+
name: "byTag",
|
|
1500
|
+
eq: ["tags"],
|
|
1501
|
+
sort: []
|
|
1502
|
+
}],
|
|
1503
|
+
retention: {
|
|
1504
|
+
column: "at",
|
|
1505
|
+
ms: 1e3
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
const tables = [
|
|
1509
|
+
conformanceItems,
|
|
1510
|
+
conformanceCounters,
|
|
1511
|
+
conformanceExpiring
|
|
1512
|
+
];
|
|
1513
|
+
const expiring = (id, at) => ({
|
|
1514
|
+
type: "insert",
|
|
1515
|
+
table: conformanceExpiring,
|
|
1516
|
+
row: {
|
|
1517
|
+
id,
|
|
1518
|
+
grp: "g",
|
|
1519
|
+
at,
|
|
1520
|
+
tags: ["t"],
|
|
1521
|
+
_v: 0
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
2155
1524
|
const item = (id, values = {}) => ({
|
|
2156
1525
|
id,
|
|
2157
1526
|
grp: "g",
|
|
@@ -2728,6 +2097,79 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
|
|
|
2728
2097
|
const results = await Promise.all([adapter.write(skew(a, b)), adapter.write(skew(b, a))]);
|
|
2729
2098
|
expect(results.filter((result) => result.ok).length).toBeLessThanOrEqual(1);
|
|
2730
2099
|
});
|
|
2100
|
+
const expiringIds = async (adapter) => ({
|
|
2101
|
+
byGroup: ids(await adapter.query(conformanceExpiring, {
|
|
2102
|
+
index: "byGroup",
|
|
2103
|
+
eq: ["g"],
|
|
2104
|
+
order: "asc",
|
|
2105
|
+
limit: 500
|
|
2106
|
+
})),
|
|
2107
|
+
byTag: ids(await adapter.query(conformanceExpiring, {
|
|
2108
|
+
index: "byTag",
|
|
2109
|
+
eq: ["t"],
|
|
2110
|
+
order: "asc",
|
|
2111
|
+
limit: 500
|
|
2112
|
+
}))
|
|
2113
|
+
});
|
|
2114
|
+
it.skipIf(options.retention === "ttl")("prunes expired rows oldest first, up to the limit, with their index entries", async () => {
|
|
2115
|
+
const adapter = await setup();
|
|
2116
|
+
await ok(adapter, [
|
|
2117
|
+
expiring("a", 300),
|
|
2118
|
+
expiring("b", 100),
|
|
2119
|
+
expiring("c", 200),
|
|
2120
|
+
expiring("d", null),
|
|
2121
|
+
expiring("e", 5e3),
|
|
2122
|
+
expiring("f", 100)
|
|
2123
|
+
]);
|
|
2124
|
+
expect(await adapter.prune(conformanceExpiring, 250, 2)).toBe(2);
|
|
2125
|
+
expect((await expiringIds(adapter)).byGroup).toEqual([
|
|
2126
|
+
"a",
|
|
2127
|
+
"c",
|
|
2128
|
+
"d",
|
|
2129
|
+
"e"
|
|
2130
|
+
]);
|
|
2131
|
+
expect(await adapter.prune(conformanceExpiring, 250, 10)).toBe(1);
|
|
2132
|
+
expect(await adapter.prune(conformanceExpiring, 250, 10)).toBe(0);
|
|
2133
|
+
expect(await expiringIds(adapter)).toEqual({
|
|
2134
|
+
byGroup: [
|
|
2135
|
+
"a",
|
|
2136
|
+
"d",
|
|
2137
|
+
"e"
|
|
2138
|
+
],
|
|
2139
|
+
byTag: [
|
|
2140
|
+
"a",
|
|
2141
|
+
"d",
|
|
2142
|
+
"e"
|
|
2143
|
+
]
|
|
2144
|
+
});
|
|
2145
|
+
expect(await adapter.get(conformanceExpiring, [
|
|
2146
|
+
["b"],
|
|
2147
|
+
["c"],
|
|
2148
|
+
["d"]
|
|
2149
|
+
])).toEqual([
|
|
2150
|
+
null,
|
|
2151
|
+
null,
|
|
2152
|
+
expect.objectContaining({
|
|
2153
|
+
id: "d",
|
|
2154
|
+
at: null
|
|
2155
|
+
})
|
|
2156
|
+
]);
|
|
2157
|
+
await ok(adapter, [insert(item("kept"))]);
|
|
2158
|
+
expect(await adapter.prune(conformanceExpiring, 1e4, 10)).toBe(2);
|
|
2159
|
+
expect(ids(await query(adapter, {
|
|
2160
|
+
index: "all",
|
|
2161
|
+
eq: []
|
|
2162
|
+
}))).toEqual(["kept"]);
|
|
2163
|
+
});
|
|
2164
|
+
it.skipIf(options.retention !== "ttl")("leaves expired rows to the backend's TTL and reads them until it deletes them", async () => {
|
|
2165
|
+
const adapter = await setup();
|
|
2166
|
+
expect(adapter.prune).toBeUndefined();
|
|
2167
|
+
await ok(adapter, [expiring("old", 0), expiring("never", null)]);
|
|
2168
|
+
expect(await expiringIds(adapter)).toEqual({
|
|
2169
|
+
byGroup: ["never", "old"],
|
|
2170
|
+
byTag: ["never", "old"]
|
|
2171
|
+
});
|
|
2172
|
+
});
|
|
2731
2173
|
it.skipIf(options.maxOps === void 0)("rejects an over-limit write before sending it", async () => {
|
|
2732
2174
|
const adapter = await setup();
|
|
2733
2175
|
const ops = Array.from({ length: (options.maxOps ?? 0) + 1 }, (_, index) => insert(item(`over${index}`)));
|
|
@@ -2773,7 +2215,6 @@ const eventOf = (n, overrides = {}) => ({
|
|
|
2773
2215
|
app_version: "1.0.0",
|
|
2774
2216
|
channel: "production",
|
|
2775
2217
|
metadata: {
|
|
2776
|
-
username: null,
|
|
2777
2218
|
cohort: "1",
|
|
2778
2219
|
update_strategy: "appVersion",
|
|
2779
2220
|
fingerprint_hash: null,
|
|
@@ -2783,6 +2224,31 @@ const eventOf = (n, overrides = {}) => ({
|
|
|
2783
2224
|
...overrides
|
|
2784
2225
|
});
|
|
2785
2226
|
/**
|
|
2227
|
+
* Update failures on day 3, after every other windowed read's range: installs
|
|
2228
|
+
* 3 and 5 fail to download release-b, and install 7's update check fails.
|
|
2229
|
+
*/
|
|
2230
|
+
const failureOf = (n, install, check = false) => eventOf(200 + n, {
|
|
2231
|
+
type: "UPDATE_FAILED",
|
|
2232
|
+
install_id: `install-${install}`,
|
|
2233
|
+
from_release_id: "release-a",
|
|
2234
|
+
from_bundle_id: "bundle-a",
|
|
2235
|
+
to_release_id: check ? null : "release-b",
|
|
2236
|
+
to_bundle_id: check ? "bundle-a" : "bundle-b",
|
|
2237
|
+
metadata: {
|
|
2238
|
+
...eventOf(0).metadata,
|
|
2239
|
+
failure: check ? {
|
|
2240
|
+
stage: "check",
|
|
2241
|
+
reason: "http",
|
|
2242
|
+
http_status: 500
|
|
2243
|
+
} : {
|
|
2244
|
+
stage: "download",
|
|
2245
|
+
reason: "http",
|
|
2246
|
+
http_status: 403
|
|
2247
|
+
}
|
|
2248
|
+
},
|
|
2249
|
+
received_at_ms: T0 + 3 * DAY + n * 10 * 6e4
|
|
2250
|
+
});
|
|
2251
|
+
/**
|
|
2786
2252
|
* History event `n` of the day before T0, three an hour, on its own release
|
|
2787
2253
|
* and bundles: every windowed read, which starts at T0 or later, skips it.
|
|
2788
2254
|
*/
|
|
@@ -2875,6 +2341,9 @@ const seed = async (database, server) => {
|
|
|
2875
2341
|
received_at_ms: T0 + 2 * DAY
|
|
2876
2342
|
}));
|
|
2877
2343
|
for (let n = 1; n <= 72; n += 1) await api.insights.recordEvent(historyOf(n));
|
|
2344
|
+
await api.insights.recordEvent(failureOf(1, 3));
|
|
2345
|
+
await api.insights.recordEvent(failureOf(2, 5));
|
|
2346
|
+
await api.insights.recordEvent(failureOf(3, 7, true));
|
|
2878
2347
|
const channel = (name) => core.findChannelByName(name).then((found) => found);
|
|
2879
2348
|
const productionId = (await channel("production")).id;
|
|
2880
2349
|
return {
|
|
@@ -2886,7 +2355,7 @@ const seed = async (database, server) => {
|
|
|
2886
2355
|
nightlyId: (await channel("nightly")).id,
|
|
2887
2356
|
/** The auto-patch base key of a new production iOS 1.0.0 bundle. */
|
|
2888
2357
|
candidateKey: server.targetBaseCandidateKey({
|
|
2889
|
-
|
|
2358
|
+
channel: "production",
|
|
2890
2359
|
platform: "ios",
|
|
2891
2360
|
fingerprintHash: null,
|
|
2892
2361
|
appVersion: "1.0.0"
|
|
@@ -3006,12 +2475,12 @@ const READ_BUDGETS = [
|
|
|
3006
2475
|
returned: (releases) => releases.length
|
|
3007
2476
|
}),
|
|
3008
2477
|
budget({
|
|
3009
|
-
api: "deploy base candidates:
|
|
2478
|
+
api: "deploy base candidates: 1 catalog point read",
|
|
3010
2479
|
read: ({ core, candidateKey }) => core.findBaseBundleIds(candidateKey, createBundleFixture("107").id, 2),
|
|
3011
|
-
adapter: reads(
|
|
2480
|
+
adapter: reads(1, 1, 0, 0),
|
|
3012
2481
|
engine: {
|
|
3013
2482
|
calls: 1,
|
|
3014
|
-
rows:
|
|
2483
|
+
rows: 1
|
|
3015
2484
|
},
|
|
3016
2485
|
check: (ids) => expect(ids).toEqual(["106", "105"].map((suffix) => createBundleFixture(suffix).id))
|
|
3017
2486
|
}),
|
|
@@ -3035,17 +2504,47 @@ const READ_BUDGETS = [
|
|
|
3035
2504
|
returned: (channel) => channel === null ? 0 : 1
|
|
3036
2505
|
}),
|
|
3037
2506
|
budget({
|
|
3038
|
-
api: "list events: limit rows
|
|
2507
|
+
api: "list events of a dense day: limit rows from one query",
|
|
2508
|
+
read: ({ insights }) => insights.listEvents({
|
|
2509
|
+
filter: { kind: "all" },
|
|
2510
|
+
sinceMs: T0 - DAY,
|
|
2511
|
+
beforeReceivedAtMs: T0,
|
|
2512
|
+
limit: 5
|
|
2513
|
+
}),
|
|
2514
|
+
adapter: reads(0, 0, 1, 5),
|
|
2515
|
+
engine: {
|
|
2516
|
+
calls: 1,
|
|
2517
|
+
rows: 5
|
|
2518
|
+
},
|
|
2519
|
+
returned: (events) => events.length
|
|
2520
|
+
}),
|
|
2521
|
+
budget({
|
|
2522
|
+
api: "list events across a gap: limit rows, one empty day, and one outcome row",
|
|
3039
2523
|
read: ({ insights }) => insights.listEvents({
|
|
3040
2524
|
filter: bundleEvents,
|
|
3041
2525
|
sinceMs: T0,
|
|
3042
2526
|
beforeReceivedAtMs: T0 + 3 * DAY,
|
|
3043
2527
|
limit: 5
|
|
3044
2528
|
}),
|
|
3045
|
-
adapter: reads(
|
|
2529
|
+
adapter: reads(1, 7, 4, 6),
|
|
3046
2530
|
engine: {
|
|
3047
|
-
calls:
|
|
3048
|
-
rows:
|
|
2531
|
+
calls: 4,
|
|
2532
|
+
rows: 6
|
|
2533
|
+
},
|
|
2534
|
+
returned: (events) => events.length + 1
|
|
2535
|
+
}),
|
|
2536
|
+
budget({
|
|
2537
|
+
api: "list events over an empty range: its top day and one outcome read, both empty",
|
|
2538
|
+
read: ({ insights }) => insights.listEvents({
|
|
2539
|
+
filter: { kind: "all" },
|
|
2540
|
+
sinceMs: T0 - 91 * DAY,
|
|
2541
|
+
beforeReceivedAtMs: T0 - DAY,
|
|
2542
|
+
limit: 5
|
|
2543
|
+
}),
|
|
2544
|
+
adapter: reads(0, 0, 2, 0),
|
|
2545
|
+
engine: {
|
|
2546
|
+
calls: 2,
|
|
2547
|
+
rows: 0
|
|
3049
2548
|
},
|
|
3050
2549
|
returned: (events) => events.length
|
|
3051
2550
|
}),
|
|
@@ -3091,14 +2590,14 @@ const READ_BUDGETS = [
|
|
|
3091
2590
|
read: ({ insights }) => insights.countLatestEvents({
|
|
3092
2591
|
platform: "ios",
|
|
3093
2592
|
channel: "production",
|
|
3094
|
-
sinceMs: T0
|
|
2593
|
+
sinceMs: T0
|
|
3095
2594
|
}),
|
|
3096
|
-
adapter: reads(0, 0, 1,
|
|
2595
|
+
adapter: reads(0, 0, 1, 15),
|
|
3097
2596
|
engine: {
|
|
3098
2597
|
calls: 1,
|
|
3099
|
-
rows:
|
|
2598
|
+
rows: 2
|
|
3100
2599
|
},
|
|
3101
|
-
check: (count) => expect(count).toBe(
|
|
2600
|
+
check: (count) => expect(count).toBe(24)
|
|
3102
2601
|
}),
|
|
3103
2602
|
budget({
|
|
3104
2603
|
api: "countLatestEvents by bundle: buckets in the window × shards, all used",
|
|
@@ -3112,10 +2611,10 @@ const READ_BUDGETS = [
|
|
|
3112
2611
|
types: ["UPDATE_APPLIED"]
|
|
3113
2612
|
}]
|
|
3114
2613
|
}),
|
|
3115
|
-
adapter: reads(0, 0, 1,
|
|
2614
|
+
adapter: reads(0, 0, 1, 15),
|
|
3116
2615
|
engine: {
|
|
3117
2616
|
calls: 1,
|
|
3118
|
-
rows:
|
|
2617
|
+
rows: 2
|
|
3119
2618
|
},
|
|
3120
2619
|
check: (count) => expect(count).toBe(24)
|
|
3121
2620
|
}),
|
|
@@ -3163,23 +2662,86 @@ const READ_BUDGETS = [
|
|
|
3163
2662
|
},
|
|
3164
2663
|
intervalMs: DAY
|
|
3165
2664
|
}),
|
|
3166
|
-
adapter: reads(0, 0,
|
|
2665
|
+
adapter: reads(0, 0, 4, 30),
|
|
3167
2666
|
engine: {
|
|
3168
|
-
calls:
|
|
3169
|
-
rows:
|
|
2667
|
+
calls: 4,
|
|
2668
|
+
rows: 4
|
|
3170
2669
|
},
|
|
3171
2670
|
check: ({ versions }) => expect(versions).toEqual([{
|
|
3172
2671
|
name: "1.0.0",
|
|
3173
2672
|
installations: 24
|
|
3174
2673
|
}])
|
|
3175
2674
|
}),
|
|
2675
|
+
budget({
|
|
2676
|
+
api: "update failures of a release over a window: buckets × shards, all used",
|
|
2677
|
+
read: ({ insights }) => insights.getUpdateFailures({
|
|
2678
|
+
platform: "ios",
|
|
2679
|
+
channel: "production",
|
|
2680
|
+
releaseId: "release-b",
|
|
2681
|
+
timeRange: {
|
|
2682
|
+
start: T0 + 3 * DAY,
|
|
2683
|
+
end: T0 + 4 * DAY
|
|
2684
|
+
}
|
|
2685
|
+
}),
|
|
2686
|
+
adapter: reads(0, 0, 3, 5),
|
|
2687
|
+
engine: {
|
|
2688
|
+
calls: 3,
|
|
2689
|
+
rows: 3
|
|
2690
|
+
},
|
|
2691
|
+
check: (failures) => {
|
|
2692
|
+
expect(failures).toMatchObject({ failedUpdates: 2 });
|
|
2693
|
+
expect(failures.breakdown).toEqual([{
|
|
2694
|
+
stage: "download",
|
|
2695
|
+
reason: "http",
|
|
2696
|
+
events: 2,
|
|
2697
|
+
details: [expect.anything()]
|
|
2698
|
+
}]);
|
|
2699
|
+
}
|
|
2700
|
+
}),
|
|
2701
|
+
budget({
|
|
2702
|
+
api: "update failures of a channel over a window: buckets × shards, all used",
|
|
2703
|
+
read: ({ insights }) => insights.getUpdateFailures({
|
|
2704
|
+
platform: "ios",
|
|
2705
|
+
channel: "production",
|
|
2706
|
+
timeRange: {
|
|
2707
|
+
start: T0 + 3 * DAY,
|
|
2708
|
+
end: T0 + 4 * DAY
|
|
2709
|
+
}
|
|
2710
|
+
}),
|
|
2711
|
+
adapter: reads(0, 0, 5, 6),
|
|
2712
|
+
engine: {
|
|
2713
|
+
calls: 5,
|
|
2714
|
+
rows: 4
|
|
2715
|
+
},
|
|
2716
|
+
check: (failures) => expect(failures).toMatchObject({
|
|
2717
|
+
failedUpdates: 2,
|
|
2718
|
+
checks: { failures: 1 }
|
|
2719
|
+
})
|
|
2720
|
+
}),
|
|
2721
|
+
budget({
|
|
2722
|
+
api: "update failures of a release since its first: two kept rows × shards, all used",
|
|
2723
|
+
read: ({ insights }) => insights.getUpdateFailures({
|
|
2724
|
+
platform: "ios",
|
|
2725
|
+
channel: "production",
|
|
2726
|
+
releaseId: "release-b"
|
|
2727
|
+
}),
|
|
2728
|
+
adapter: reads(0, 0, 2, 10),
|
|
2729
|
+
engine: {
|
|
2730
|
+
calls: 2,
|
|
2731
|
+
rows: 2
|
|
2732
|
+
},
|
|
2733
|
+
check: (failures) => expect(failures).toMatchObject({
|
|
2734
|
+
failedUpdates: 2,
|
|
2735
|
+
failedInstallations: 2
|
|
2736
|
+
})
|
|
2737
|
+
}),
|
|
3176
2738
|
budget({
|
|
3177
2739
|
api: "catalog compile: the scope's enabled releases, all used",
|
|
3178
2740
|
read: ({ core }) => core.deploy([{
|
|
3179
2741
|
bundle: bundleOf("107"),
|
|
3180
2742
|
release: production
|
|
3181
2743
|
}]),
|
|
3182
|
-
adapter: (copies) => reads(copies ?
|
|
2744
|
+
adapter: (copies) => reads(copies ? 5 : 3, copies ? 8 : 3, 3, 6),
|
|
3183
2745
|
engine: {
|
|
3184
2746
|
calls: 6,
|
|
3185
2747
|
rows: 9
|
|
@@ -3213,7 +2775,7 @@ const READ_BUDGETS = [
|
|
|
3213
2775
|
install_id: "install-2",
|
|
3214
2776
|
received_at_ms: T0 + 3 * HOUR
|
|
3215
2777
|
})),
|
|
3216
|
-
adapter: reads(
|
|
2778
|
+
adapter: reads(4, 7, 0, 0),
|
|
3217
2779
|
engine: {
|
|
3218
2780
|
calls: 2,
|
|
3219
2781
|
rows: 1
|
|
@@ -3236,7 +2798,7 @@ const setupReadBudgetTestSuite = (options) => {
|
|
|
3236
2798
|
let writes = 0;
|
|
3237
2799
|
beforeAll(async () => {
|
|
3238
2800
|
created = await options.createAdapter({
|
|
3239
|
-
tables: options.server.
|
|
2801
|
+
tables: options.server.toolingTargetOf(options.server.plugins).schema.tables,
|
|
3240
2802
|
nativePageSize: NATIVE_PAGE_SIZE
|
|
3241
2803
|
});
|
|
3242
2804
|
const { adapter } = created;
|
|
@@ -3279,6 +2841,266 @@ const setupReadBudgetTestSuite = (options) => {
|
|
|
3279
2841
|
});
|
|
3280
2842
|
};
|
|
3281
2843
|
//#endregion
|
|
2844
|
+
//#region src/runContentionHarness.ts
|
|
2845
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2846
|
+
/** Delays every adapter call by `ms`, like a round trip to a remote database. */
|
|
2847
|
+
const withAdapterLatency = (adapter, ms) => ({
|
|
2848
|
+
...adapter,
|
|
2849
|
+
get: async (table, keys) => {
|
|
2850
|
+
await sleep(ms);
|
|
2851
|
+
return adapter.get(table, keys);
|
|
2852
|
+
},
|
|
2853
|
+
query: async (table, request) => {
|
|
2854
|
+
await sleep(ms);
|
|
2855
|
+
return adapter.query(table, request);
|
|
2856
|
+
},
|
|
2857
|
+
write: async (ops) => {
|
|
2858
|
+
await sleep(ms);
|
|
2859
|
+
return adapter.write(ops);
|
|
2860
|
+
}
|
|
2861
|
+
});
|
|
2862
|
+
/**
|
|
2863
|
+
* Starts `transactions` runs at a steady arrival rate through a fixed pool and
|
|
2864
|
+
* reports how they ended. Retry counts come from the engine's `onRetry`.
|
|
2865
|
+
*/
|
|
2866
|
+
const runContentionHarness = async (options) => {
|
|
2867
|
+
const started = Date.now();
|
|
2868
|
+
const errors = {};
|
|
2869
|
+
const waiting = [];
|
|
2870
|
+
let free = options.concurrency;
|
|
2871
|
+
let committed = 0;
|
|
2872
|
+
const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
|
|
2873
|
+
const release = () => {
|
|
2874
|
+
const next = waiting.shift();
|
|
2875
|
+
if (next) next();
|
|
2876
|
+
else free += 1;
|
|
2877
|
+
};
|
|
2878
|
+
await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
|
|
2879
|
+
await sleep(index * 1e3 / options.ratePerSecond);
|
|
2880
|
+
await acquire();
|
|
2881
|
+
try {
|
|
2882
|
+
await options.run(index);
|
|
2883
|
+
committed += 1;
|
|
2884
|
+
} catch (error) {
|
|
2885
|
+
const name = error instanceof Error ? error.name : "unknown";
|
|
2886
|
+
errors[name] = (errors[name] ?? 0) + 1;
|
|
2887
|
+
} finally {
|
|
2888
|
+
release();
|
|
2889
|
+
}
|
|
2890
|
+
}));
|
|
2891
|
+
return {
|
|
2892
|
+
transactions: options.transactions,
|
|
2893
|
+
committed,
|
|
2894
|
+
errors,
|
|
2895
|
+
durationMs: Date.now() - started
|
|
2896
|
+
};
|
|
2897
|
+
};
|
|
2898
|
+
//#endregion
|
|
2899
|
+
//#region src/setupAggregateBatchingTestSuite.ts
|
|
2900
|
+
const identity = (name) => ({ [name]: {
|
|
2901
|
+
type: "string",
|
|
2902
|
+
maxLength: 16
|
|
2903
|
+
} });
|
|
2904
|
+
/** A module shaped like `defineTable` and `defineAggregate` build them. */
|
|
2905
|
+
const module = {
|
|
2906
|
+
id: "batching",
|
|
2907
|
+
schema: {
|
|
2908
|
+
installs: {
|
|
2909
|
+
kind: "table",
|
|
2910
|
+
fields: {
|
|
2911
|
+
id: {
|
|
2912
|
+
type: "string",
|
|
2913
|
+
maxLength: 64
|
|
2914
|
+
},
|
|
2915
|
+
release: {
|
|
2916
|
+
type: "string",
|
|
2917
|
+
maxLength: 16
|
|
2918
|
+
}
|
|
2919
|
+
},
|
|
2920
|
+
key: ["id"],
|
|
2921
|
+
derived: {},
|
|
2922
|
+
indexes: {}
|
|
2923
|
+
},
|
|
2924
|
+
distribution: {
|
|
2925
|
+
kind: "aggregate",
|
|
2926
|
+
fields: identity("release"),
|
|
2927
|
+
key: ["release"],
|
|
2928
|
+
counters: [],
|
|
2929
|
+
gauges: ["installations"],
|
|
2930
|
+
distinct: [],
|
|
2931
|
+
shards: 8,
|
|
2932
|
+
batched: true,
|
|
2933
|
+
indexes: { byRelease: {
|
|
2934
|
+
eq: ["release"],
|
|
2935
|
+
sort: []
|
|
2936
|
+
} }
|
|
2937
|
+
},
|
|
2938
|
+
opens: {
|
|
2939
|
+
kind: "aggregate",
|
|
2940
|
+
fields: { day: { type: "integer" } },
|
|
2941
|
+
key: ["day"],
|
|
2942
|
+
counters: ["opens"],
|
|
2943
|
+
gauges: [],
|
|
2944
|
+
distinct: [],
|
|
2945
|
+
shards: 4,
|
|
2946
|
+
batched: true,
|
|
2947
|
+
indexes: { all: {
|
|
2948
|
+
eq: [],
|
|
2949
|
+
sort: ["day"]
|
|
2950
|
+
} }
|
|
2951
|
+
},
|
|
2952
|
+
users: {
|
|
2953
|
+
kind: "aggregate",
|
|
2954
|
+
fields: { day: { type: "integer" } },
|
|
2955
|
+
key: ["day"],
|
|
2956
|
+
counters: [],
|
|
2957
|
+
gauges: [],
|
|
2958
|
+
distinct: ["users"],
|
|
2959
|
+
shards: 4,
|
|
2960
|
+
batched: true,
|
|
2961
|
+
indexes: { all: {
|
|
2962
|
+
eq: [],
|
|
2963
|
+
sort: ["day"]
|
|
2964
|
+
} }
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
};
|
|
2968
|
+
const RELEASES = [
|
|
2969
|
+
"A",
|
|
2970
|
+
"B",
|
|
2971
|
+
"C"
|
|
2972
|
+
];
|
|
2973
|
+
/** Step `step`: 12 installations, each moving to the next release every round of 12. */
|
|
2974
|
+
const stepOf = (step) => ({
|
|
2975
|
+
id: `i${step % 12}`,
|
|
2976
|
+
release: RELEASES[(Math.floor(step / 12) + step) % 3],
|
|
2977
|
+
day: 1 + step % 2
|
|
2978
|
+
});
|
|
2979
|
+
/** An installation opens the app on `release`, moving its gauge when the release changed. */
|
|
2980
|
+
const open = (db, step) => {
|
|
2981
|
+
const { id, release, day } = stepOf(step);
|
|
2982
|
+
return db.transaction(async (tx) => {
|
|
2983
|
+
const head = await tx.findOne("installs", { id });
|
|
2984
|
+
if (head === null) tx.create("installs", {
|
|
2985
|
+
id,
|
|
2986
|
+
release
|
|
2987
|
+
});
|
|
2988
|
+
else if (head.release !== release) {
|
|
2989
|
+
tx.update("installs", head, { release });
|
|
2990
|
+
const from = { release: head.release };
|
|
2991
|
+
tx.aggregate("distribution", from, { installations: -1 }, { shardBy: id });
|
|
2992
|
+
}
|
|
2993
|
+
if (head?.release !== release) tx.aggregate("distribution", { release }, { installations: 1 }, { shardBy: id });
|
|
2994
|
+
tx.aggregate("opens", { day }, { opens: 1 }, { shardBy: id });
|
|
2995
|
+
const users = addDistinct(null, id);
|
|
2996
|
+
tx.aggregate("users", { day }, { users }, { shardBy: id });
|
|
2997
|
+
});
|
|
2998
|
+
};
|
|
2999
|
+
/** Every aggregate as reads see it, sketches as their estimates. */
|
|
3000
|
+
const snapshot = async (db) => {
|
|
3001
|
+
const read = (model, index, where = {}) => db.findAggregates(model, {
|
|
3002
|
+
index,
|
|
3003
|
+
where,
|
|
3004
|
+
limit: 100
|
|
3005
|
+
});
|
|
3006
|
+
const releases = await Promise.all(RELEASES.map((release) => read("distribution", "byRelease", { release })));
|
|
3007
|
+
const [opens, users] = await Promise.all([read("opens", "all"), read("users", "all")]);
|
|
3008
|
+
return {
|
|
3009
|
+
distribution: releases.flatMap(({ rows }) => rows),
|
|
3010
|
+
opens: opens.rows,
|
|
3011
|
+
users: users.rows.map((row) => ({
|
|
3012
|
+
day: row.day,
|
|
3013
|
+
users: countDistinct(row.users)
|
|
3014
|
+
}))
|
|
3015
|
+
};
|
|
3016
|
+
};
|
|
3017
|
+
/**
|
|
3018
|
+
* Aggregates declared `batched` on a key-value store: log rows the store
|
|
3019
|
+
* writes with each transaction and a compaction merges once, and a memory
|
|
3020
|
+
* buffer, read back exactly as transactional writes read.
|
|
3021
|
+
*/
|
|
3022
|
+
const setupAggregateBatchingTestSuite = ({ name, engine, createAdapter, oversizedRows }) => {
|
|
3023
|
+
const schema = engine.resolveSchema([module, engine.aggregateBatchingModule]);
|
|
3024
|
+
const cleanups = [];
|
|
3025
|
+
const store = async () => {
|
|
3026
|
+
const { adapter, cleanup } = await createAdapter();
|
|
3027
|
+
if (cleanup) cleanups.push(cleanup);
|
|
3028
|
+
await adapter.migrations?.apply(schema.tables);
|
|
3029
|
+
return adapter;
|
|
3030
|
+
};
|
|
3031
|
+
const server = (adapter, batching) => {
|
|
3032
|
+
const created = engine.createDatabaseEngine({
|
|
3033
|
+
adapter,
|
|
3034
|
+
schema,
|
|
3035
|
+
...batching === void 0 ? {} : { batching },
|
|
3036
|
+
retry: { attempts: 64 }
|
|
3037
|
+
});
|
|
3038
|
+
return {
|
|
3039
|
+
db: created.database(module),
|
|
3040
|
+
flush: created.flush
|
|
3041
|
+
};
|
|
3042
|
+
};
|
|
3043
|
+
/** The reference: transactional writes on a memory adapter. */
|
|
3044
|
+
const expected = async (steps) => {
|
|
3045
|
+
const memory = createMemoryAdapter();
|
|
3046
|
+
await memory.migrations?.apply(schema.tables);
|
|
3047
|
+
const { db } = server(memory);
|
|
3048
|
+
for (let step = 0; step < steps; step += 1) await open(db, step);
|
|
3049
|
+
return snapshot(db);
|
|
3050
|
+
};
|
|
3051
|
+
describe(`${name} aggregate batching`, () => {
|
|
3052
|
+
afterEach(async () => {
|
|
3053
|
+
for (const cleanup of cleanups.splice(0)) await cleanup();
|
|
3054
|
+
});
|
|
3055
|
+
for (const mode of ["log", "memory"]) it(`reads what transactional writes read, in ${mode} mode`, async () => {
|
|
3056
|
+
const { db } = server(await store(), { mode });
|
|
3057
|
+
for (let step = 0; step < 36; step += 1) await open(db, step);
|
|
3058
|
+
expect(await snapshot(db)).toEqual(await expected(36));
|
|
3059
|
+
}, 12e4);
|
|
3060
|
+
it("applies each log row once when two servers compact while 16 writers commit", async () => {
|
|
3061
|
+
const adapter = await store();
|
|
3062
|
+
const first = server(adapter, {
|
|
3063
|
+
mode: "log",
|
|
3064
|
+
windowMs: 0
|
|
3065
|
+
});
|
|
3066
|
+
const second = server(adapter, {
|
|
3067
|
+
mode: "log",
|
|
3068
|
+
windowMs: 0
|
|
3069
|
+
});
|
|
3070
|
+
const report = await runContentionHarness({
|
|
3071
|
+
transactions: 12,
|
|
3072
|
+
ratePerSecond: 1e3,
|
|
3073
|
+
concurrency: 16,
|
|
3074
|
+
run: async (install) => {
|
|
3075
|
+
for (let round = 0; round < 4; round += 1) {
|
|
3076
|
+
const db = (install + round) % 2 === 0 ? first.db : second.db;
|
|
3077
|
+
await open(db, round * 12 + install);
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
});
|
|
3081
|
+
expect(report.errors).toEqual({});
|
|
3082
|
+
await Promise.all([first.flush(), second.flush()]);
|
|
3083
|
+
await first.flush();
|
|
3084
|
+
expect(await snapshot(second.db)).toEqual(await expected(48));
|
|
3085
|
+
}, 3e5);
|
|
3086
|
+
it("applies a log row larger than one write in parts, each change once", async () => {
|
|
3087
|
+
const { db, flush } = server(await store(), { mode: "log" });
|
|
3088
|
+
await db.transaction(async (tx) => {
|
|
3089
|
+
for (let day = 1; day <= oversizedRows; day += 1) tx.aggregate("opens", { day }, { opens: day }, { shardBy: "i" });
|
|
3090
|
+
});
|
|
3091
|
+
await flush();
|
|
3092
|
+
const { rows } = await db.findAggregates("opens", {
|
|
3093
|
+
index: "all",
|
|
3094
|
+
where: {},
|
|
3095
|
+
limit: 500
|
|
3096
|
+
});
|
|
3097
|
+
const days = Math.min(oversizedRows, 500);
|
|
3098
|
+
expect(rows).toHaveLength(days);
|
|
3099
|
+
expect(rows.every((row) => row.opens === row.day)).toBe(true);
|
|
3100
|
+
}, 3e5);
|
|
3101
|
+
});
|
|
3102
|
+
};
|
|
3103
|
+
//#endregion
|
|
3282
3104
|
//#region src/sqlRowsExamined.ts
|
|
3283
3105
|
/** Wraps executors so each read they run while counting is examined first. */
|
|
3284
3106
|
const examineReads = (hooks) => {
|
|
@@ -3400,63 +3222,7 @@ const mysqlRowsExamined = (query) => {
|
|
|
3400
3222
|
});
|
|
3401
3223
|
};
|
|
3402
3224
|
//#endregion
|
|
3403
|
-
//#region src/runContentionHarness.ts
|
|
3404
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3405
|
-
/** Delays every adapter call by `ms`, like a round trip to a remote database. */
|
|
3406
|
-
const withAdapterLatency = (adapter, ms) => ({
|
|
3407
|
-
...adapter,
|
|
3408
|
-
get: async (table, keys) => {
|
|
3409
|
-
await sleep(ms);
|
|
3410
|
-
return adapter.get(table, keys);
|
|
3411
|
-
},
|
|
3412
|
-
query: async (table, request) => {
|
|
3413
|
-
await sleep(ms);
|
|
3414
|
-
return adapter.query(table, request);
|
|
3415
|
-
},
|
|
3416
|
-
write: async (ops) => {
|
|
3417
|
-
await sleep(ms);
|
|
3418
|
-
return adapter.write(ops);
|
|
3419
|
-
}
|
|
3420
|
-
});
|
|
3421
|
-
/**
|
|
3422
|
-
* Starts `transactions` runs at a steady arrival rate through a fixed pool and
|
|
3423
|
-
* reports how they ended. Retry counts come from the engine's `onRetry`.
|
|
3424
|
-
*/
|
|
3425
|
-
const runContentionHarness = async (options) => {
|
|
3426
|
-
const started = Date.now();
|
|
3427
|
-
const errors = {};
|
|
3428
|
-
const waiting = [];
|
|
3429
|
-
let free = options.concurrency;
|
|
3430
|
-
let committed = 0;
|
|
3431
|
-
const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
|
|
3432
|
-
const release = () => {
|
|
3433
|
-
const next = waiting.shift();
|
|
3434
|
-
if (next) next();
|
|
3435
|
-
else free += 1;
|
|
3436
|
-
};
|
|
3437
|
-
await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
|
|
3438
|
-
await sleep(index * 1e3 / options.ratePerSecond);
|
|
3439
|
-
await acquire();
|
|
3440
|
-
try {
|
|
3441
|
-
await options.run(index);
|
|
3442
|
-
committed += 1;
|
|
3443
|
-
} catch (error) {
|
|
3444
|
-
const name = error instanceof Error ? error.name : "unknown";
|
|
3445
|
-
errors[name] = (errors[name] ?? 0) + 1;
|
|
3446
|
-
} finally {
|
|
3447
|
-
release();
|
|
3448
|
-
}
|
|
3449
|
-
}));
|
|
3450
|
-
return {
|
|
3451
|
-
transactions: options.transactions,
|
|
3452
|
-
committed,
|
|
3453
|
-
errors,
|
|
3454
|
-
durationMs: Date.now() - started
|
|
3455
|
-
};
|
|
3456
|
-
};
|
|
3457
|
-
//#endregion
|
|
3458
3225
|
//#region src/createPluginTestHarness.ts
|
|
3459
|
-
const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
|
|
3460
3226
|
/**
|
|
3461
3227
|
* Runs one plugin the way `createHotUpdater` does, on a memory adapter in
|
|
3462
3228
|
* verify mode, so tests can call its API and measure what each call reads.
|
|
@@ -3466,7 +3232,7 @@ const createPluginTestHarness = async (plugin, options) => {
|
|
|
3466
3232
|
const module = {
|
|
3467
3233
|
id: plugin.id,
|
|
3468
3234
|
schema: plugin.schema,
|
|
3469
|
-
...
|
|
3235
|
+
...plugin.namespace === false ? {} : { namespace: plugin.id }
|
|
3470
3236
|
};
|
|
3471
3237
|
const schema = options.engine.resolveSchema([module]);
|
|
3472
3238
|
await adapter.migrations?.apply(schema.tables);
|
|
@@ -3566,4 +3332,4 @@ async function startHttpTestServer(handlers) {
|
|
|
3566
3332
|
};
|
|
3567
3333
|
}
|
|
3568
3334
|
//#endregion
|
|
3569
|
-
export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceItems, createAdminApiTestClient, createHandlerHttpTestClient, createHttpTestClient, createPluginTestHarness, createReleaseCatalogTestStorage, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite,
|
|
3335
|
+
export { RELEASE_CATALOG_DOWNLOAD_HASH, RELEASE_CATALOG_MANIFEST_URIS, RELEASE_CATALOG_STORAGE_FIXTURES, conformanceCounters, conformanceExpiring, conformanceItems, createAdminApiTestClient, createHandlerHttpTestClient, createHttpTestClient, createPluginTestHarness, createReleaseCatalogTestStorage, jsonRequest, mysqlRowsExamined, postgresRowsExamined, releaseCatalogArtifact, releaseCatalogDownloadUrl, runContentionHarness, setupAggregateBatchingTestSuite, setupBundleMethodsTestSuite, setupCoreAdminTestSuite, setupDatabaseAdapterConformanceSuite, setupDatabaseTestSuite, setupReadBudgetTestSuite, setupReleaseCatalogTestSuite, setupSemverSatisfiesTestSuite, startHttpTestServer, toDeployBundle, withAdapterLatency };
|