fix(fs): cap how many entry invalidations a node broadcasts per window

FSEntryStore now broadcasts at most 10,000 entries' cache invalidations
per broadcast flush window (outbound_flush_ms, 2 s by default). Past
that, keys are dropped locally only and peers fall back to the 60 s
entry-cache TTL; the store logs once per window when this happens.

Each flush goes to a peer as one POST that is re-sent whole on failure,
so a large recursive delete could otherwise build a batch the peer
rejects on every retry. The budget is per store instance, not per call,
so a delete issued page by page is bounded too, and single-entry writes
count against the same budget.
This commit is contained in:
Daniel Salazar committed 2026-10-11 12:18:10 -04:00
1 parent e1fe7c8d98
commit f09fe84b12
2 files changed
+178 -9

No files matched your search

+120 -1
View File
@@ -27,7 +27,7 @@ import type { IConfig } from '../../types';
import { decodeCursor, encodeCursor } from '../../util/pagination.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { FSEntry, FSEntryCreateInput } from './FSEntry.js';
import { FSEntryStore } from './FSEntryStore.js';
import { ENTRY_BROADCAST_CAP, FSEntryStore } from './FSEntryStore.js';
describe('FSEntryStore', () => {
it('quotes camelCase storage allowance aliases for Postgres', async () => {
@@ -447,6 +447,125 @@ describe('FSEntryStore cache invalidation', () => {
});
});
describe('FSEntryStore broadcast budget', () => {
// Its own store, so spending the budget can't starve the shared store's
// broadcasts that other tests assert on.
const budgetedStore = (outboundFlushMs?: number): FSEntryStore =>
new FSEntryStore(
{
...(configContainer as IConfig),
broadcast: { outbound_flush_ms: outboundFlushMs },
},
server.clients as ConstructorParameters<typeof FSEntryStore>[1],
server.stores as ConstructorParameters<typeof FSEntryStore>[2],
);
/** `count` files in a fresh directory, each cached under all its keys. */
const cachedFiles = async (count: number): Promise<FSEntry[]> => {
const user = await makeUser();
const dir = `${user.home}/bulk-${uuidv4()}`;
const entries = await store.batchCreateEntries(
Array.from({ length: count }, (_, i) => ({
userId: user.userId,
uuid: uuidv4(),
path: `${dir}/f${i}`,
size: 1,
bucket: 'puter-local',
bucketRegion: 'us-west-2',
})),
);
await store.getEntriesByIds(entries.map((entry) => entry.id));
return entries;
};
const cachedKeyCount = async (entries: FSEntry[]): Promise<number> => {
const values = await server.clients.redis.mget(
entries.flatMap(entryCacheKeys),
);
return values.filter((value) => value !== null).length;
};
const broadcastEntryCount = (updates: CacheUpdate[]): number =>
updates
.flatMap((update) => update.cacheKey)
.filter((key) => key.startsWith('prodfsv2:fsentry:uuid:')).length;
it('broadcasts at most the cap in one window and invalidates the rest locally', async () => {
const entries = await cachedFiles(ENTRY_BROADCAST_CAP + 50);
expect(await cachedKeyCount(entries)).toBe(entries.length * 3);
const budgeted = budgetedStore();
vi.useFakeTimers({ toFake: ['Date'] });
try {
const updates = await captureCacheUpdates(() =>
budgeted.deleteEntries(entries),
);
expect(broadcastEntryCount(updates)).toBe(ENTRY_BROADCAST_CAP);
} finally {
vi.useRealTimers();
}
expect(await cachedKeyCount(entries)).toBe(0);
});
it('caps a delete that arrives page by page, warning once', async () => {
const entries = await cachedFiles(ENTRY_BROADCAST_CAP + 50);
const budgeted = budgetedStore();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.useFakeTimers({ toFake: ['Date'] });
try {
const updates = await captureCacheUpdates(async () => {
for (let i = 0; i < entries.length; i += 150) {
await budgeted.deleteEntries(entries.slice(i, i + 150));
}
});
expect(broadcastEntryCount(updates)).toBe(ENTRY_BROADCAST_CAP);
const capWarnings = warn.mock.calls.filter(([message]) =>
String(message).startsWith('[FSEntryStore]'),
);
expect(capWarnings).toHaveLength(1);
} finally {
vi.useRealTimers();
warn.mockRestore();
}
expect(await cachedKeyCount(entries)).toBe(0);
});
it('starts a fresh budget once the configured flush window passes', async () => {
const entries = await cachedFiles(ENTRY_BROADCAST_CAP + 6);
const spend = entries.slice(0, ENTRY_BROADCAST_CAP);
const late = entries.slice(ENTRY_BROADCAST_CAP, -3);
const next = entries.slice(-3);
const budgeted = budgetedStore(5_000);
vi.useFakeTimers({ toFake: ['Date'] });
try {
const start = Date.now();
const spent = await captureCacheUpdates(() =>
budgeted.deleteEntries(spend),
);
expect(broadcastEntryCount(spent)).toBe(ENTRY_BROADCAST_CAP);
vi.setSystemTime(start + 4_999);
const capped = await captureCacheUpdates(() =>
budgeted.deleteEntries(late),
);
expect(broadcastEntryCount(capped)).toBe(0);
expect(await cachedKeyCount(late)).toBe(0);
vi.setSystemTime(start + 5_000);
const fresh = await captureCacheUpdates(() =>
budgeted.deleteEntries(next),
);
expect(fresh.flatMap((update) => update.cacheKey)).toEqual(
expect.arrayContaining(next.flatMap(entryCacheKeys)),
);
} finally {
vi.useRealTimers();
}
});
});
describe('FSEntryStore legacy NULL-path healing', () => {
it('resolves and backfills a path-less row looked up by path', async () => {
const user = await makeUser();
+58 -8
View File
@@ -103,6 +103,13 @@ const vacatedHomeKey = (username: string) =>
`prodfsv2:vacated-home:${username.toLowerCase()}`;
const BULK_QUERY_CHUNK_SIZE = 200;
const DEFAULT_DB_CHUNK_CONCURRENCY = 4;
// Most entries whose invalidations one node broadcasts per broadcast flush
// window. A flush reaches each peer as one POST (50 MB max, ~200 bytes an
// entry); past the cap, invalidations stay local and peers wait out
// ENTRY_CACHE_TTL_SECONDS.
export const ENTRY_BROADCAST_CAP = 10_000;
// BroadcastService's default `broadcast.outbound_flush_ms`.
const DEFAULT_BROADCAST_FLUSH_MS = 2_000;
/**
* Store backing the `fsentries` table. Owns DB CRUD over filesystem entries,
@@ -112,6 +119,10 @@ const DEFAULT_DB_CHUNK_CONCURRENCY = 4;
export class FSEntryStore extends PuterStore {
declare protected stores: LayerInstances<typeof puterStores>;
#broadcastWindowStart = 0;
#broadcastedInWindow = 0;
#broadcastCapLogged = false;
#insertIgnoreIntoFsentriesSql(): string {
return this.clients.db.insertIgnoreInto('fsentries');
}
@@ -353,12 +364,48 @@ export class FSEntryStore extends PuterStore {
}
/**
* Drops here and in peer regions, as one event for the whole batch. The
* read-path `#writeEntryToCache` stays local so backfills don't fan out.
* Drops here and, within the window's broadcast budget, in peer regions as
* one event for the batch. The read-path `#writeEntryToCache` stays local
* so backfills don't fan out.
*/
async #invalidateEntriesCache(entries: FSEntry[]): Promise<void> {
const keys = entries.flatMap((entry) => this.#entryCacheKeys(entry));
await this.publishCacheKeys({ keys, broadcast: true });
const keysOf = (batch: FSEntry[]) =>
batch.flatMap((entry) => this.#entryCacheKeys(entry));
const broadcastCount = this.#takeBroadcastBudget(entries.length);
await this.publishCacheKeys({
keys: keysOf(entries.slice(0, broadcastCount)),
broadcast: true,
});
await this.publishCacheKeys({
keys: keysOf(entries.slice(broadcastCount)),
});
}
/** How many of `count` entries may still broadcast this window. */
#takeBroadcastBudget(count: number): number {
const flushMs = Number(this.config.broadcast?.outbound_flush_ms);
const windowMs =
Number.isFinite(flushMs) && flushMs > 0
? flushMs
: DEFAULT_BROADCAST_FLUSH_MS;
const now = Date.now();
if (now - this.#broadcastWindowStart >= windowMs) {
this.#broadcastWindowStart = now;
this.#broadcastedInWindow = 0;
this.#broadcastCapLogged = false;
}
const allowed = Math.min(
count,
ENTRY_BROADCAST_CAP - this.#broadcastedInWindow,
);
this.#broadcastedInWindow += allowed;
if (allowed < count && !this.#broadcastCapLogged) {
this.#broadcastCapLogged = true;
console.warn(
`[FSEntryStore] broadcast cap (${ENTRY_BROADCAST_CAP} entries per ${windowMs} ms) reached; invalidating the rest locally only`,
);
}
return allowed;
}
async invalidateEntryCacheByPathForUser(
@@ -382,7 +429,10 @@ export class FSEntryStore extends PuterStore {
return;
}
await this.publishCacheKeys({ keys: cacheKeys, broadcast: true });
await this.publishCacheKeys({
keys: cacheKeys,
broadcast: this.#takeBroadcastBudget(1) > 0,
});
}
async invalidateEntryCacheByUuid(uuid: string): Promise<void> {
@@ -412,7 +462,7 @@ export class FSEntryStore extends PuterStore {
await this.publishCacheKeys({
keys: [`prodfsv2:fsentry:uuid:${uuid}`],
broadcast: true,
broadcast: this.#takeBroadcastBudget(1) > 0,
});
}
@@ -443,7 +493,7 @@ export class FSEntryStore extends PuterStore {
await this.publishCacheKeys({
keys: [`prodfsv2:fsentry:id:${id}`],
broadcast: true,
broadcast: this.#takeBroadcastBudget(1) > 0,
});
}
@@ -1306,7 +1356,7 @@ export class FSEntryStore extends PuterStore {
keys: this.#entryCacheKeys(entry),
serializedData: JSON.stringify(entry),
ttlSeconds: ENTRY_CACHE_TTL_SECONDS,
broadcast: true,
broadcast: this.#takeBroadcastBudget(1) > 0,
});
return entry;
}