Skip to content

Commit 7006630

Browse files
authored
Add code for interpolation search for asset manifest lookup (#8044)
1 parent 1f80d69 commit 7006630

File tree

8 files changed

+507
-6
lines changed

8 files changed

+507
-6
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/workers-shared": minor
3+
---
4+
5+
chore: Adds analytics and code (zero-percent gated) for a new asset manifest search algorithm
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { afterAll, beforeAll } from "vitest";
2+
3+
// Can be deleted once Node.js (where these tests run) version is bumped to one which includes this global :)
4+
5+
beforeAll(() => {
6+
// @ts-expect-error will go away once Node.js is bumped
7+
globalThis.crypto = require("crypto");
8+
});
9+
10+
afterAll(() => {
11+
// @ts-expect-error will go away once Node.js is bumped
12+
delete globalThis.crypto;
13+
});
140 Bytes
Binary file not shown.

packages/workers-shared/asset-worker/src/assets-manifest.ts

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,26 @@ export class AssetsManifest {
1212
this.data = data;
1313
}
1414

15-
async get(pathname: string) {
15+
async getWithBinarySearch(pathname: string) {
1616
const pathHash = await hashPath(pathname);
1717
const entry = binarySearch(
1818
new Uint8Array(this.data, HEADER_SIZE),
1919
pathHash
2020
);
2121
return entry ? contentHashToKey(entry) : null;
2222
}
23+
24+
async getWithInterpolationSearch(pathname: string) {
25+
const pathHash = await hashPath(pathname);
26+
const entry = interpolationSearch(
27+
new Uint8Array(this.data, HEADER_SIZE),
28+
pathHash
29+
);
30+
return entry ? contentHashToKey(entry) : null;
31+
}
2332
}
2433

25-
const hashPath = async (path: string) => {
34+
export const hashPath = async (path: string) => {
2635
const encoder = new TextEncoder();
2736
const data = encoder.encode(path);
2837
const hashBuffer = await crypto.subtle.digest(
@@ -32,7 +41,7 @@ const hashPath = async (path: string) => {
3241
return new Uint8Array(hashBuffer, 0, PATH_HASH_SIZE);
3342
};
3443

35-
const binarySearch = (
44+
export const binarySearch = (
3645
arr: Uint8Array,
3746
searchValue: Uint8Array
3847
): Uint8Array | false => {
@@ -67,7 +76,81 @@ const binarySearch = (
6776
}
6877
};
6978

70-
const compare = (a: Uint8Array, b: Uint8Array) => {
79+
const uint8ArrayToNumber = (uint8Array: Uint8Array) => {
80+
const dataView = new DataView(uint8Array.buffer, uint8Array.byteOffset);
81+
return (dataView.getBigUint64(0) << 64n) + dataView.getBigUint64(8);
82+
};
83+
84+
export const interpolationSearch = (
85+
arr: Uint8Array,
86+
searchValue: Uint8Array
87+
) => {
88+
if (arr.byteLength === 0) {
89+
return false;
90+
}
91+
let low = 0;
92+
let high = arr.byteLength / ENTRY_SIZE - 1;
93+
if (high === low) {
94+
const current = new Uint8Array(arr.buffer, arr.byteOffset, PATH_HASH_SIZE);
95+
if (current.byteLength !== searchValue.byteLength) {
96+
throw new TypeError(
97+
"Search value and current value are of different lengths"
98+
);
99+
}
100+
const cmp = compare(current, searchValue);
101+
if (cmp === 0) {
102+
return new Uint8Array(arr.buffer, arr.byteOffset, ENTRY_SIZE);
103+
} else {
104+
return false;
105+
}
106+
}
107+
const searchValueNumber = uint8ArrayToNumber(searchValue);
108+
while (low <= high) {
109+
const lowValue = new Uint8Array(
110+
arr.buffer,
111+
arr.byteOffset + low * ENTRY_SIZE,
112+
PATH_HASH_SIZE
113+
);
114+
const highValue = new Uint8Array(
115+
arr.buffer,
116+
arr.byteOffset + high * ENTRY_SIZE,
117+
PATH_HASH_SIZE
118+
);
119+
const mid = Math.floor(
120+
Number(
121+
BigInt(low) +
122+
(BigInt(high - low) *
123+
(searchValueNumber - uint8ArrayToNumber(lowValue))) /
124+
(uint8ArrayToNumber(highValue) - uint8ArrayToNumber(lowValue))
125+
)
126+
);
127+
const current = new Uint8Array(
128+
arr.buffer,
129+
arr.byteOffset + mid * ENTRY_SIZE,
130+
PATH_HASH_SIZE
131+
);
132+
if (current.byteLength !== searchValue.byteLength) {
133+
throw new TypeError(
134+
"Search value and current value are of different lengths"
135+
);
136+
}
137+
const cmp = compare(current, searchValue);
138+
if (cmp === 0) {
139+
return new Uint8Array(
140+
arr.buffer,
141+
arr.byteOffset + mid * ENTRY_SIZE,
142+
ENTRY_SIZE
143+
);
144+
} else if (cmp < 0) {
145+
low = mid + 1;
146+
} else {
147+
high = mid - 1;
148+
}
149+
}
150+
return false;
151+
};
152+
153+
export const compare = (a: Uint8Array, b: Uint8Array) => {
71154
if (a.byteLength < b.byteLength) {
72155
return -1;
73156
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ReadyAnalytics } from "./types";
2+
3+
// This will allow us to make breaking changes to the analytic schema
4+
const VERSION = 1;
5+
6+
// When adding new columns please update the schema
7+
type Data = {
8+
// -- Indexes --
9+
accountId?: number;
10+
experimentName?: string;
11+
12+
// -- Doubles --
13+
// double1 - The time it takes to read the manifest in milliseconds
14+
manifestReadTime?: number;
15+
16+
// -- Blobs --
17+
// blob1 - Manifest read method
18+
manifestReadMethod?: string;
19+
};
20+
21+
export class ExperimentAnalytics {
22+
private data: Data = {};
23+
private readyAnalytics?: ReadyAnalytics;
24+
25+
constructor(readyAnalytics?: ReadyAnalytics) {
26+
this.readyAnalytics = readyAnalytics;
27+
}
28+
29+
setData(newData: Partial<Data>) {
30+
this.data = { ...this.data, ...newData };
31+
}
32+
33+
getData(key: keyof Data) {
34+
return this.data[key];
35+
}
36+
37+
write() {
38+
if (!this.readyAnalytics) {
39+
return;
40+
}
41+
42+
this.readyAnalytics.logEvent({
43+
version: VERSION,
44+
accountId: this.data.accountId,
45+
indexId: this.data.experimentName,
46+
doubles: [
47+
this.data.manifestReadTime ?? -1, // double1
48+
],
49+
blobs: [
50+
this.data.manifestReadMethod, // blob1
51+
],
52+
});
53+
}
54+
}

packages/workers-shared/asset-worker/src/index.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mockJaegerBinding } from "../../utils/tracing";
66
import { Analytics } from "./analytics";
77
import { AssetsManifest } from "./assets-manifest";
88
import { applyConfigurationDefaults } from "./configuration";
9+
import { ExperimentAnalytics } from "./experiment-analytics";
910
import { decodePath, getIntent, handleRequest } from "./handler";
1011
import { getAssetWithMetadataFromKV } from "./utils/kv";
1112
import type {
@@ -39,6 +40,7 @@ export type Env = {
3940
JAEGER: JaegerTracing;
4041

4142
ENVIRONMENT: Environment;
43+
EXPERIMENT_ANALYTICS: ReadyAnalytics;
4244
ANALYTICS: ReadyAnalytics;
4345
COLO_METADATA: ColoMetadata;
4446
UNSAFE_PERFORMANCE: UnsafePerformanceTimer;
@@ -212,7 +214,38 @@ export default class extends WorkerEntrypoint<Env> {
212214
}
213215

214216
async unstable_exists(pathname: string): Promise<string | null> {
215-
const assetsManifest = new AssetsManifest(this.env.ASSETS_MANIFEST);
216-
return await assetsManifest.get(pathname);
217+
const analytics = new ExperimentAnalytics(this.env.EXPERIMENT_ANALYTICS);
218+
const performance = new PerformanceTimer(this.env.UNSAFE_PERFORMANCE);
219+
220+
const INTERPOLATION_EXPERIMENT_SAMPLE_RATE = 0;
221+
let searchMethod: "binary" | "interpolation" = "binary";
222+
if (Math.random() < INTERPOLATION_EXPERIMENT_SAMPLE_RATE) {
223+
searchMethod = "interpolation";
224+
}
225+
analytics.setData({ manifestReadMethod: searchMethod });
226+
227+
if (
228+
this.env.COLO_METADATA &&
229+
this.env.VERSION_METADATA &&
230+
this.env.CONFIG
231+
) {
232+
analytics.setData({
233+
accountId: this.env.CONFIG.account_id,
234+
experimentName: "manifest-read-timing",
235+
});
236+
}
237+
238+
const startTimeMs = performance.now();
239+
try {
240+
const assetsManifest = new AssetsManifest(this.env.ASSETS_MANIFEST);
241+
if (searchMethod === "interpolation") {
242+
return await assetsManifest.getWithInterpolationSearch(pathname);
243+
} else {
244+
return await assetsManifest.getWithBinarySearch(pathname);
245+
}
246+
} finally {
247+
analytics.setData({ manifestReadTime: performance.now() - startTimeMs });
248+
analytics.write();
249+
}
217250
}
218251
}

0 commit comments

Comments
 (0)