diff --git a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts index 6b1db125..cfbeacfb 100644 --- a/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts +++ b/packages/cloudflare/src/api/overrides/tag-cache/do-sharded-tag-cache.ts @@ -106,30 +106,6 @@ interface DOIdOptions { region?: DurableObjectLocationHint; } -export class DOId { - shardId: string; - replicaId: number; - region?: DurableObjectLocationHint; - constructor(public options: DOIdOptions) { - const { baseShardId, shardType, numberOfReplicas, replicaId, region } = options; - this.shardId = `tag-${shardType};${baseShardId}`; - this.replicaId = replicaId ?? this.generateRandomNumberBetween(1, numberOfReplicas); - this.region = region; - } - - private generateRandomNumberBetween(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1) + min); - } - - get key() { - return `${this.shardId};replica-${this.replicaId}${this.region ? `;region-${this.region}` : ""}`; - } -} - -interface CacheTagKeyOptions { - doId: DOId; - tags: string[]; -} class ShardedDOTagCache implements NextModeTagCache { readonly mode = "nextMode" as const; readonly name = NAME; @@ -148,159 +124,12 @@ class ShardedDOTagCache implements NextModeTagCache { this.defaultRegion = opts.shardReplication?.regionalReplication?.defaultRegion ?? DEFAULT_REGION; } - private getDurableObjectStub(doId: DOId) { - const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED; - if (!durableObject) throw new IgnorableError("No durable object binding for cache revalidation"); - - const id = durableObject.idFromName(doId.key); - debug("[shardedTagCache] - Accessing Durable Object : ", { - key: doId.key, - region: doId.region, - }); - return durableObject.get(id, { locationHint: doId.region }); - } - /** - * Generates a list of DO ids for the shards and replicas - * @param tags The tags to generate shards for - * @param shardType Whether to generate shards for soft or hard tags - * @param generateAllShards Whether to generate all shards or only one - * @returns An array of TagCacheDOId and tag + * Public API */ - private generateDOIdArray({ - tags, - shardType, - generateAllReplicas = false, - }: { - tags: string[]; - shardType: "soft" | "hard"; - generateAllReplicas: boolean; - }) { - let replicaIndexes: Array = [1]; - const isSoft = shardType === "soft"; - let numReplicas = 1; - if (this.opts.shardReplication) { - numReplicas = isSoft ? this.numSoftReplicas : this.numHardReplicas; - replicaIndexes = generateAllReplicas - ? Array.from({ length: numReplicas }, (_, i) => i + 1) - : [undefined]; - } - const regionalReplicas = replicaIndexes.flatMap((replicaId) => { - return tags - .filter((tag) => (isSoft ? tag.startsWith(SOFT_TAG_PREFIX) : !tag.startsWith(SOFT_TAG_PREFIX))) - .map((tag) => { - return { - doId: new DOId({ - baseShardId: generateShardId(tag, this.opts.baseShardSize, "shard"), - numberOfReplicas: numReplicas, - shardType, - replicaId, - }), - tag, - }; - }); - }); - if (!this.enableRegionalReplication) return regionalReplicas; - // If we have regional replication enabled, we need to further duplicate the shards in all the regions - const regionalReplicasInAllRegions = generateAllReplicas - ? regionalReplicas.flatMap(({ doId, tag }) => { - return AVAILABLE_REGIONS.map((region) => { - return { - doId: new DOId({ - baseShardId: doId.options.baseShardId, - numberOfReplicas: numReplicas, - shardType, - replicaId: doId.replicaId, - region, - }), - tag, - }; - }); - }) - : regionalReplicas.map(({ doId, tag }) => { - doId.region = this.getClosestRegion(); - return { doId, tag }; - }); - return regionalReplicasInAllRegions; - } - - getClosestRegion() { - const continent = getCloudflareContext().cf?.continent; - if (!continent) return this.defaultRegion; - debug("[shardedTagCache] - Continent : ", continent); - switch (continent) { - case "AF": - return "afr"; - case "AS": - return "apac"; - case "EU": - return "weur"; - case "NA": - return "enam"; - case "OC": - return "oc"; - case "SA": - return "sam"; - default: - return this.defaultRegion; - } - } - - /** - * Same tags are guaranteed to be in the same shard - * @param tags - * @returns An array of DO ids and tags - */ - groupTagsByDO({ tags, generateAllReplicas = false }: { tags: string[]; generateAllReplicas?: boolean }) { - // Here we'll start by splitting soft tags from hard tags - // This will greatly increase the cache hit rate for the soft tag (which are the most likely to cause issue because of load) - const softTags = this.generateDOIdArray({ tags, shardType: "soft", generateAllReplicas }); - - const hardTags = this.generateDOIdArray({ tags, shardType: "hard", generateAllReplicas }); - - const tagIdCollection = [...softTags, ...hardTags]; - - // We then group the tags by DO id - const tagsByDOId = new Map< - string, - { - doId: DOId; - tags: string[]; - } - >(); - for (const { doId, tag } of tagIdCollection) { - const doIdString = doId.key; - const tagsArray = tagsByDOId.get(doIdString)?.tags ?? []; - tagsArray.push(tag); - tagsByDOId.set(doIdString, { - // We override the doId here, but it should be the same for all tags - doId, - tags: tagsArray, - }); - } - const result = Array.from(tagsByDOId.values()); - return result; - } - - private async getConfig() { - const cfEnv = getCloudflareContext().env; - const db = cfEnv.NEXT_TAG_CACHE_DO_SHARDED; - - if (!db) debugCache("No Durable object found"); - const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig - .dangerous?.disableTagCache; - - return !db || isDisabled - ? { isDisabled: true as const } - : { - isDisabled: false as const, - db, - }; - } - - async getLastRevalidated(tags: string[]): Promise { - const { isDisabled } = await this.getConfig(); + public async getLastRevalidated(tags: string[]): Promise { + const { isDisabled } = this.getConfig(); if (isDisabled) return 0; if (tags.length === 0) return 0; // No tags to check const deduplicatedTags = Array.from(new Set(tags)); // We deduplicate the tags to avoid unnecessary requests @@ -343,8 +172,8 @@ class ShardedDOTagCache implements NextModeTagCache { * @param lastModified default to `Date.now()` * @returns */ - async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { - const { isDisabled } = await this.getConfig(); + public async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { + const { isDisabled } = this.getConfig(); if (isDisabled) return false; try { const shardedTagGroups = this.groupTagsByDO({ tags }); @@ -387,8 +216,8 @@ class ShardedDOTagCache implements NextModeTagCache { * @param tags * @returns */ - async writeTags(tags: string[]): Promise { - const { isDisabled } = await this.getConfig(); + public async writeTags(tags: string[]): Promise { + const { isDisabled } = this.getConfig(); if (isDisabled) return; const shardedTagGroups = this.groupTagsByDO({ tags, generateAllReplicas: true }); // We want to use the same revalidation time for all tags @@ -401,7 +230,11 @@ class ShardedDOTagCache implements NextModeTagCache { await purgeCacheByTags(tags); } - async performWriteTagsWithRetry(doId: DOId, tags: string[], lastModified: number, retryNumber = 0) { + /** + * The following methods are public only because they are accessed from the tests + */ + + public async performWriteTagsWithRetry(doId: DOId, tags: string[], lastModified: number, retryNumber = 0) { try { const stub = this.getDurableObjectStub(doId); await stub.writeTags(tags, lastModified); @@ -424,24 +257,23 @@ class ShardedDOTagCache implements NextModeTagCache { } } - // Cache API - async getCacheInstance() { + public getCacheUrlKey(doId: DOId, tag: string) { + return `http://local.cache/shard/${doId.shardId}?tag=${encodeURIComponent(tag)}`; + } + + public async getCacheInstance() { if (!this.localCache && this.opts.regionalCache) { this.localCache = await caches.open("sharded-do-tag-cache"); } return this.localCache; } - getCacheUrlKey(doId: DOId, tag: string) { - return `http://local.cache/shard/${doId.shardId}?tag=${encodeURIComponent(tag)}`; - } - /** * Get the last revalidation time for the tags from the regional cache * If the cache is not enabled, it will return an empty array * @returns An array of objects with the tag and the last revalidation time */ - async getFromRegionalCache(opts: CacheTagKeyOptions) { + public async getFromRegionalCache(opts: CacheTagKeyOptions) { try { if (!this.opts.regionalCache) return []; const cache = await this.getCacheInstance(); @@ -465,7 +297,8 @@ class ShardedDOTagCache implements NextModeTagCache { return []; } } - async putToRegionalCache(optsKey: CacheTagKeyOptions, stub: DurableObjectStub) { + + public async putToRegionalCache(optsKey: CacheTagKeyOptions, stub: DurableObjectStub) { if (!this.opts.regionalCache) return; const cache = await this.getCacheInstance(); if (!cache) return; @@ -506,7 +339,7 @@ class ShardedDOTagCache implements NextModeTagCache { * Deletes the regional cache for the given tags * This is used to ensure that the cache is cleared when the tags are revalidated */ - async deleteRegionalCache(optsKey: CacheTagKeyOptions) { + public async deleteRegionalCache(optsKey: CacheTagKeyOptions) { // We never want to crash because of the cache try { if (!this.opts.regionalCache) return; @@ -523,6 +356,190 @@ class ShardedDOTagCache implements NextModeTagCache { debugCache("Error while deleting from regional cache", e); } } + + /** + * Same tags are guaranteed to be in the same shard + * @param tags + * @returns An array of DO ids and tags + */ + public groupTagsByDO({ + tags, + generateAllReplicas = false, + }: { + tags: string[]; + generateAllReplicas?: boolean; + }) { + // Here we'll start by splitting soft tags from hard tags + // This will greatly increase the cache hit rate for the soft tag (which are the most likely to cause issue because of load) + const softTags = this.generateDOIdArray({ tags, shardType: "soft", generateAllReplicas }); + + const hardTags = this.generateDOIdArray({ tags, shardType: "hard", generateAllReplicas }); + + const tagIdCollection = [...softTags, ...hardTags]; + + // We then group the tags by DO id + const tagsByDOId = new Map< + string, + { + doId: DOId; + tags: string[]; + } + >(); + for (const { doId, tag } of tagIdCollection) { + const doIdString = doId.key; + const tagsArray = tagsByDOId.get(doIdString)?.tags ?? []; + tagsArray.push(tag); + tagsByDOId.set(doIdString, { + // We override the doId here, but it should be the same for all tags + doId, + tags: tagsArray, + }); + } + const result = Array.from(tagsByDOId.values()); + return result; + } + + // Private methods + + private getDurableObjectStub(doId: DOId) { + const durableObject = getCloudflareContext().env.NEXT_TAG_CACHE_DO_SHARDED; + if (!durableObject) throw new IgnorableError("No durable object binding for cache revalidation"); + + const id = durableObject.idFromName(doId.key); + debug("[shardedTagCache] - Accessing Durable Object : ", { + key: doId.key, + region: doId.region, + }); + return durableObject.get(id, { locationHint: doId.region }); + } + + /** + * Generates a list of DO ids for the shards and replicas + * @param tags The tags to generate shards for + * @param shardType Whether to generate shards for soft or hard tags + * @param generateAllShards Whether to generate all shards or only one + * @returns An array of TagCacheDOId and tag + */ + private generateDOIdArray({ + tags, + shardType, + generateAllReplicas = false, + }: { + tags: string[]; + shardType: "soft" | "hard"; + generateAllReplicas: boolean; + }) { + let replicaIndexes: Array = [1]; + const isSoft = shardType === "soft"; + let numReplicas = 1; + if (this.opts.shardReplication) { + numReplicas = isSoft ? this.numSoftReplicas : this.numHardReplicas; + replicaIndexes = generateAllReplicas + ? Array.from({ length: numReplicas }, (_, i) => i + 1) + : [undefined]; + } + const regionalReplicas = replicaIndexes.flatMap((replicaId) => { + return tags + .filter((tag) => (isSoft ? tag.startsWith(SOFT_TAG_PREFIX) : !tag.startsWith(SOFT_TAG_PREFIX))) + .map((tag) => { + return { + doId: new DOId({ + baseShardId: generateShardId(tag, this.opts.baseShardSize, "shard"), + numberOfReplicas: numReplicas, + shardType, + replicaId, + }), + tag, + }; + }); + }); + if (!this.enableRegionalReplication) return regionalReplicas; + + // If we have regional replication enabled, we need to further duplicate the shards in all the regions + const regionalReplicasInAllRegions = generateAllReplicas + ? regionalReplicas.flatMap(({ doId, tag }) => { + return AVAILABLE_REGIONS.map((region) => { + return { + doId: new DOId({ + baseShardId: doId.options.baseShardId, + numberOfReplicas: numReplicas, + shardType, + replicaId: doId.replicaId, + region, + }), + tag, + }; + }); + }) + : regionalReplicas.map(({ doId, tag }) => { + doId.region = this.getClosestRegion(); + return { doId, tag }; + }); + return regionalReplicasInAllRegions; + } + + private getClosestRegion() { + const continent = getCloudflareContext().cf?.continent; + if (!continent) return this.defaultRegion; + debug("[shardedTagCache] - Continent : ", continent); + switch (continent) { + case "AF": + return "afr"; + case "AS": + return "apac"; + case "EU": + return "weur"; + case "NA": + return "enam"; + case "OC": + return "oc"; + case "SA": + return "sam"; + default: + return this.defaultRegion; + } + } + + private getConfig() { + const cfEnv = getCloudflareContext().env; + const db = cfEnv.NEXT_TAG_CACHE_DO_SHARDED; + + if (!db) debugCache("No Durable object found"); + const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig + .dangerous?.disableTagCache; + + return !db || isDisabled + ? { isDisabled: true as const } + : { + isDisabled: false as const, + db, + }; + } +} + +export class DOId { + shardId: string; + replicaId: number; + region?: DurableObjectLocationHint; + constructor(public options: DOIdOptions) { + const { baseShardId, shardType, numberOfReplicas, replicaId, region } = options; + this.shardId = `tag-${shardType};${baseShardId}`; + this.replicaId = replicaId ?? this.generateRandomNumberBetween(1, numberOfReplicas); + this.region = region; + } + + private generateRandomNumberBetween(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); + } + + get key() { + return `${this.shardId};replica-${this.replicaId}${this.region ? `;region-${this.region}` : ""}`; + } +} + +interface CacheTagKeyOptions { + doId: DOId; + tags: string[]; } export default (opts?: ShardedDOTagCacheOptions) => new ShardedDOTagCache(opts);