@hot-updater/test-utils 1.0.0-rc.16 → 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 +14 -6
- package/dist/index.d.mts +96 -49
- package/dist/index.mjs +542 -825
- 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,729 +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("counts a latest installation once when a from and a to predicate both match it", async () => {
|
|
884
|
-
const model = state.getDatabase();
|
|
885
|
-
const moved = createBundleEventRowFixture("9701", 100);
|
|
886
|
-
await record(model, moved);
|
|
887
|
-
await expectInsightsIndex(() => model.countLatestEvents({
|
|
888
|
-
platform: moved.platform,
|
|
889
|
-
channel: moved.channel,
|
|
890
|
-
sinceMs: 0,
|
|
891
|
-
bundle: [{
|
|
892
|
-
field: "from_bundle_id",
|
|
893
|
-
value: moved.from_bundle_id,
|
|
894
|
-
types: [moved.type]
|
|
895
|
-
}, {
|
|
896
|
-
field: "to_bundle_id",
|
|
897
|
-
value: moved.to_bundle_id,
|
|
898
|
-
types: [moved.type]
|
|
899
|
-
}]
|
|
900
|
-
}), 1);
|
|
901
|
-
});
|
|
902
|
-
it("returns zero for successful empty scalar queries", async () => {
|
|
903
|
-
const model = state.getDatabase();
|
|
904
|
-
await expect(model.countLatestEvents({
|
|
905
|
-
platform: "ios",
|
|
906
|
-
channel: "production",
|
|
907
|
-
sinceMs: 0
|
|
908
|
-
})).resolves.toBe(0);
|
|
909
|
-
await expect(model.countEvents({
|
|
910
|
-
filter: {
|
|
911
|
-
platform: "ios",
|
|
912
|
-
channel: "production",
|
|
913
|
-
type: "RECOVERED",
|
|
914
|
-
fromBundleId: createBundleEventRowFixture("1", 0).to_bundle_id
|
|
915
|
-
},
|
|
916
|
-
sinceMs: 0,
|
|
917
|
-
beforeReceivedAtMs: 100
|
|
918
|
-
})).resolves.toBe(0);
|
|
919
|
-
});
|
|
920
|
-
it("maintains idempotent release, scope, usage, and latest-distribution summaries", async () => {
|
|
921
|
-
const model = state.getDatabase();
|
|
922
|
-
const releaseA = "00000000-0000-7000-8000-000000008001";
|
|
923
|
-
const releaseB = "00000000-0000-7000-8000-000000008002";
|
|
924
|
-
const download = {
|
|
925
|
-
...createBundleEventRowFixture("9801", 1e3),
|
|
926
|
-
type: "UPDATE_DOWNLOADED",
|
|
927
|
-
install_id: "summary-install-a",
|
|
928
|
-
from_release_id: releaseA,
|
|
929
|
-
to_release_id: releaseB
|
|
930
|
-
};
|
|
931
|
-
const appliedA = {
|
|
932
|
-
...download,
|
|
933
|
-
id: createBundleEventRowFixture("9802", 2e3).id,
|
|
934
|
-
type: "UPDATE_APPLIED",
|
|
935
|
-
received_at_ms: 2e3
|
|
936
|
-
};
|
|
937
|
-
const appliedB = {
|
|
938
|
-
...appliedA,
|
|
939
|
-
id: createBundleEventRowFixture("9803", 2100).id,
|
|
940
|
-
install_id: "summary-install-b",
|
|
941
|
-
received_at_ms: 2100
|
|
942
|
-
};
|
|
943
|
-
const recovered = {
|
|
944
|
-
...appliedA,
|
|
945
|
-
id: createBundleEventRowFixture("9804", 3e3).id,
|
|
946
|
-
type: "RECOVERED",
|
|
947
|
-
from_release_id: releaseB,
|
|
948
|
-
to_release_id: releaseA,
|
|
949
|
-
received_at_ms: 3e3
|
|
950
|
-
};
|
|
951
|
-
for (const event of [
|
|
952
|
-
download,
|
|
953
|
-
appliedA,
|
|
954
|
-
appliedB,
|
|
955
|
-
appliedB,
|
|
956
|
-
recovered
|
|
957
|
-
]) await record(model, event);
|
|
958
|
-
const releases = [{
|
|
959
|
-
releaseId: releaseA,
|
|
960
|
-
platform: "ios",
|
|
961
|
-
channel: "production"
|
|
962
|
-
}, {
|
|
963
|
-
releaseId: releaseB,
|
|
964
|
-
platform: "ios",
|
|
965
|
-
channel: "production"
|
|
966
|
-
}];
|
|
967
|
-
const lifetime = await model.getReleaseActivity({ releases });
|
|
968
|
-
expect(lifetime.data.map(({ metrics }) => metrics)).toEqual([{
|
|
969
|
-
downloads: 0,
|
|
970
|
-
launches: 1,
|
|
971
|
-
failedLaunches: 0
|
|
972
|
-
}, {
|
|
973
|
-
downloads: 1,
|
|
974
|
-
launches: 2,
|
|
975
|
-
failedLaunches: 1
|
|
976
|
-
}]);
|
|
977
|
-
const scope = await model.getReleaseActivity({
|
|
978
|
-
scope: {
|
|
979
|
-
platform: "ios",
|
|
980
|
-
channel: "production"
|
|
981
|
-
},
|
|
982
|
-
timeRange: {
|
|
983
|
-
start: 0,
|
|
984
|
-
end: 36e5
|
|
985
|
-
}
|
|
986
|
-
});
|
|
987
|
-
expect(scope.data[0]?.metrics).toMatchObject({
|
|
988
|
-
downloads: 1,
|
|
989
|
-
launches: 3,
|
|
990
|
-
failedLaunches: 1,
|
|
991
|
-
uniqueUsers: 2
|
|
992
|
-
});
|
|
993
|
-
const usage = await model.getAppUsage({
|
|
994
|
-
channel: "production",
|
|
995
|
-
platform: "all",
|
|
996
|
-
timeRange: {
|
|
997
|
-
start: 0,
|
|
998
|
-
end: 36e5
|
|
999
|
-
},
|
|
1000
|
-
intervalMs: 36e5
|
|
1001
|
-
});
|
|
1002
|
-
expect(usage.activeInstallations).toBe(2);
|
|
1003
|
-
expect(usage.points).toEqual([{
|
|
1004
|
-
startMs: 0,
|
|
1005
|
-
installations: 2
|
|
1006
|
-
}]);
|
|
1007
|
-
expect(usage.bundleDistribution).toEqual(expect.arrayContaining([{
|
|
1008
|
-
appVersion: "1.0.0",
|
|
1009
|
-
platform: "ios",
|
|
1010
|
-
releaseId: releaseA,
|
|
1011
|
-
installations: 1
|
|
1012
|
-
}, {
|
|
1013
|
-
appVersion: "1.0.0",
|
|
1014
|
-
platform: "ios",
|
|
1015
|
-
releaseId: releaseB,
|
|
1016
|
-
installations: 1
|
|
1017
|
-
}]));
|
|
1018
|
-
});
|
|
1019
|
-
it("pages insights events newest first with a stable cursor", async () => {
|
|
1020
|
-
const model = state.getDatabase();
|
|
1021
|
-
const first = createBundleEventRowFixture("701", 100);
|
|
1022
|
-
const second = createBundleEventRowFixture("702", 100);
|
|
1023
|
-
const third = createBundleEventRowFixture("703", 200);
|
|
1024
|
-
await model.recordEvent({ event: third });
|
|
1025
|
-
await model.recordEvent({ event: second });
|
|
1026
|
-
await model.recordEvent({ event: first });
|
|
1027
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1028
|
-
filter: { kind: "all" },
|
|
1029
|
-
beforeReceivedAtMs: 201,
|
|
1030
|
-
limit: 2
|
|
1031
|
-
}), [third, second]);
|
|
1032
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1033
|
-
filter: { kind: "all" },
|
|
1034
|
-
after: {
|
|
1035
|
-
receivedAtMs: second.received_at_ms,
|
|
1036
|
-
id: second.id
|
|
1037
|
-
},
|
|
1038
|
-
beforeReceivedAtMs: 201,
|
|
1039
|
-
limit: 2
|
|
1040
|
-
}), [first]);
|
|
1041
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1042
|
-
filter: { kind: "all" },
|
|
1043
|
-
beforeReceivedAtMs: 200,
|
|
1044
|
-
limit: 10
|
|
1045
|
-
}), [second, first]);
|
|
1046
|
-
});
|
|
1047
|
-
it("filters installation movements before applying the page limit", async () => {
|
|
1048
|
-
const model = state.getDatabase();
|
|
1049
|
-
const unchanged = {
|
|
1050
|
-
...createBundleEventRowFixture("711", 300),
|
|
1051
|
-
type: "UNCHANGED",
|
|
1052
|
-
install_id: "install-target",
|
|
1053
|
-
from_bundle_id: null,
|
|
1054
|
-
metadata: {
|
|
1055
|
-
...createBundleEventRowFixture("711", 300).metadata,
|
|
1056
|
-
update_strategy: null
|
|
1057
|
-
}
|
|
1058
|
-
};
|
|
1059
|
-
const unrelated = createMovementEvent("712", 250, "UPDATE_APPLIED", "install-other");
|
|
1060
|
-
const applied = createMovementEvent("713", 200, "UPDATE_APPLIED", "install-target");
|
|
1061
|
-
const recovered = createMovementEvent("714", 100, "RECOVERED", "install-target");
|
|
1062
|
-
for (const row of [
|
|
1063
|
-
unchanged,
|
|
1064
|
-
unrelated,
|
|
1065
|
-
applied,
|
|
1066
|
-
recovered
|
|
1067
|
-
]) await model.recordEvent({ event: row });
|
|
1068
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1069
|
-
filter: {
|
|
1070
|
-
kind: "installationMovement",
|
|
1071
|
-
installId: "install-target"
|
|
1072
|
-
},
|
|
1073
|
-
beforeReceivedAtMs: 301,
|
|
1074
|
-
limit: 1
|
|
1075
|
-
}), [applied]);
|
|
1076
|
-
await expectInsightsIndex(() => model.listEvents({
|
|
1077
|
-
filter: {
|
|
1078
|
-
kind: "installationMovement",
|
|
1079
|
-
installId: "install-target"
|
|
1080
|
-
},
|
|
1081
|
-
after: {
|
|
1082
|
-
receivedAtMs: applied.received_at_ms,
|
|
1083
|
-
id: applied.id
|
|
1084
|
-
},
|
|
1085
|
-
beforeReceivedAtMs: 301,
|
|
1086
|
-
limit: 1
|
|
1087
|
-
}), [recovered]);
|
|
1088
|
-
});
|
|
1089
|
-
});
|
|
1090
|
-
};
|
|
1091
|
-
//#endregion
|
|
1092
|
-
//#region src/setupInsightsModelTestSuite.ts
|
|
1093
|
-
/**
|
|
1094
|
-
* The Insights report contract on one database: pass the Insights plugin's
|
|
1095
|
-
* model over it, `createInsightsModel(createDatabasePluginApis(database,
|
|
1096
|
-
* [insights()]).insights)`, as `createDatabase`.
|
|
1097
|
-
*/
|
|
1098
|
-
const setupInsightsModelTestSuite = (lifecycle) => setupDatabaseTestRunner(lifecycle, registerInsightsModelTests);
|
|
1099
|
-
//#endregion
|
|
1100
356
|
//#region src/setupSemverSatisfiesTestSuite.ts
|
|
1101
357
|
/**
|
|
1102
358
|
*
|
|
@@ -1307,6 +563,35 @@ const setupSemverSatisfiesTestSuite = ({ semverSatisfies }) => {
|
|
|
1307
563
|
});
|
|
1308
564
|
};
|
|
1309
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
|
|
1310
595
|
//#region src/releaseCatalogHttpFixtures.ts
|
|
1311
596
|
const releaseCatalogDownloadUrl = (uri) => `https://storage.example.com/${encodeURIComponent(uri)}`;
|
|
1312
597
|
const targetId = createBundleRowFixture("302").id;
|
|
@@ -1701,10 +986,11 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
1701
986
|
const otherPath = strategy === "APP_VERSION" ? `/release-catalogs/fingerprint/ios/${scope().channelKey}/fingerprint-a` : `/release-catalogs/app-version/ios/${scope().channelKey}/1.2.3`;
|
|
1702
987
|
expect((await request(otherPath)).status).toBe(404);
|
|
1703
988
|
});
|
|
1704
|
-
it("
|
|
989
|
+
it("caches an unknown scope's 404 like a catalog", async () => {
|
|
1705
990
|
const response = await request(catalogPath());
|
|
1706
991
|
expect(response.status).toBe(404);
|
|
1707
|
-
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");
|
|
1708
994
|
await publish("121");
|
|
1709
995
|
expect((await readCatalog()).releases).toHaveLength(1);
|
|
1710
996
|
});
|
|
@@ -2016,9 +1302,8 @@ const setupReleaseCatalogTestSuite = (options) => {
|
|
|
2016
1302
|
//#region src/setupDatabaseTestSuite.ts
|
|
2017
1303
|
/**
|
|
2018
1304
|
* A provider's database under Hot Updater's server: core's operations, the
|
|
2019
|
-
* Release Catalog contract,
|
|
2020
|
-
*
|
|
2021
|
-
* 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.
|
|
2022
1307
|
*/
|
|
2023
1308
|
const setupDatabaseTestSuite = (options) => {
|
|
2024
1309
|
setupDatabaseTestRunner(options, ({ getDatabase }) => {
|
|
@@ -2040,10 +1325,11 @@ const setupDatabaseTestSuite = (options) => {
|
|
|
2040
1325
|
setupCoreAdminTestSuite({ getClient });
|
|
2041
1326
|
setupBundleMethodsTestSuite({ getClient });
|
|
2042
1327
|
setupReleaseCatalogTestSuite({ getClient });
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
1328
|
+
for (const suite of options.plugins ?? []) describe(suite.name, () => {
|
|
1329
|
+
suite.register({
|
|
1330
|
+
getClient,
|
|
1331
|
+
getDatabase
|
|
1332
|
+
});
|
|
2047
1333
|
});
|
|
2048
1334
|
});
|
|
2049
1335
|
};
|
|
@@ -2170,7 +1456,71 @@ const conformanceCounters = {
|
|
|
2170
1456
|
sort: ["shard"]
|
|
2171
1457
|
}]
|
|
2172
1458
|
};
|
|
2173
|
-
|
|
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
|
+
});
|
|
2174
1524
|
const item = (id, values = {}) => ({
|
|
2175
1525
|
id,
|
|
2176
1526
|
grp: "g",
|
|
@@ -2747,6 +2097,79 @@ const setupDatabaseAdapterConformanceSuite = (options) => {
|
|
|
2747
2097
|
const results = await Promise.all([adapter.write(skew(a, b)), adapter.write(skew(b, a))]);
|
|
2748
2098
|
expect(results.filter((result) => result.ok).length).toBeLessThanOrEqual(1);
|
|
2749
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
|
+
});
|
|
2750
2173
|
it.skipIf(options.maxOps === void 0)("rejects an over-limit write before sending it", async () => {
|
|
2751
2174
|
const adapter = await setup();
|
|
2752
2175
|
const ops = Array.from({ length: (options.maxOps ?? 0) + 1 }, (_, index) => insert(item(`over${index}`)));
|
|
@@ -2792,7 +2215,6 @@ const eventOf = (n, overrides = {}) => ({
|
|
|
2792
2215
|
app_version: "1.0.0",
|
|
2793
2216
|
channel: "production",
|
|
2794
2217
|
metadata: {
|
|
2795
|
-
username: null,
|
|
2796
2218
|
cohort: "1",
|
|
2797
2219
|
update_strategy: "appVersion",
|
|
2798
2220
|
fingerprint_hash: null,
|
|
@@ -2802,6 +2224,31 @@ const eventOf = (n, overrides = {}) => ({
|
|
|
2802
2224
|
...overrides
|
|
2803
2225
|
});
|
|
2804
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
|
+
/**
|
|
2805
2252
|
* History event `n` of the day before T0, three an hour, on its own release
|
|
2806
2253
|
* and bundles: every windowed read, which starts at T0 or later, skips it.
|
|
2807
2254
|
*/
|
|
@@ -2894,6 +2341,9 @@ const seed = async (database, server) => {
|
|
|
2894
2341
|
received_at_ms: T0 + 2 * DAY
|
|
2895
2342
|
}));
|
|
2896
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));
|
|
2897
2347
|
const channel = (name) => core.findChannelByName(name).then((found) => found);
|
|
2898
2348
|
const productionId = (await channel("production")).id;
|
|
2899
2349
|
return {
|
|
@@ -3140,14 +2590,14 @@ const READ_BUDGETS = [
|
|
|
3140
2590
|
read: ({ insights }) => insights.countLatestEvents({
|
|
3141
2591
|
platform: "ios",
|
|
3142
2592
|
channel: "production",
|
|
3143
|
-
sinceMs: T0
|
|
2593
|
+
sinceMs: T0
|
|
3144
2594
|
}),
|
|
3145
|
-
adapter: reads(0, 0, 1,
|
|
2595
|
+
adapter: reads(0, 0, 1, 15),
|
|
3146
2596
|
engine: {
|
|
3147
2597
|
calls: 1,
|
|
3148
|
-
rows:
|
|
2598
|
+
rows: 2
|
|
3149
2599
|
},
|
|
3150
|
-
check: (count) => expect(count).toBe(
|
|
2600
|
+
check: (count) => expect(count).toBe(24)
|
|
3151
2601
|
}),
|
|
3152
2602
|
budget({
|
|
3153
2603
|
api: "countLatestEvents by bundle: buckets in the window × shards, all used",
|
|
@@ -3161,10 +2611,10 @@ const READ_BUDGETS = [
|
|
|
3161
2611
|
types: ["UPDATE_APPLIED"]
|
|
3162
2612
|
}]
|
|
3163
2613
|
}),
|
|
3164
|
-
adapter: reads(0, 0, 1,
|
|
2614
|
+
adapter: reads(0, 0, 1, 15),
|
|
3165
2615
|
engine: {
|
|
3166
2616
|
calls: 1,
|
|
3167
|
-
rows:
|
|
2617
|
+
rows: 2
|
|
3168
2618
|
},
|
|
3169
2619
|
check: (count) => expect(count).toBe(24)
|
|
3170
2620
|
}),
|
|
@@ -3212,16 +2662,79 @@ const READ_BUDGETS = [
|
|
|
3212
2662
|
},
|
|
3213
2663
|
intervalMs: DAY
|
|
3214
2664
|
}),
|
|
3215
|
-
adapter: reads(0, 0,
|
|
2665
|
+
adapter: reads(0, 0, 4, 30),
|
|
3216
2666
|
engine: {
|
|
3217
|
-
calls:
|
|
3218
|
-
rows:
|
|
2667
|
+
calls: 4,
|
|
2668
|
+
rows: 4
|
|
3219
2669
|
},
|
|
3220
2670
|
check: ({ versions }) => expect(versions).toEqual([{
|
|
3221
2671
|
name: "1.0.0",
|
|
3222
2672
|
installations: 24
|
|
3223
2673
|
}])
|
|
3224
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
|
+
}),
|
|
3225
2738
|
budget({
|
|
3226
2739
|
api: "catalog compile: the scope's enabled releases, all used",
|
|
3227
2740
|
read: ({ core }) => core.deploy([{
|
|
@@ -3262,7 +2775,7 @@ const READ_BUDGETS = [
|
|
|
3262
2775
|
install_id: "install-2",
|
|
3263
2776
|
received_at_ms: T0 + 3 * HOUR
|
|
3264
2777
|
})),
|
|
3265
|
-
adapter: reads(
|
|
2778
|
+
adapter: reads(4, 7, 0, 0),
|
|
3266
2779
|
engine: {
|
|
3267
2780
|
calls: 2,
|
|
3268
2781
|
rows: 1
|
|
@@ -3285,7 +2798,7 @@ const setupReadBudgetTestSuite = (options) => {
|
|
|
3285
2798
|
let writes = 0;
|
|
3286
2799
|
beforeAll(async () => {
|
|
3287
2800
|
created = await options.createAdapter({
|
|
3288
|
-
tables: options.server.
|
|
2801
|
+
tables: options.server.toolingTargetOf(options.server.plugins).schema.tables,
|
|
3289
2802
|
nativePageSize: NATIVE_PAGE_SIZE
|
|
3290
2803
|
});
|
|
3291
2804
|
const { adapter } = created;
|
|
@@ -3328,6 +2841,266 @@ const setupReadBudgetTestSuite = (options) => {
|
|
|
3328
2841
|
});
|
|
3329
2842
|
};
|
|
3330
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
|
|
3331
3104
|
//#region src/sqlRowsExamined.ts
|
|
3332
3105
|
/** Wraps executors so each read they run while counting is examined first. */
|
|
3333
3106
|
const examineReads = (hooks) => {
|
|
@@ -3449,63 +3222,7 @@ const mysqlRowsExamined = (query) => {
|
|
|
3449
3222
|
});
|
|
3450
3223
|
};
|
|
3451
3224
|
//#endregion
|
|
3452
|
-
//#region src/runContentionHarness.ts
|
|
3453
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
3454
|
-
/** Delays every adapter call by `ms`, like a round trip to a remote database. */
|
|
3455
|
-
const withAdapterLatency = (adapter, ms) => ({
|
|
3456
|
-
...adapter,
|
|
3457
|
-
get: async (table, keys) => {
|
|
3458
|
-
await sleep(ms);
|
|
3459
|
-
return adapter.get(table, keys);
|
|
3460
|
-
},
|
|
3461
|
-
query: async (table, request) => {
|
|
3462
|
-
await sleep(ms);
|
|
3463
|
-
return adapter.query(table, request);
|
|
3464
|
-
},
|
|
3465
|
-
write: async (ops) => {
|
|
3466
|
-
await sleep(ms);
|
|
3467
|
-
return adapter.write(ops);
|
|
3468
|
-
}
|
|
3469
|
-
});
|
|
3470
|
-
/**
|
|
3471
|
-
* Starts `transactions` runs at a steady arrival rate through a fixed pool and
|
|
3472
|
-
* reports how they ended. Retry counts come from the engine's `onRetry`.
|
|
3473
|
-
*/
|
|
3474
|
-
const runContentionHarness = async (options) => {
|
|
3475
|
-
const started = Date.now();
|
|
3476
|
-
const errors = {};
|
|
3477
|
-
const waiting = [];
|
|
3478
|
-
let free = options.concurrency;
|
|
3479
|
-
let committed = 0;
|
|
3480
|
-
const acquire = () => free > 0 ? (free -= 1, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve));
|
|
3481
|
-
const release = () => {
|
|
3482
|
-
const next = waiting.shift();
|
|
3483
|
-
if (next) next();
|
|
3484
|
-
else free += 1;
|
|
3485
|
-
};
|
|
3486
|
-
await Promise.all(Array.from({ length: options.transactions }, async (_, index) => {
|
|
3487
|
-
await sleep(index * 1e3 / options.ratePerSecond);
|
|
3488
|
-
await acquire();
|
|
3489
|
-
try {
|
|
3490
|
-
await options.run(index);
|
|
3491
|
-
committed += 1;
|
|
3492
|
-
} catch (error) {
|
|
3493
|
-
const name = error instanceof Error ? error.name : "unknown";
|
|
3494
|
-
errors[name] = (errors[name] ?? 0) + 1;
|
|
3495
|
-
} finally {
|
|
3496
|
-
release();
|
|
3497
|
-
}
|
|
3498
|
-
}));
|
|
3499
|
-
return {
|
|
3500
|
-
transactions: options.transactions,
|
|
3501
|
-
committed,
|
|
3502
|
-
errors,
|
|
3503
|
-
durationMs: Date.now() - started
|
|
3504
|
-
};
|
|
3505
|
-
};
|
|
3506
|
-
//#endregion
|
|
3507
3225
|
//#region src/createPluginTestHarness.ts
|
|
3508
|
-
const builtInPlugin = Symbol.for("@hot-updater/server/built-in-plugin");
|
|
3509
3226
|
/**
|
|
3510
3227
|
* Runs one plugin the way `createHotUpdater` does, on a memory adapter in
|
|
3511
3228
|
* verify mode, so tests can call its API and measure what each call reads.
|
|
@@ -3515,7 +3232,7 @@ const createPluginTestHarness = async (plugin, options) => {
|
|
|
3515
3232
|
const module = {
|
|
3516
3233
|
id: plugin.id,
|
|
3517
3234
|
schema: plugin.schema,
|
|
3518
|
-
...
|
|
3235
|
+
...plugin.namespace === false ? {} : { namespace: plugin.id }
|
|
3519
3236
|
};
|
|
3520
3237
|
const schema = options.engine.resolveSchema([module]);
|
|
3521
3238
|
await adapter.migrations?.apply(schema.tables);
|
|
@@ -3615,4 +3332,4 @@ async function startHttpTestServer(handlers) {
|
|
|
3615
3332
|
};
|
|
3616
3333
|
}
|
|
3617
3334
|
//#endregion
|
|
3618
|
-
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 };
|