Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,13 @@ export function createCache(cacheTtlMs: number, cacheWindowMs: number): Cache {
hits++;
}

// If `after` points to a message older than the cache window, return []
// Clamp: if `after` is older than the cache window, use the window
// start so we return all cached messages instead of an empty result
const afterTs = snowflakeToTimestamp(after);
const windowStart = Date.now() - cacheWindowMs;
if (afterTs < windowStart) {
return { data: [], cachedAt: entry.cachedAt };
}

// Filter to only messages with snowflake ID > after
const afterBigInt = BigInt(after);
const afterBigInt = afterTs < windowStart
? ((BigInt(windowStart) - DISCORD_EPOCH) << 22n) - 1n
: BigInt(after);
let filtered = entry.data.filter((msg) => BigInt(msg.id) > afterBigInt);

// Apply limit (return newest N, matching Discord API behavior)
Expand Down
6 changes: 4 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createDiscordClient } from "./discord";
import type { DiscordClient, DiscordMessage } from "./discord";
import { createCache } from "./cache";
import type { Cache } from "./cache";
import { initialPoll, startPollingLoop, createLogger } from "./poller";
import { initialPoll, startPollingLoop, createLogger, createChannelHealth } from "./poller";

const VERSION = "0.2.0";
const startTime = Date.now();
Expand Down Expand Up @@ -159,6 +159,7 @@ if (import.meta.main) {
const client = createDiscordClient(config.discordBotToken);
const cache = createCache(config.cacheTtlMs, config.cacheWindowMs);
const logger = createLogger(config.logLevel);
const health = createChannelHealth(config.pollIntervalMs);

logger.info(`scream-hole v${VERSION} starting...`);

Expand All @@ -168,6 +169,7 @@ if (import.meta.main) {
cache,
config.discordGuildId,
logger,
health,
);

const handler = createHandler(cache, config.discordGuildId, config.cacheTtlMs, client);
Expand All @@ -178,7 +180,7 @@ if (import.meta.main) {
});

// Start the continuous polling loop
const poller = startPollingLoop(client, cache, config);
const poller = startPollingLoop(client, cache, config, health);

const intervalSec = (config.pollIntervalMs / 1000).toFixed(1);
logger.info(
Expand Down
59 changes: 57 additions & 2 deletions poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const GUILD_TEXT_CHANNEL = 0;

const PER_CHANNEL_TIMEOUT_MS = 10_000;
const INITIAL_POLL_TIMEOUT_MS = 30_000;
const MAX_BACKOFF_EXPONENT = 4; // max multiplier: 2^4 = 16× poll interval

export interface Logger {
debug(msg: string): void;
Expand Down Expand Up @@ -80,6 +81,7 @@ async function pollCycle(
guildId: string,
logger: Logger,
perChannelTimeout: number,
health: ChannelHealth,
): Promise<number> {
// Fetch channels
const channels = await withTimeout(
Expand All @@ -97,21 +99,31 @@ async function pollCycle(

// Fetch messages for each text channel
for (const channel of textChannels) {
if (health.shouldSkip(channel.id)) {
logger.debug(`Skipping #${channel.name ?? channel.id} (in backoff)`);
continue;
}
try {
const messages = await withTimeout(
client.fetchMessages(channel.id),
perChannelTimeout,
`fetchMessages(${channel.id})`,
);
cache.setMessages(channel.id, messages);
if (health.recordSuccess(channel.id)) {
logger.info(`Channel #${channel.name ?? channel.id} recovered`);
}
logger.debug(
`Cached ${messages.length} messages for #${channel.name ?? channel.id}`,
);
} catch (err) {
const { failures, backoffMs } = health.recordFailure(channel.id);
logger.error(
`Failed to fetch messages for channel ${channel.id}: ${err instanceof Error ? err.message : String(err)}`,
);
// Continue to next channel — don't crash
logger.warn(
`Channel #${channel.name ?? channel.id} failed ${failures}x consecutively, backing off ${Math.round(backoffMs / 1000)}s`,
);
}
}

Expand All @@ -130,10 +142,11 @@ export async function initialPoll(
cache: Cache,
guildId: string,
logger: Logger,
health: ChannelHealth,
): Promise<number> {
try {
const channelCount = await withTimeout(
pollCycle(client, cache, guildId, logger, PER_CHANNEL_TIMEOUT_MS),
pollCycle(client, cache, guildId, logger, PER_CHANNEL_TIMEOUT_MS, health),
INITIAL_POLL_TIMEOUT_MS,
"initial poll",
);
Expand All @@ -155,6 +168,7 @@ export function startPollingLoop(
client: DiscordClient,
cache: Cache,
config: Config,
health: ChannelHealth,
): { stop: () => void; logger: Logger } {
const logger = createLogger(config.logLevel);
let timer: ReturnType<typeof setInterval> | null = null;
Expand All @@ -167,6 +181,7 @@ export function startPollingLoop(
config.discordGuildId,
logger,
PER_CHANNEL_TIMEOUT_MS,
health,
);
logger.debug("Poll cycle complete");
} catch (err) {
Expand All @@ -187,4 +202,44 @@ export function startPollingLoop(
};
}

export interface ChannelHealth {
/** Returns true if the channel is in backoff and should be skipped this cycle. */
shouldSkip(channelId: string): boolean;
/** Record a successful fetch. Resets backoff. Returns true if the channel was previously failing. */
recordSuccess(channelId: string): boolean;
/** Record a failed fetch. Returns consecutive failure count and backoff duration. */
recordFailure(channelId: string): { failures: number; backoffMs: number };
}

export function createChannelHealth(pollIntervalMs: number): ChannelHealth {
const entries = new Map<string, { failures: number; backoffUntil: number }>();

return {
shouldSkip(channelId: string): boolean {
const entry = entries.get(channelId);
if (!entry) return false;
return Date.now() < entry.backoffUntil;
},

recordSuccess(channelId: string): boolean {
const entry = entries.get(channelId);
const wasInBackoff = !!entry && Date.now() < entry.backoffUntil;
entries.delete(channelId);
return wasInBackoff;
},

recordFailure(channelId: string): { failures: number; backoffMs: number } {
const existing = entries.get(channelId);
const failures = existing ? existing.failures + 1 : 1;
const exponent = Math.min(failures - 1, MAX_BACKOFF_EXPONENT);
const backoffMs = pollIntervalMs * Math.pow(2, exponent);
entries.set(channelId, {
failures,
backoffUntil: Date.now() + backoffMs,
});
return { failures, backoffMs };
},
};
}

export { createLogger, withTimeout };
23 changes: 21 additions & 2 deletions tests/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,37 @@ describe("cache messages", () => {
expect(cache.getMessages("unknown-ch", after)).toBeUndefined();
});

test("returns empty array when `after` is older than cache window", () => {
test("clamps `after` to window start when older than cache window", () => {
const cache = createCache(60_000, FOUR_HOURS);
const now = Date.now();

const recentId = timestampToSnowflake(now - 60_000);
cache.setMessages("ch-1", [makeMessage(recentId, "ch-1", "recent")]);

// `after` is 5 hours ago — outside the 4-hour window
// Should clamp to window start and return all messages within the window
const oldId = timestampToSnowflake(now - 5 * 60 * 60 * 1000);
const result = cache.getMessages("ch-1", oldId);
expect(result).toBeDefined();
expect(result!.data).toEqual([]);
expect(result!.data).toHaveLength(1);
expect(result!.data[0].content).toBe("recent");
});

test("returns all cached messages when after=0", () => {
const cache = createCache(60_000, FOUR_HOURS);
const now = Date.now();

const id1 = timestampToSnowflake(now - 60_000);
const id2 = timestampToSnowflake(now - 30_000);
cache.setMessages("ch-1", [
makeMessage(id1, "ch-1", "msg-1"),
makeMessage(id2, "ch-1", "msg-2"),
]);

// after=0 is the "give me everything" pattern — should return all
const result = cache.getMessages("ch-1", "0");
expect(result).toBeDefined();
expect(result!.data).toHaveLength(2);
});

test("evicts messages older than cache window", () => {
Expand Down
8 changes: 5 additions & 3 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => {
expect(body[1].content).toBe("new message");
});

test("returns empty array when `after` is older than cache window", async () => {
test("clamps `after` to window start when older than cache window", async () => {
const cache = createCache(TEST_TTL, TEST_WINDOW);
const now = Date.now();

Expand All @@ -193,6 +193,7 @@ describe("GET /api/v10/channels/{channelId}/messages", () => {
]);

// `after` pointing to 5 hours ago — outside 4-hour window
// Should clamp and return all messages within the window
const oldId = timestampToSnowflake(now - 5 * 60 * 60 * 1000);

const handler = createHandler(cache, "guild-1");
Expand All @@ -202,8 +203,9 @@ describe("GET /api/v10/channels/{channelId}/messages", () => {
const res = await handler(req);

expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual([]);
const body = (await res.json()) as { content: string }[];
expect(body).toHaveLength(1);
expect(body[0].content).toBe("recent");
});

test("returns 404 for unknown channel", async () => {
Expand Down
Loading
Loading