refactor(api): drop Bunny for pluggable cache purge adapters (#2742)

This commit is contained in:
Hampus
2026-09-13 20:28:56 +02:00
committed by GitHub
parent 84dcf6b8a8
commit ca7ddd9272
45 changed files with 929 additions and 977 deletions
+7 -4
View File
@@ -409,10 +409,13 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
: undefined,
legacyPrices: master.integrations.stripe.legacy_prices,
},
bunny: {
purgeEnabled: master.integrations.bunny.purge_enabled,
apiKey: master.integrations.bunny.api_key,
pullZoneId: master.integrations.bunny.pull_zone_id,
cachePurge: {
adapter: master.integrations.cache_purge.adapter,
http: {
endpoint: master.integrations.cache_purge.http.endpoint,
token: master.integrations.cache_purge.http.token,
timeoutMs: master.integrations.cache_purge.http.timeout_ms,
},
},
clamav: {
enabled: master.integrations.clamav.enabled,
@@ -5,7 +5,7 @@ import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
import {UserMessageDeletionService} from '@app/api/channel/services/message/UserMessageDeletionService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildMemberOperationsService} from '@app/api/guild/services/member/GuildMemberOperationsService';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
@@ -21,7 +21,7 @@ import {
} from '@app/api/channel/services/message/MessageHelpers';
import {applyUploadRelayDecision, resolveUploadRelayDecision} from '@app/api/channel/services/UploadRelay';
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
@@ -387,10 +387,8 @@ export class AttachmentUploadService {
}
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
await this.purgeQueue.addUrls([cdnUrl]);
}
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
await this.purgeQueue.addUrls([cdnUrl]);
const updatedAttachments = message.attachments.filter((a: Attachment) => a.id !== attachmentId);
const updatedRowData = {
...message.toRow(),
@@ -12,7 +12,7 @@ import type {MessagePersistenceService} from '@app/api/channel/services/message/
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
@@ -16,7 +16,7 @@ import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMeme
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {EmbedService} from '@app/api/infrastructure/EmbedService';
import type {ILiveKitService} from '@app/api/infrastructure/ILiveKitService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
@@ -20,7 +20,7 @@ import {MessageValidationService} from '@app/api/channel/services/message/Messag
import type {IFavoriteMemeRepository} from '@app/api/favorite_meme/IFavoriteMemeRepository';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IMediaService} from '@app/api/infrastructure/IMediaService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
@@ -6,7 +6,7 @@ import type {IChannelRepositoryAggregate} from '@app/api/channel/repositories/IC
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import {dispatchMessageCreateBroadcast} from '@app/api/channel/services/message/MessageGatewayDispatch';
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
@@ -9,7 +9,7 @@ import {isOperationDisabled, purgeMessageAttachments} from '@app/api/channel/ser
import type {MessageSearchService} from '@app/api/channel/services/message/MessageSearchService';
import type {MessageValidationService} from '@app/api/channel/services/message/MessageValidationService';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAttachmentID, createChannelID, createMessageID, createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import {purgeMessageAttachments} from '@app/api/channel/services/message/MessageHelpers';
import type {MessageEmbed} from '@app/api/database/types/MessageTypes';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {Message} from '@app/api/models/Message';
import {MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {describe, expect, it} from 'vitest';
const CHANNEL_ID = createChannelID(10n);
const ATTACHMENT_KEY = 'attachments/10/200/ação.png';
const OTHER_MESSAGE_ATTACHMENT_KEY = 'attachments/11/300/photo.jpg';
function imageEmbed(key: string): MessageEmbed {
return {
type: 'image',
title: null,
description: null,
url: null,
timestamp: null,
color: null,
author: null,
provider: null,
thumbnail: null,
image: {
url: `${Config.endpoints.media}/${key}`,
width: 16,
height: 16,
description: null,
content_type: 'image/png',
content_hash: null,
placeholder: null,
flags: 0,
duration: null,
},
video: null,
footer: null,
fields: null,
nsfw: null,
};
}
function makeMessageWithMedia(): Message {
return new Message({
channel_id: CHANNEL_ID,
bucket: 0,
message_id: createMessageID(100n),
author_id: createUserID(3n),
type: MessageTypes.DEFAULT,
webhook_id: null,
webhook_name: null,
webhook_avatar_hash: null,
content: '',
edited_timestamp: null,
pinned_timestamp: null,
flags: 0,
mention_everyone: false,
mention_users: null,
mention_roles: null,
mention_channels: null,
attachments: [
{
attachment_id: createAttachmentID(200n),
filename: 'ação.png',
size: 1024n,
title: null,
description: null,
width: 16,
height: 16,
content_type: 'image/png',
content_hash: null,
placeholder: null,
flags: 0,
duration: null,
nsfw: null,
waveform: null,
},
],
embeds: [imageEmbed(ATTACHMENT_KEY), imageEmbed(OTHER_MESSAGE_ATTACHMENT_KEY)],
sticker_items: null,
message_reference: null,
message_snapshots: null,
call: null,
has_reaction: null,
version: 1,
});
}
describe('purgeMessageAttachments', () => {
it('queues each stored media URL of a deleted message once and leaves media of other messages alone', async () => {
const deletedObjects: Array<string> = [];
const queuedUrls: Array<string> = [];
const storageService = {
deleteObject: async (bucket: string, key: string) => {
deletedObjects.push(`${bucket}/${key}`);
},
} as unknown as IStorageService;
const purgeQueue: IPurgeQueue = {
addUrls: async (urls) => {
queuedUrls.push(...urls);
},
};
await purgeMessageAttachments(makeMessageWithMedia(), storageService, purgeQueue);
expect(deletedObjects).toEqual([`${Config.s3.buckets.cdn}/${ATTACHMENT_KEY}`]);
expect(queuedUrls).toEqual([`${Config.endpoints.media}/${ATTACHMENT_KEY}`]);
});
});
@@ -7,7 +7,7 @@ import type {
MessageSnapshot as CassandraMessageSnapshot,
MessageAttachment,
} from '@app/api/database/types/MessageTypes';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {Logger} from '@app/api/Logger';
@@ -353,21 +353,17 @@ export async function purgeMessageAttachments(
continue;
}
cdnKeys.add(cdnKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
}
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
}
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message, ownedCdnKeys)) {
if (cdnKeys.has(embedKey)) {
continue;
}
cdnKeys.add(embedKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
}
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
}
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
if (cdnUrls.length > 0) {
await purgeQueue.addUrls(cdnUrls);
}
}
@@ -11,7 +11,7 @@ import {
type SelfMessageFilter,
} from '@app/api/channel/services/message/SelfMessageFilter';
import {assertMutableUserId} from '@app/api/constants/Core';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {Logger} from '@app/api/Logger';
+11 -5
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
@@ -14,6 +15,15 @@ export interface PushProviderAppConfig {
projectId?: string;
}
export interface APICachePurgeConfig {
adapter: CachePurgeAdapterName;
http: {
endpoint: string;
token: string;
timeoutMs: number;
};
}
interface APIGeoipFilesystemConfig {
mode: 'filesystem';
maxmindDbPath?: string;
@@ -262,11 +272,7 @@ export interface APIConfig {
};
legacyPrices?: Record<string, Array<string> | undefined>;
};
bunny: {
purgeEnabled: boolean;
apiKey?: string;
pullZoneId?: number;
};
cachePurge: APICachePurgeConfig;
clamav: {
enabled: boolean;
host: string;
@@ -29,7 +29,7 @@ import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
import type {AttachmentUploadTraceByAttachmentRow} from '@app/api/database/types/AttachmentUploadTypes';
import type {NcmecAttachmentSubmissionRow, NcmecUserWorkflowRow} from '@app/api/database/types/CsamTypes';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
@@ -1,124 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/api/Logger';
import type {IKVProvider, KVPurgeBatchResult} from '@pkgs/kv_client/src/IKVProvider';
export interface IPurgeQueue {
addUrls(urls: Array<string>): Promise<void>;
getQueueSize(): Promise<number>;
clear(): Promise<void>;
}
const EXACT_QUEUE_KEY = 'bunny:purge:exact';
const PREFIX_QUEUE_KEY = 'bunny:purge:prefix';
const EXACT_BUCKET_KEY = 'bunny:purge:budget:exact';
const PREFIX_BUCKET_KEY = 'bunny:purge:budget:prefix';
const EXACT_MAX_TOKENS = 120;
const EXACT_REFILL_RATE = 5;
const EXACT_REFILL_INTERVAL_MS = 1000;
const PREFIX_MAX_TOKENS = 20;
const PREFIX_REFILL_RATE = 1;
const PREFIX_REFILL_INTERVAL_MS = 2000;
function isPrefix(url: string): boolean {
return url.endsWith('*') || url.endsWith('/');
}
export class BunnyPurgeQueue implements IPurgeQueue {
private readonly kvClient: IKVProvider;
constructor(kvClient: IKVProvider) {
this.kvClient = kvClient;
}
async addUrls(urls: Array<string>): Promise<void> {
if (urls.length === 0) {
return;
}
const exactUrls: Array<string> = [];
const prefixUrls: Array<string> = [];
for (const url of urls) {
const trimmed = url.trim();
if (trimmed === '') {
continue;
}
if (isPrefix(trimmed)) {
prefixUrls.push(trimmed);
} else {
exactUrls.push(trimmed);
}
}
try {
const ops: Array<Promise<number>> = [];
if (exactUrls.length > 0) {
ops.push(this.kvClient.sadd(EXACT_QUEUE_KEY, ...exactUrls));
}
if (prefixUrls.length > 0) {
ops.push(this.kvClient.sadd(PREFIX_QUEUE_KEY, ...prefixUrls));
}
await Promise.all(ops);
Logger.debug({exact: exactUrls.length, prefix: prefixUrls.length}, 'Added URLs to CDN purge queue');
} catch (error) {
Logger.error(
{error, exact: exactUrls.length, prefix: prefixUrls.length},
'Failed to add URLs to CDN purge queue',
);
throw error;
}
}
async getQueueSize(): Promise<number> {
try {
const [exactSize, prefixSize] = await Promise.all([
this.kvClient.scard(EXACT_QUEUE_KEY),
this.kvClient.scard(PREFIX_QUEUE_KEY),
]);
return exactSize + prefixSize;
} catch (error) {
Logger.error({error}, 'Failed to get CDN purge queue size');
throw error;
}
}
async clear(): Promise<void> {
try {
await this.kvClient.del(EXACT_QUEUE_KEY, PREFIX_QUEUE_KEY);
Logger.debug('Cleared CDN purge queue');
} catch (error) {
Logger.error({error}, 'Failed to clear CDN purge queue');
throw error;
}
}
async dequeueExactBatch(maxItems: number): Promise<KVPurgeBatchResult> {
return this.kvClient.dequeuePurgeBatch(
EXACT_QUEUE_KEY,
EXACT_BUCKET_KEY,
maxItems,
EXACT_MAX_TOKENS,
EXACT_REFILL_RATE,
EXACT_REFILL_INTERVAL_MS,
);
}
async dequeuePrefixBatch(maxItems: number): Promise<KVPurgeBatchResult> {
return this.kvClient.dequeuePurgeBatch(
PREFIX_QUEUE_KEY,
PREFIX_BUCKET_KEY,
maxItems,
PREFIX_MAX_TOKENS,
PREFIX_REFILL_RATE,
PREFIX_REFILL_INTERVAL_MS,
);
}
}
export class NoopPurgeQueue implements IPurgeQueue {
async addUrls(_urls: Array<string>): Promise<void> {}
async getQueueSize(): Promise<number> {
return 0;
}
async clear(): Promise<void> {}
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APICachePurgeConfig} from '@app/api/config/APIConfig';
import {createHttpCachePurgeAdapter} from '@app/api/infrastructure/HttpCachePurgeAdapter';
import {createNoneCachePurgeAdapter} from '@app/api/infrastructure/NoneCachePurgeAdapter';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
export interface CachePurgeBatch {
readonly exact: ReadonlyArray<string>;
readonly prefix: ReadonlyArray<string>;
}
export type CachePurgeOutcome =
| {readonly kind: 'purged'}
| {readonly kind: 'invalid_entries'; readonly status: number}
| {readonly kind: 'failed'; readonly status: number | null; readonly error: unknown};
export interface CachePurgeAdapter {
purge(batch: CachePurgeBatch): Promise<CachePurgeOutcome>;
}
const CACHE_PURGE_ADAPTERS = {
none: createNoneCachePurgeAdapter,
http: createHttpCachePurgeAdapter,
} satisfies Record<CachePurgeAdapterName, (config: APICachePurgeConfig) => CachePurgeAdapter>;
export function createCachePurgeAdapter(config: APICachePurgeConfig): CachePurgeAdapter {
return CACHE_PURGE_ADAPTERS[config.adapter](config);
}
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CachePurgeBatch} from '@app/api/infrastructure/CachePurgeAdapter';
import {Logger} from '@app/api/Logger';
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
export interface IPurgeQueue {
addUrls(urls: Array<string>): Promise<void>;
}
const EXACT_QUEUE_KEY = 'cache_purge:exact';
const PREFIX_QUEUE_KEY = 'cache_purge:prefix';
const EXACT_BUCKET_KEY = 'cache_purge:budget:exact';
const PREFIX_BUCKET_KEY = 'cache_purge:budget:prefix';
const REJECTED_KEY = 'cache_purge:rejected';
const EXACT_MAX_TOKENS = 120;
const EXACT_REFILL_RATE = 5;
const EXACT_REFILL_INTERVAL_MS = 1000;
const PREFIX_MAX_TOKENS = 20;
const PREFIX_REFILL_RATE = 1;
const PREFIX_REFILL_INTERVAL_MS = 2000;
function isPrefix(url: string): boolean {
return url.endsWith('*') || url.endsWith('/');
}
export class CachePurgeQueue implements IPurgeQueue {
private readonly kvClient: IKVProvider;
constructor(kvClient: IKVProvider) {
this.kvClient = kvClient;
}
async addUrls(urls: Array<string>): Promise<void> {
if (urls.length === 0) {
return;
}
const exactUrls: Array<string> = [];
const prefixUrls: Array<string> = [];
for (const url of urls) {
const trimmed = url.trim();
if (trimmed === '') {
continue;
}
if (isPrefix(trimmed)) {
prefixUrls.push(trimmed);
} else {
exactUrls.push(trimmed);
}
}
try {
await this.addToSets({exact: exactUrls, prefix: prefixUrls});
Logger.debug({exact: exactUrls.length, prefix: prefixUrls.length}, 'Added URLs to cache purge queue');
} catch (error) {
Logger.error(
{error, exact: exactUrls.length, prefix: prefixUrls.length},
'Failed to add URLs to cache purge queue',
);
throw error;
}
}
async dequeueBatch(): Promise<CachePurgeBatch> {
const exact = await this.kvClient.dequeuePurgeBatch(
EXACT_QUEUE_KEY,
EXACT_BUCKET_KEY,
EXACT_MAX_TOKENS,
EXACT_MAX_TOKENS,
EXACT_REFILL_RATE,
EXACT_REFILL_INTERVAL_MS,
);
try {
const prefix = await this.kvClient.dequeuePurgeBatch(
PREFIX_QUEUE_KEY,
PREFIX_BUCKET_KEY,
PREFIX_MAX_TOKENS,
PREFIX_MAX_TOKENS,
PREFIX_REFILL_RATE,
PREFIX_REFILL_INTERVAL_MS,
);
return {exact: exact.urls, prefix: prefix.urls};
} catch (error) {
await this.requeue({exact: exact.urls, prefix: []});
throw error;
}
}
async requeue(batch: CachePurgeBatch): Promise<void> {
try {
await this.addToSets(batch);
} catch (error) {
Logger.error(
{error, exact: batch.exact.length, prefix: batch.prefix.length},
'Failed to requeue cache purge entries',
);
throw error;
}
}
async reject(batch: CachePurgeBatch): Promise<void> {
await this.kvClient.sadd(REJECTED_KEY, ...batch.exact, ...batch.prefix);
Logger.error(
{exact: batch.exact.length, prefix: batch.prefix.length, key: REJECTED_KEY},
'Set aside cache purge entries the endpoint rejected',
);
}
private async addToSets(batch: CachePurgeBatch): Promise<void> {
const ops: Array<Promise<number>> = [];
if (batch.exact.length > 0) {
ops.push(this.kvClient.sadd(EXACT_QUEUE_KEY, ...batch.exact));
}
if (batch.prefix.length > 0) {
ops.push(this.kvClient.sadd(PREFIX_QUEUE_KEY, ...batch.prefix));
}
await Promise.all(ops);
}
}
export class NoopPurgeQueue implements IPurgeQueue {
async addUrls(_urls: Array<string>): Promise<void> {}
}
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APICachePurgeConfig} from '@app/api/config/APIConfig';
import type {CachePurgeAdapter, CachePurgeBatch, CachePurgeOutcome} from '@app/api/infrastructure/CachePurgeAdapter';
import * as FetchUtils from '@app/api/utils/FetchUtils';
function stripOneTrailingAsterisk(entry: string): string {
return entry.endsWith('*') ? entry.slice(0, -1) : entry;
}
export function createHttpCachePurgeAdapter(config: APICachePurgeConfig): CachePurgeAdapter {
return {
async purge(batch: CachePurgeBatch): Promise<CachePurgeOutcome> {
const headers: Record<string, string> = {'Content-Type': 'application/json'};
if (config.http.token !== '') {
headers.Authorization = `Bearer ${config.http.token}`;
}
let response: Response;
try {
response = await fetch(config.http.endpoint, {
method: 'POST',
headers,
body: JSON.stringify({exact: batch.exact, prefix: batch.prefix.map(stripOneTrailingAsterisk)}),
redirect: 'manual',
signal: AbortSignal.timeout(config.http.timeoutMs),
});
} catch (error) {
return {kind: 'failed', status: null, error};
}
FetchUtils.discardResponseBody(response.body, response.status);
if (response.ok) {
return {kind: 'purged'};
}
if (response.status === 400 || response.status === 422) {
return {kind: 'invalid_entries', status: response.status};
}
return {kind: 'failed', status: response.status, error: null};
},
};
}
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {CachePurgeAdapter} from '@app/api/infrastructure/CachePurgeAdapter';
export function createNoneCachePurgeAdapter(): CachePurgeAdapter {
return {
purge: async () => ({kind: 'purged'}),
};
}
@@ -9,7 +9,7 @@ import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildService} from '@app/api/guild/services/GuildService';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {EmbedService} from '@app/api/infrastructure/EmbedService';
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue';
@@ -31,7 +31,7 @@ import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import {GuildDiscoveryService} from '@app/api/guild/services/GuildDiscoveryService';
import {AssetDeletionQueue} from '@app/api/infrastructure/AssetDeletionQueue';
import {AvatarService} from '@app/api/infrastructure/AvatarService';
import {BunnyPurgeQueue, type IPurgeQueue, NoopPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import {CachePurgeQueue, type IPurgeQueue, NoopPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import {DisabledVirusScanService} from '@app/api/infrastructure/DisabledVirusScanService';
import {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import {EmailDnsValidationService} from '@app/api/infrastructure/EmailDnsValidationService';
@@ -240,7 +240,7 @@ export const getLimitConfigService = singleton(
},
);
export const getPurgeQueue: () => IPurgeQueue = singleton(() =>
Config.bunny.purgeEnabled ? new BunnyPurgeQueue(getKVClient()) : new NoopPurgeQueue(),
Config.cachePurge.adapter === 'none' ? new NoopPurgeQueue() : new CachePurgeQueue(getKVClient()),
);
export const getAssetDeletionQueue: () => IAssetDeletionQueue = singleton(() => new AssetDeletionQueue(getKVClient()));
@@ -1,27 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {HttpResponse, http} from 'msw';
const BUNNY_EDGE_IPV4_LIST = ['198.51.100.10', '198.51.100.11'].join('\n');
const BUNNY_EDGE_IPV6_LIST = ['2001:db8::10', '2001:db8::11'].join('\n');
export function createBunnyEdgeHandlers() {
return [
http.get(
'https://bunnycdn.com/api/system/edgeserverlist/plain',
() =>
new HttpResponse(BUNNY_EDGE_IPV4_LIST, {
status: 200,
headers: {'content-type': 'text/plain; charset=utf-8'},
}),
),
http.get(
'https://bunnycdn.com/api/system/edgeserverlist/IPv6/plain',
() =>
new HttpResponse(BUNNY_EDGE_IPV6_LIST, {
status: 200,
headers: {'content-type': 'text/plain; charset=utf-8'},
}),
),
];
}
-2
View File
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createBunnyEdgeHandlers} from '@app/api/test/msw/handlers/BunnyEdgeHandlers';
import {createIpInfoLookupHandler} from '@app/api/test/msw/handlers/IpInfoHandlers';
import {createNcmecHandlers} from '@app/api/test/msw/handlers/NcmecHandlers';
import {createOnionooDetailsHandler} from '@app/api/test/msw/handlers/OnionooHandlers';
@@ -9,7 +8,6 @@ import {createPwnedPasswordsRangeHandler} from '@app/api/test/msw/handlers/Pwned
import {setupServer} from 'msw/node';
export const server = setupServer(
...createBunnyEdgeHandlers(),
...createNcmecHandlers(),
...createOpenNsfwHandlers(),
createIpInfoLookupHandler(),
@@ -8,7 +8,7 @@ import type {ChannelRepository} from '@app/api/channel/ChannelRepository';
import type {IConnectionRepository} from '@app/api/connection/IConnectionRepository';
import type {FavoriteMemeRepository} from '@app/api/favorite_meme/FavoriteMemeRepository';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService';
@@ -21,7 +21,6 @@ export const EXTERNAL_RESPONSE_LIMITS = {
pwnedPasswordsBytes: 1024 * 1024,
rdapBytes: 512 * 1024,
externalTemplateBytes: 512 * 1024,
bunnyErrorBytes: 16 * 1024,
ncmecResponseBytes: 64 * 1024,
fileBlocklistBytes: 25 * 1024 * 1024,
urlBlocklistBytes: 25 * 1024 * 1024,
@@ -17,7 +17,7 @@ import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {GuildService} from '@app/api/guild/services/GuildService';
import type {AvatarService} from '@app/api/infrastructure/AvatarService';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {DiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
import type {EmbedService} from '@app/api/infrastructure/EmbedService';
import type {IAssetDeletionQueue} from '@app/api/infrastructure/IAssetDeletionQueue';
@@ -70,7 +70,7 @@ const LANE_CONFIG = {
'indexChannelMessages',
'indexGuildMembers',
'processAssetDeletionQueue',
'processBunnyPurgeQueue',
'processCachePurgeQueue',
'processExpiredPremiumSweep',
'processInactivityDeletions',
'processPendingBulkMessageDeletions',
+3 -3
View File
@@ -54,8 +54,8 @@ const SEARCH_REQUIRED_TASKS = new Set<string>([
function registerCronJobs(cron: CronScheduler): void {
cron.upsert('processAssetDeletionQueue', 'processAssetDeletionQueue', {}, '0 */5 * * * *', {ledger: false});
if (Config.bunny.purgeEnabled) {
cron.upsert('processBunnyPurgeQueue', 'processBunnyPurgeQueue', {}, '*/10 * * * * *', {ledger: false});
if (Config.cachePurge.adapter !== 'none') {
cron.upsert('processCachePurgeQueue', 'processCachePurgeQueue', {}, '*/10 * * * * *', {ledger: false});
}
cron.upsert('processPendingBulkMessageDeletions', 'processPendingBulkMessageDeletions', {}, '0 */10 * * * *', {
ledger: false,
@@ -80,7 +80,7 @@ function registerCronJobs(cron: CronScheduler): void {
Logger.info(
{
blocklistFeeds: Config.blocklistFeeds.enabled,
bunnyPurge: Config.bunny.purgeEnabled,
cachePurgeAdapter: Config.cachePurge.adapter,
selfHosted: Config.instance.selfHosted,
},
'Cron jobs registered successfully',
@@ -25,7 +25,7 @@ import indexChannelMessages from '@app/api/worker/tasks/IndexChannelMessages';
import indexGuildMembers from '@app/api/worker/tasks/IndexGuildMembers';
import messageShred from '@app/api/worker/tasks/MessageShred';
import processAssetDeletionQueue from '@app/api/worker/tasks/ProcessAssetDeletionQueue';
import processBunnyPurgeQueue from '@app/api/worker/tasks/ProcessBunnyPurgeQueue';
import processCachePurgeQueue from '@app/api/worker/tasks/ProcessCachePurgeQueue';
import processExpiredPremiumSweep from '@app/api/worker/tasks/ProcessExpiredPremiumSweep';
import processInactivityDeletions from '@app/api/worker/tasks/ProcessInactivityDeletions';
import processPendingBulkMessageDeletions from '@app/api/worker/tasks/ProcessPendingBulkMessageDeletions';
@@ -69,7 +69,7 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
indexGuildMembers,
messageShred,
processAssetDeletionQueue,
processBunnyPurgeQueue,
processCachePurgeQueue,
processStripeWebhook,
processExpiredPremiumSweep,
processInactivityDeletions,
@@ -3,7 +3,7 @@
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
import {Config} from '@app/api/Config';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import type {IPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {
IAssetDeletionQueue,
QueuedAssetDeletion,
@@ -1,152 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {BunnyPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import {Logger} from '@app/api/Logger';
import {EXTERNAL_RESPONSE_LIMITS} from '@app/api/utils/ExternalResponseLimits';
import * as FetchUtils from '@app/api/utils/FetchUtils';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import {formatUrlForDiagnostics} from '@pkgs/http_client/src/HttpClientDiagnostics';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
import {ms} from 'itty-time';
const EXACT_BATCH_SIZE = 120;
const PREFIX_BATCH_SIZE = 20;
type PurgeLabel = 'exact' | 'prefix';
interface PurgeStats {
attempted: number;
purged: number;
requeued: number;
failures: number;
rateLimited: number;
}
function createEmptyPurgeStats(): PurgeStats {
return {
attempted: 0,
purged: 0,
requeued: 0,
failures: 0,
rateLimited: 0,
};
}
function mergePurgeStats(target: PurgeStats, source: PurgeStats): void {
target.attempted += source.attempted;
target.purged += source.purged;
target.requeued += source.requeued;
target.failures += source.failures;
target.rateLimited += source.rateLimited;
}
async function purgeUrls(
urls: Array<string>,
apiKey: string,
queue: BunnyPurgeQueue,
label: PurgeLabel,
): Promise<PurgeStats> {
const stats = createEmptyPurgeStats();
for (let index = 0; index < urls.length; index++) {
const url = urls[index]!;
stats.attempted++;
try {
const response = await fetch(`https://api.bunny.net/purge?url=${encodeURIComponent(url)}&async=true`, {
method: 'POST',
headers: {
AccessKey: apiKey,
},
signal: AbortSignal.timeout(ms('30 seconds')),
});
if (response.status === 429) {
FetchUtils.discardResponseBody(response.body, response.status);
const remaining = urls.slice(index);
await queue.addUrls(remaining);
stats.requeued += remaining.length;
stats.rateLimited += remaining.length;
Logger.warn({label, rateLimitedUrls: remaining.length}, 'Rate limited by Bunny CDN, re-queued remaining URLs');
break;
}
if (!response.ok) {
stats.failures++;
const errorBody = await FetchUtils.streamToBufferWithLimit(response.body, {
maxBytes: EXTERNAL_RESPONSE_LIMITS.bunnyErrorBytes,
headers: response.headers,
url: response.url,
description: 'Bunny CDN purge response',
});
Logger.error(
{
status: response.status,
responseBodyBytes: errorBody.byteLength,
url: formatUrlForDiagnostics(url),
label,
},
'Failed to purge URL via Bunny CDN',
);
await queue.addUrls([url]);
stats.requeued++;
continue;
}
FetchUtils.discardResponseBody(response.body, response.status);
stats.purged++;
} catch (error) {
stats.failures++;
Logger.error({error, url: formatUrlForDiagnostics(url), label}, 'Error purging URL via Bunny CDN');
await queue.addUrls([url]);
stats.requeued++;
}
}
return stats;
}
const processBunnyPurgeQueue: WorkerTaskHandler = async (_payload, _helpers) => {
if (!Config.bunny.purgeEnabled) {
Logger.debug('Bunny CDN cache purge is disabled, skipping queue processing');
return;
}
if (!Config.bunny.apiKey || !Config.bunny.pullZoneId) {
Logger.error('Bunny CDN cache purge is enabled but credentials are missing');
return;
}
const deps = getWorkerDependencies();
const queue = deps.purgeQueue as BunnyPurgeQueue;
const apiKey = Config.bunny.apiKey;
const totalStats = createEmptyPurgeStats();
try {
const queueSizeBefore = await queue.getQueueSize();
if (queueSizeBefore === 0) {
Logger.debug('CDN purge queue is empty');
return;
}
Logger.debug({queueSize: queueSizeBefore}, 'Processing CDN purge queue');
const exactBatch = await queue.dequeueExactBatch(EXACT_BATCH_SIZE);
if (exactBatch.urls.length > 0) {
const exactStats = await purgeUrls(exactBatch.urls, apiKey, queue, 'exact');
mergePurgeStats(totalStats, exactStats);
}
const prefixBatch = await queue.dequeuePrefixBatch(PREFIX_BATCH_SIZE);
if (prefixBatch.urls.length > 0) {
const prefixStats = await purgeUrls(prefixBatch.urls, apiKey, queue, 'prefix');
mergePurgeStats(totalStats, prefixStats);
}
const remainingQueueSize = await queue.getQueueSize();
Logger.debug(
{
totalAttempted: totalStats.attempted,
totalPurged: totalStats.purged,
totalRequeued: totalStats.requeued,
totalFailures: totalStats.failures,
totalRateLimited: totalStats.rateLimited,
remainingQueueSize,
},
'Finished processing CDN purge queue',
);
} catch (error) {
Logger.error({error}, 'Error processing CDN purge queue');
throw error;
}
};
export default processBunnyPurgeQueue;
@@ -0,0 +1,104 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {
type CachePurgeBatch,
type CachePurgeOutcome,
createCachePurgeAdapter,
} from '@app/api/infrastructure/CachePurgeAdapter';
import {CachePurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import {Logger} from '@app/api/Logger';
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
import type {CachePurgeAdapterName} from '@fluxer/config/src/MasterConfig';
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
const RUN_DEADLINE_MS = 10_000;
type CachePurgeFailure = Extract<CachePurgeOutcome, {kind: 'failed'}>;
function describeBatch(adapter: CachePurgeAdapterName, batch: CachePurgeBatch) {
return {adapter, exact: batch.exact.length, prefix: batch.prefix.length};
}
function logFailure(adapter: CachePurgeAdapterName, requeued: CachePurgeBatch, failure: CachePurgeFailure): void {
const {status, error} = failure;
const context = {...describeBatch(adapter, requeued), status, error};
if (status === null || status === 408 || status === 429 || status >= 500) {
Logger.warn(context, 'Cache purge request failed, requeued its entries');
return;
}
Logger.error(context, 'Cache purge endpoint refused the request, requeued its entries');
}
function untriedFrom(singles: ReadonlyArray<CachePurgeBatch>, index: number): CachePurgeBatch {
const untried = singles.slice(index);
return {exact: untried.flatMap((single) => single.exact), prefix: untried.flatMap((single) => single.prefix)};
}
const processCachePurgeQueue: WorkerTaskHandler = async (_payload, _helpers) => {
const adapterName = Config.cachePurge.adapter;
if (adapterName === 'none') {
Logger.warn('Skipped a cache purge run because the adapter is none');
return;
}
const adapter = createCachePurgeAdapter(Config.cachePurge);
const queue = new CachePurgeQueue(getWorkerDependencies().kvClient);
const startedAt = Date.now();
const batch = await queue.dequeueBatch();
if (batch.exact.length === 0 && batch.prefix.length === 0) {
return;
}
let outcome: CachePurgeOutcome;
try {
outcome = await adapter.purge(batch);
} catch (error) {
await queue.requeue(batch);
throw error;
}
if (outcome.kind === 'purged') {
Logger.debug(describeBatch(adapterName, batch), 'Purged a batch of cache entries');
return;
}
if (outcome.kind === 'failed') {
await queue.requeue(batch);
logFailure(adapterName, batch, outcome);
return;
}
Logger.warn(
{...describeBatch(adapterName, batch), status: outcome.status},
'Cache purge endpoint rejected the batch, retrying each entry on its own',
);
const singles: Array<CachePurgeBatch> = [
...batch.exact.map((entry) => ({exact: [entry], prefix: []})),
...batch.prefix.map((entry) => ({exact: [], prefix: [entry]})),
];
for (const [index, single] of singles.entries()) {
if (Date.now() - startedAt >= RUN_DEADLINE_MS) {
const untried = untriedFrom(singles, index);
await queue.requeue(untried);
Logger.warn(
describeBatch(adapterName, untried),
'Cache purge run reached its deadline, requeued untried entries',
);
return;
}
let singleOutcome: CachePurgeOutcome;
try {
singleOutcome = await adapter.purge(single);
if (singleOutcome.kind === 'invalid_entries') {
await queue.reject(single);
}
} catch (error) {
await queue.requeue(untriedFrom(singles, index));
throw error;
}
if (singleOutcome.kind === 'failed') {
const untried = untriedFrom(singles, index);
await queue.requeue(untried);
logFailure(adapterName, untried, singleOutcome);
return;
}
}
};
export default processCachePurgeQueue;
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AssetDeletionQueue} from '@app/api/infrastructure/AssetDeletionQueue';
import {NoopPurgeQueue} from '@app/api/infrastructure/BunnyPurgeQueue';
import {NoopPurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
@@ -0,0 +1,315 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import type {APICachePurgeConfig} from '@app/api/config/APIConfig';
import {CachePurgeQueue} from '@app/api/infrastructure/CachePurgeQueue';
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
import {server} from '@app/api/test/msw/server';
import processCachePurgeQueue from '@app/api/worker/tasks/ProcessCachePurgeQueue';
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {delay, HttpResponse, http} from 'msw';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
const ENDPOINT = 'https://cache-purge.test/purge';
const MEDIA = 'https://media.test';
const EXACT_KEY = 'cache_purge:exact';
const PREFIX_KEY = 'cache_purge:prefix';
const REJECTED_KEY = 'cache_purge:rejected';
interface PurgeBody {
exact: Array<string>;
prefix: Array<string>;
}
interface RecordedRequest {
authorization: string | null;
contentType: string | null;
body: PurgeBody;
}
function createHarness() {
const kvClient = new MockKVProvider();
setWorkerDependenciesForTest({kvClient});
return {kvClient, queue: new CachePurgeQueue(kvClient)};
}
function recordPurges(respond: (body: PurgeBody) => Response | Promise<Response>): Array<RecordedRequest> {
const requests: Array<RecordedRequest> = [];
server.use(
http.post(ENDPOINT, async ({request}) => {
const body = (await request.json()) as PurgeBody;
requests.push({
authorization: request.headers.get('authorization'),
contentType: request.headers.get('content-type'),
body,
});
return respond(body);
}),
);
return requests;
}
function entryCount(body: PurgeBody): number {
return body.exact.length + body.prefix.length;
}
async function members(kvClient: MockKVProvider, key: string): Promise<Array<string>> {
return (await kvClient.smembers(key)).sort();
}
function sorted(values: Array<string>): Array<string> {
return [...values].sort();
}
describe('processCachePurgeQueue', () => {
let previousCachePurge: APICachePurgeConfig;
beforeEach(() => {
previousCachePurge = {adapter: Config.cachePurge.adapter, http: Config.cachePurge.http};
Config.cachePurge.adapter = 'http';
Config.cachePurge.http = {endpoint: ENDPOINT, token: 'test-token', timeoutMs: 50};
});
afterEach(() => {
Config.cachePurge.adapter = previousCachePurge.adapter;
Config.cachePurge.http = previousCachePurge.http;
clearWorkerDependencies();
vi.useRealTimers();
});
it('sends queued exact and prefix entries in one request and drains the queue', async () => {
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/a_abc`, `${MEDIA}/emojis/9.webp`, `${MEDIA}/attachments/1/2/ação.png`];
const prefix = [`${MEDIA}/some/dir/`];
await queue.addUrls([...exact, ...prefix]);
const requests = recordPurges(() => new HttpResponse(null, {status: 204}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(requests[0]!.authorization).toBe('Bearer test-token');
expect(requests[0]!.contentType).toBe('application/json');
expect(sorted(requests[0]!.body.exact)).toEqual(sorted(exact));
expect(requests[0]!.body.prefix).toEqual(prefix);
expect(await members(kvClient, EXACT_KEY)).toEqual([]);
expect(await members(kvClient, PREFIX_KEY)).toEqual([]);
});
it('strips a trailing asterisk from a prefix entry and keeps a trailing slash', async () => {
const {kvClient, queue} = createHarness();
await queue.addUrls([`${MEDIA}/stickers/1**`, `${MEDIA}/emojis/`]);
const requests = recordPurges(() => new HttpResponse(null, {status: 204}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(requests[0]!.body.exact).toEqual([]);
expect(sorted(requests[0]!.body.prefix)).toEqual([`${MEDIA}/emojis/`, `${MEDIA}/stickers/1*`]);
expect(await members(kvClient, PREFIX_KEY)).toEqual([]);
});
it('sends no authorization header when no token is configured', async () => {
Config.cachePurge.http.token = '';
const {queue} = createHarness();
await queue.addUrls([`${MEDIA}/avatars/1/abc`]);
const requests = recordPurges(() => new HttpResponse(null, {status: 204}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(requests[0]!.authorization).toBeNull();
});
it('requeues the whole batch after a server error', async () => {
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/abc`, `${MEDIA}/banners/1/def`];
const prefix = [`${MEDIA}/some/dir/`];
await queue.addUrls([...exact, ...prefix]);
const requests = recordPurges(() => new HttpResponse(null, {status: 503}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted(exact));
expect(await members(kvClient, PREFIX_KEY)).toEqual(prefix);
});
it('requeues the batch when the endpoint does not answer in time', async () => {
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/abc`, `${MEDIA}/banners/1/def`];
await queue.addUrls(exact);
const requests = recordPurges(async () => {
await delay('infinite');
return new HttpResponse(null, {status: 204});
});
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted(exact));
});
it('requeues the batch when the endpoint is unreachable', async () => {
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/abc`, `${MEDIA}/banners/1/def`];
await queue.addUrls(exact);
const requests = recordPurges(() => HttpResponse.error());
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted(exact));
});
it('requeues the batch on a redirect without following it', async () => {
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/abc`, `${MEDIA}/banners/1/def`];
await queue.addUrls(exact);
const redirectTarget = 'https://cache-purge.test/elsewhere';
const redirectedRequests: Array<string> = [];
server.use(
http.all(redirectTarget, ({request}) => {
redirectedRequests.push(request.method);
return new HttpResponse(null, {status: 204});
}),
);
const requests = recordPurges(() => new HttpResponse(null, {status: 302, headers: {Location: redirectTarget}}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(redirectedRequests).toEqual([]);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted(exact));
});
it('requeues the batch on an authorisation failure without splitting it', async () => {
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/abc`, `${MEDIA}/banners/1/def`, `${MEDIA}/emojis/9.webp`];
await queue.addUrls(exact);
const requests = recordPurges(() => new HttpResponse(null, {status: 401}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toHaveLength(1);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted(exact));
expect(await members(kvClient, REJECTED_KEY)).toEqual([]);
});
it('sets aside only the entry the endpoint rejects on its own', async () => {
const {kvClient, queue} = createHarness();
const poison = `${MEDIA}/attachments/1/2/poison.png`;
await queue.addUrls([`${MEDIA}/avatars/1/abc`, poison, `${MEDIA}/emojis/9.webp`]);
const requests = recordPurges((body) =>
body.exact.includes(poison) ? new HttpResponse(null, {status: 422}) : new HttpResponse(null, {status: 204}),
);
await processCachePurgeQueue({}, HELPERS);
expect(requests.map((request) => entryCount(request.body))).toEqual([3, 1, 1, 1]);
expect(await members(kvClient, REJECTED_KEY)).toEqual([poison]);
expect(await members(kvClient, EXACT_KEY)).toEqual([]);
});
it('splits the batch when the endpoint answers 400', async () => {
const {kvClient, queue} = createHarness();
const poison = `${MEDIA}/attachments/1/2/poison.png`;
await queue.addUrls([`${MEDIA}/avatars/1/abc`, poison, `${MEDIA}/emojis/9.webp`]);
const requests = recordPurges((body) =>
body.exact.includes(poison) ? new HttpResponse(null, {status: 400}) : new HttpResponse(null, {status: 204}),
);
await processCachePurgeQueue({}, HELPERS);
expect(requests.map((request) => entryCount(request.body))).toEqual([3, 1, 1, 1]);
expect(await members(kvClient, REJECTED_KEY)).toEqual([poison]);
expect(await members(kvClient, EXACT_KEY)).toEqual([]);
});
it('keeps untried exact and prefix entries queued when a single-entry retry fails', async () => {
const {kvClient, queue} = createHarness();
const first = `${MEDIA}/avatars/1/abc`;
const second = `${MEDIA}/banners/1/def`;
const third = `${MEDIA}/emojis/9.webp`;
const directory = `${MEDIA}/some/dir/`;
await queue.addUrls([first, second, third, directory]);
let singleRequests = 0;
const requests = recordPurges((body) => {
if (entryCount(body) > 1) {
return new HttpResponse(null, {status: 422});
}
singleRequests++;
return new HttpResponse(null, {status: singleRequests === 1 ? 204 : 503});
});
await processCachePurgeQueue({}, HELPERS);
expect(requests.map((request) => request.body.exact)).toEqual([[first, second, third], [first], [second]]);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted([second, third]));
expect(await members(kvClient, PREFIX_KEY)).toEqual([directory]);
expect(await members(kvClient, REJECTED_KEY)).toEqual([]);
});
it('requeues untried entries when the fallback runs out of time', async () => {
vi.useFakeTimers({toFake: ['Date']});
vi.setSystemTime(new Date('2026-09-13T00:00:00.000Z'));
const {kvClient, queue} = createHarness();
const entries = [1, 2, 3, 4, 5].map((index) => `${MEDIA}/avatars/${index}/abc`);
await queue.addUrls(entries);
const requests = recordPurges((body) => {
if (entryCount(body) > 1) {
return new HttpResponse(null, {status: 422});
}
vi.setSystemTime(Date.now() + 4_000);
return new HttpResponse(null, {status: 204});
});
await processCachePurgeQueue({}, HELPERS);
expect(requests.map((request) => request.body.exact)).toEqual([entries, [entries[0]], [entries[1]], [entries[2]]]);
expect(await members(kvClient, EXACT_KEY)).toEqual(sorted([entries[3]!, entries[4]!]));
expect(await members(kvClient, REJECTED_KEY)).toEqual([]);
});
it('leaves the queue untouched when the adapter is none', async () => {
Config.cachePurge.adapter = 'none';
const {kvClient, queue} = createHarness();
const exact = [`${MEDIA}/avatars/1/abc`];
const prefix = [`${MEDIA}/some/dir/`];
await queue.addUrls([...exact, ...prefix]);
const requests = recordPurges(() => new HttpResponse(null, {status: 204}));
await processCachePurgeQueue({}, HELPERS);
expect(requests).toEqual([]);
expect(await members(kvClient, EXACT_KEY)).toEqual(exact);
expect(await members(kvClient, PREFIX_KEY)).toEqual(prefix);
expect(await kvClient.get('cache_purge:budget:exact')).toBeNull();
expect(await kvClient.get('cache_purge:budget:prefix')).toBeNull();
});
it('sends no more than the token bucket allows and refills at the configured rate', async () => {
vi.useFakeTimers({toFake: ['Date']});
vi.setSystemTime(new Date('2026-09-13T00:00:00.000Z'));
const {kvClient, queue} = createHarness();
await queue.addUrls([
...Array.from({length: 200}, (_, index) => `${MEDIA}/avatars/${index}/abc`),
...Array.from({length: 30}, (_, index) => `${MEDIA}/dirs/${index}/`),
]);
const requests = recordPurges(() => new HttpResponse(null, {status: 204}));
await processCachePurgeQueue({}, HELPERS);
await processCachePurgeQueue({}, HELPERS);
vi.setSystemTime(Date.now() + 10_000);
await processCachePurgeQueue({}, HELPERS);
expect(requests.map((request) => [request.body.exact.length, request.body.prefix.length])).toEqual([
[120, 20],
[50, 5],
]);
expect(await kvClient.scard(EXACT_KEY)).toBe(30);
expect(await kvClient.scard(PREFIX_KEY)).toBe(5);
});
});