From 7c2f713dba3ede4ec0b3b62224ec2a09df472f27 Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Fri, 28 Aug 2026 18:34:47 +0100 Subject: [PATCH 1/2] test: count visitors against a firing summary schedule Yulin 1.20.14 carries the shims KensioSoftware/yulin#1082 asked for. `sim-athena-binary-shims` registers Trino's hashing functions and the binary encodings that feed them, and `sim-athena-count-distinct` covers `count(DISTINCT )`. Between them the shipped visitor count runs under the simulated Athena engine, and the deployment in src/cdk/rollup-summaries.test.ts drops `viewsOnly` for `pageviews` as Rainlytics ships it. Every delivered record now carries an address of its own. One new case fires the hourly schedule over three views from two addresses and reads a summary back saying three views and two visitors. Another points the deployment at a parameter nobody created and finds the run failed naming it, with no query execution behind it. --- package.json | 2 +- pnpm-lock.yaml | 10 ++--- src/cdk/rollup-summaries.test.ts | 69 ++++++++++++++++++++++++-------- 3 files changed, 59 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 5eab19e..dbc82ab 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "@aws-sdk/client-ssm": "^3.1119.0", "@faker-js/faker": "^10.6.0", "@kensio/smartass": "^1.37.5", - "@kensio/yulin": "^1.20.12", + "@kensio/yulin": "^1.20.14", "@semantic-release/exec": "7.1.0", "@types/node": "^26.1.1", "@typescript/native": "npm:typescript@^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c112c93..84c8508 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^1.37.5 version: 1.37.5 '@kensio/yulin': - specifier: ^1.20.12 - version: 1.20.13(node-sql-parser@5.4.0) + specifier: ^1.20.14 + version: 1.20.14(node-sql-parser@5.4.0) '@semantic-release/exec': specifier: 7.1.0 version: 7.1.0(semantic-release@25.0.9(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(supports-color@7.2.0) @@ -231,8 +231,8 @@ packages: resolution: {integrity: sha512-Djp3HOC3s3U3axh/Phb31vJ1QCYls5DasmPAJZ466ArFE2aed5a1zQCHVLh3sCP7BjER/YRqBXqlRIwVpG+wkw==} engines: {node: '>=24.0.0'} - '@kensio/yulin@1.20.13': - resolution: {integrity: sha512-nIz+14XrtXFV/r1WZUZm/qVq1ewvL0FOplKzNy1xLsQcG9hhjkT9q8F2mjkP2m5cIa04APDZkGOrVfTsLjSGtA==} + '@kensio/yulin@1.20.14': + resolution: {integrity: sha512-vao/M2vY9BsMbzZ7tVh1B1mfr/Na1LbDzKzqF7svw7G1y4FWqHpRUAWqNKhfZmMOXZPu88Tvo3e8jOBIspgjOA==} engines: {node: '>=24.0.0'} hasBin: true peerDependencies: @@ -2621,7 +2621,7 @@ snapshots: '@kensio/smartass@1.37.5': {} - '@kensio/yulin@1.20.13(node-sql-parser@5.4.0)': + '@kensio/yulin@1.20.14(node-sql-parser@5.4.0)': dependencies: '@faker-js/faker': 10.6.0 '@kensio/part-factory': 1.10.1 diff --git a/src/cdk/rollup-summaries.test.ts b/src/cdk/rollup-summaries.test.ts index cad5ad4..a82b77a 100644 --- a/src/cdk/rollup-summaries.test.ts +++ b/src/cdk/rollup-summaries.test.ts @@ -23,7 +23,6 @@ import { partitionPrefix } from "../partitions.js"; import { pageviews } from "../rollup-questions.js"; import type { RollupSummary } from "../rollup-summaries.js"; import { summarySchemaVersion } from "../rollup-summaries.js"; -import type { Rollup } from "../rollups.js"; import { defaultRedirectStatuses, windowPlaceholder } from "../rollups.js"; import { defaultVisitorSaltParameter, @@ -45,18 +44,6 @@ describe("computing rollup summaries on a schedule", () => { */ const theClosedHour = new Date("2026-08-23T08:00:00.000Z"); - /** - * The pageviews question with its visitor count turned off. - * - * Every case here is about windows, keys, buckets and lag, and a visitor - * count in the middle of them would be a second query nothing can answer. - * Yulin's Athena engine has no `sha256`, `to_utf8` or `to_hex`, so the - * shipped count comes back empty under a SUCCEEDED state and the run - * refuses it. KensioSoftware/yulin#1082 is that gap, and the two cases - * below cover the wiring that reaches it. - */ - const viewsOnly: Rollup = { ...pageviews, countsVisitors: false }; - /** A whole deployment in a simulated account, computing one question. */ const deployAnalytics = async ( over: Partial = {}, @@ -93,7 +80,7 @@ describe("computing rollup summaries on a schedule", () => { new RollupSummaries(stack, "RainlyticsSummaries", { table, workgroup, - rollups: [viewsOnly], + rollups: [pageviews], granularities: ["hourly"], summariesBucketName, removalPolicy: RemovalPolicy.DESTROY, @@ -132,7 +119,13 @@ describe("computing rollup summaries on a schedule", () => { type Deployed = Awaited>; - /** One record, with everything a rollup reads set to something sensible. */ + /** + * One record, with everything a rollup reads set to something sensible. + * + * Every record gets an address of its own. A case that says nothing about + * visitors then counts one per record, and a case that cares who came back + * hands `c-ip` in. + */ const aRecord = ( at: Date, over: Readonly> = {}, @@ -148,6 +141,7 @@ describe("computing rollup summaries on a schedule", () => { "cs(User-Agent)": "Mozilla/5.0%20(Macintosh)", "x-edge-result-type": "Hit", "c-country": "GB", + "c-ip": faker.internet.ipv4(), ...over, }); @@ -405,10 +399,53 @@ describe("computing rollup summaries on a schedule", () => { }); }); + it("counts the visitors the closed hour saw", async () => { + // Given an hour holding two views from one address and one from another. + const returning = faker.internet.ipv4(); + const deployed = await deployAnalytics(); + await putDelivered(deployed, theClosedHour, [ + aRecord(theClosedHour, { "c-ip": returning }), + aRecord(theClosedHour, { "c-ip": returning }), + aRecord(theClosedHour, { "c-ip": faker.internet.ipv4() }), + ]); + + // When the schedule fires. + await deployed.simAws.clock().advanceBy({ minutes: 16 }); + + // Then the summary carries three views and two visitors. The gap between + // the two numbers is the address that came back, counted once. + const summary = await summaryAt(deployed, closedHourKey); + + expect(summary?.rows).toStrictEqual([{ path: "/", views: "3" }]); + expect(summary?.visitors).toStrictEqual({ distinct: 2, additive: false }); + }); + + it("asks Athena nothing where the salt parameter is missing", async () => { + // Given a deployment naming a parameter nobody created, and an hour of + // traffic waiting to be counted. + const parameter = `/mine/${faker.string.uuid()}`; + const deployed = await deployAnalytics({ visitorSaltParameter: parameter }); + const account = deployed.simAws.region("us-east-1").account(); + await putDelivered(deployed, theClosedHour, [aRecord(theClosedHour)]); + + // When the schedule fires. + await deployed.simAws.clock().advanceBy({ minutes: 16 }); + + // Then the run failed naming the parameter, having asked Athena nothing. + // A run that queried first would have paid for a window it then refused + // to write. Scheduler keeps a failed invocation to itself, and the + // simulation's record of it stands in for the log group. + const [failure] = account.scheduler().deliveryFailures; + + expect(failure?.message).toContain(parameter); + expect(account.athena().queryExecutions()).toStrictEqual([]); + await expect(summaryAt(deployed, closedHourKey)).resolves.toBeUndefined(); + }); + it("hands the visitor count to the schedule without the salt", async () => { // Given a deployment of the question as Rainlytics ships it, which counts // visitors. - const deployed = await deployAnalytics({ rollups: [pageviews] }); + const deployed = await deployAnalytics(); // When the schedule's target input is read back. const schedule = await deployed.simAws From a364f4d3ce1438a82570893ad2ba2ce3bb7554fb Mon Sep 17 00:00:00 2001 From: Hugh Grigg Date: Fri, 28 Aug 2026 18:41:38 +0100 Subject: [PATCH 2/2] test: write out the two addresses the visitor count separates Both were drawn from faker, which does not promise two draws differ. The count asserted is how many distinct addresses the window held, so the addresses are the one thing in the case that has to be written out. --- src/cdk/rollup-summaries.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cdk/rollup-summaries.test.ts b/src/cdk/rollup-summaries.test.ts index a82b77a..f624e3e 100644 --- a/src/cdk/rollup-summaries.test.ts +++ b/src/cdk/rollup-summaries.test.ts @@ -401,12 +401,15 @@ describe("computing rollup summaries on a schedule", () => { it("counts the visitors the closed hour saw", async () => { // Given an hour holding two views from one address and one from another. - const returning = faker.internet.ipv4(); + // Both are written out from the documentation ranges rather than drawn, + // because the number this asserts is how many of them there are. + const returning = "203.0.113.7"; + const passingThrough = "198.51.100.24"; const deployed = await deployAnalytics(); await putDelivered(deployed, theClosedHour, [ aRecord(theClosedHour, { "c-ip": returning }), aRecord(theClosedHour, { "c-ip": returning }), - aRecord(theClosedHour, { "c-ip": faker.internet.ipv4() }), + aRecord(theClosedHour, { "c-ip": passingThrough }), ]); // When the schedule fires.