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
109 changes: 109 additions & 0 deletions apps/api/src/app/cache/collection-cache.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { Test, type TestingModule } from '@nestjs/testing';
import { CollectionCacheService, CacheStatus } from './collection-cache.service';
import * as core from '@simoncodes-ca/core';
Expand All @@ -14,6 +17,10 @@ jest.mock('@simoncodes-ca/core', () => {

const mockCore = core as jest.Mocked<typeof core>;

// Revalidation is throttled in production; tests that are not about the throttle need every
// call to actually scan. Set before the service is constructed, since it reads this once.
process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '0';

describe('CollectionCacheService', () => {
let service: CollectionCacheService;

Expand Down Expand Up @@ -459,4 +466,106 @@ describe('CollectionCacheService', () => {
expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED);
});
});
describe('revalidate', () => {
let tempDir: string;

const writeEntries = (entries: Record<string, unknown>): void => {
const folderPath = path.join(tempDir, 'common');
fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(path.join(folderPath, 'resource_entries.json'), JSON.stringify(entries, null, 2), 'utf8');
fs.writeFileSync(path.join(folderPath, 'tracker_meta.json'), JSON.stringify({}, null, 2), 'utf8');
};

const indexTempCollection = async (): Promise<void> => {
const mockTree = createMockTree();
mockCore.loadResourceTree.mockReturnValue(mockTree);
mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources);
await service.indexCollection('Main', tempDir, 1);
};

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lingo-cache-revalidate-'));
writeEntries({ ok: { source: 'OK' } });
});

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
});

it('does nothing when no collection is cached', () => {
expect(service.revalidate('Main', tempDir)).toBe(false);
});

it('does nothing when the cached collection is a different one', async () => {
await indexTempCollection();

expect(service.revalidate('Admin', tempDir)).toBe(false);
expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY);
});

it('keeps the cache when nothing changed on disk', async () => {
await indexTempCollection();

expect(service.revalidate('Main', tempDir)).toBe(false);
expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY);
});

it('drops the cache when a resource file changed outside the process', async () => {
await indexTempCollection();

writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } });

expect(service.revalidate('Main', tempDir)).toBe(true);
expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED);
});

it('drops the cache when a resource folder is removed outside the process', async () => {
await indexTempCollection();

fs.rmSync(path.join(tempDir, 'common'), { recursive: true, force: true });

expect(service.revalidate('Main', tempDir)).toBe(true);
expect(service.getCacheStatus('Main')).toBe(CacheStatus.NOT_STARTED);
});

it('does not read its own write as an outside change', async () => {
await indexTempCollection();

// What an API mutation does: write to disk, then patch the cached tree.
writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } });
service.addResourceToCache('Main', createMockTree().resources[0], '');

expect(service.revalidate('Main', tempDir)).toBe(false);
expect(service.getCacheStatus('Main')).toBe(CacheStatus.READY);
});

it('detects an outside change made after its own write settled', async () => {
await indexTempCollection();

writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } });
service.addResourceToCache('Main', createMockTree().resources[0], '');
service.refreshFingerprint();

writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' }, later: { source: 'Later' } });

expect(service.revalidate('Main', tempDir)).toBe(true);
});

it('scans at most once per revalidation interval', async () => {
process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '60000';
const throttledService = new CollectionCacheService();
process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS = '0';

const mockTree = createMockTree();
mockCore.loadResourceTree.mockReturnValue(mockTree);
mockCore.extractResourcesRecursively.mockReturnValue(mockTree.resources);
await throttledService.indexCollection('Main', tempDir, 1);

writeEntries({ ok: { source: 'OK' }, maybe: { source: 'Maybe' } });

// Indexing takes a fingerprint, so the change is inside the interval that follows.
expect(throttledService.revalidate('Main', tempDir)).toBe(false);
expect(throttledService.getCacheStatus('Main')).toBe(CacheStatus.READY);
});
});
});
131 changes: 129 additions & 2 deletions apps/api/src/app/cache/collection-cache.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import type { FolderChild, ResourceTreeEntry, ResourceTreeNode, TreeFingerprint } from '@simoncodes-ca/core';
import * as core from '@simoncodes-ca/core';
import { extractResourcesRecursively } from '@simoncodes-ca/core';
import type { ResourceTreeNode, ResourceTreeEntry, FolderChild } from '@simoncodes-ca/core';
import { computeTreeFingerprint, extractResourcesRecursively, treeFingerprintsMatch } from '@simoncodes-ca/core';

/**
* How long a disk fingerprint is trusted before it is recomputed, in milliseconds.
*
* The scan is stat-only and costs a few milliseconds on a typical collection, but it runs
* on read paths, so it is throttled rather than run per request.
*/
const DEFAULT_REVALIDATION_INTERVAL_MS = 2000;

export enum CacheStatus {
NOT_STARTED = 'not-started',
Expand All @@ -18,12 +26,22 @@ export interface CachedCollection {
error?: string;
totalKeys: number;
localeCount: number;
/** Folder the tree was indexed from, needed to re-scan it later */
translationsFolder: string | null;
/** Disk state as of the last index or self-write, used to spot outside changes */
fingerprint: TreeFingerprint | null;
}

@Injectable()
export class CollectionCacheService {
readonly #logger = new Logger(CollectionCacheService.name);
#cachedCollection: CachedCollection | null = null;
#lastRevalidationAt = 0;
#pendingFingerprintRefresh: NodeJS.Timeout | null = null;

readonly #revalidationIntervalMs = Number(
process.env.LINGO_TRACKER_REVALIDATE_INTERVAL_MS ?? DEFAULT_REVALIDATION_INTERVAL_MS,
);

getCacheStatus(collectionName: string): CacheStatus {
if (!this.#cachedCollection || this.#cachedCollection.collectionName !== collectionName) {
Expand Down Expand Up @@ -92,6 +110,8 @@ export class CollectionCacheService {
error,
totalKeys: status === CacheStatus.READY && tree ? extractResourcesRecursively(tree).length : 0,
localeCount: status === CacheStatus.READY ? (localeCount ?? 0) : 0,
translationsFolder: null,
fingerprint: null,
};
} else {
this.#cachedCollection.status = status;
Expand All @@ -112,6 +132,8 @@ export class CollectionCacheService {
}

clearCache(): void {
this.#cancelPendingFingerprintRefresh();

if (this.#cachedCollection) {
this.#logger.log(`Clearing cache for collection: ${this.#cachedCollection.collectionName}`);
this.#cachedCollection = null;
Expand Down Expand Up @@ -155,6 +177,7 @@ export class CollectionCacheService {
const existingChild = parentNode.children.find((c) => c.name === folderName);
if (existingChild) {
this.#logger.log(`Folder "${folderName}" already exists in cache at path "${parentPath || 'root'}"`);
this.#scheduleFingerprintRefresh();
return true;
}

Expand All @@ -175,6 +198,7 @@ export class CollectionCacheService {
parentNode.children.sort((a, b) => a.name.localeCompare(b.name));

this.#logger.log(`Added folder "${folderName}" to cache at path "${parentPath || 'root'}"`);
this.#scheduleFingerprintRefresh();
return true;
}

Expand Down Expand Up @@ -227,6 +251,7 @@ export class CollectionCacheService {
this.#logger.log(`Added resource "${resourceEntry.key}" to cache at path "${folderPath || 'root'}"`);
}

this.#scheduleFingerprintRefresh();
return true;
}

Expand Down Expand Up @@ -279,6 +304,7 @@ export class CollectionCacheService {
}

this.#logger.log(`Removed folder "${folderPath}" from cache`);
this.#scheduleFingerprintRefresh();
return true;
}

Expand Down Expand Up @@ -329,6 +355,7 @@ export class CollectionCacheService {

this.#cachedCollection.totalKeys--;
this.#logger.log(`Removed resource "${resourceKey}" from cache at path "${folderPath || 'root'}"`);
this.#scheduleFingerprintRefresh();
return true;
}

Expand Down Expand Up @@ -418,9 +445,99 @@ export class CollectionCacheService {
destParent.children.sort((a, b) => a.name.localeCompare(b.name));

this.#logger.log(`Moved folder "${sourceFolderPath}" to "${destinationFolderPath || 'root'}" in cache`);
this.#scheduleFingerprintRefresh();
return true;
}

/**
* Drops the cache when the translations folder has changed underneath it.
*
* The app caches a collection's whole tree in memory, so a CLI command, a `git checkout`
* or a hand edit would otherwise stay invisible until a restart — a browser refresh does
* not help, because it re-reads the same cache. Filesystem watching cannot fix this
* portably: inotify never fires for Windows-side writes on a WSL `/mnt/c` mount, and the
* same holds for several network and container mounts. So the check happens on read,
* against a stat-only fingerprint, throttled so it costs almost nothing.
*
* @param collectionName - The collection being read
* @param translationsFolder - The collection's translations folder
* @param cwd - Directory `translationsFolder` is resolved against
* @returns true when the cache was dropped and needs re-indexing
*/
revalidate(collectionName: string, translationsFolder: string, cwd?: string): boolean {
const cached = this.#cachedCollection;

if (!cached || cached.collectionName !== collectionName || cached.status !== CacheStatus.READY) {
return false;
}

const now = Date.now();
if (now - this.#lastRevalidationAt < this.#revalidationIntervalMs) {
return false;
}
this.#lastRevalidationAt = now;

const fingerprint = computeTreeFingerprint({ translationsFolder, cwd });

// A write of our own is still waiting for its deferred baseline refresh. Adopt the
// fingerprint now instead of reading our own change as somebody else's.
if (this.#pendingFingerprintRefresh !== null) {
this.#cancelPendingFingerprintRefresh();
cached.fingerprint = fingerprint;
return false;
}

if (treeFingerprintsMatch(cached.fingerprint, fingerprint)) {
return false;
}

this.#logger.log(`Translations folder changed on disk for collection ${collectionName}, dropping cache`);
this.clearCache();
return true;
}

/**
* Re-takes the disk fingerprint so the cache's own writes do not later read as external
* changes. Safe to call when no collection is cached.
*/
refreshFingerprint(): void {
this.#cancelPendingFingerprintRefresh();

const cached = this.#cachedCollection;
if (!cached?.translationsFolder) {
return;
}

cached.fingerprint = computeTreeFingerprint({ translationsFolder: cached.translationsFolder });
}

/**
* Queues a fingerprint refresh for the end of the current tick.
*
* Bulk endpoints mutate the cache once per resource in a synchronous loop, so deferring
* collapses a whole batch into a single scan.
*/
#scheduleFingerprintRefresh(): void {
if (this.#pendingFingerprintRefresh !== null || !this.#cachedCollection?.translationsFolder) {
return;
}

this.#pendingFingerprintRefresh = setTimeout(() => {
this.#pendingFingerprintRefresh = null;
this.refreshFingerprint();
}, 0);

// A pending refresh must never hold the process open on its own.
this.#pendingFingerprintRefresh.unref?.();
}

#cancelPendingFingerprintRefresh(): void {
if (this.#pendingFingerprintRefresh !== null) {
clearTimeout(this.#pendingFingerprintRefresh);
this.#pendingFingerprintRefresh = null;
}
}

async indexCollection(collectionName: string, translationsFolder: string, localeCount?: number): Promise<void> {
const currentStatus = this.getCacheStatus(collectionName);

Expand All @@ -436,6 +553,10 @@ export class CollectionCacheService {
this.#logger.log(`Starting indexing for collection: ${collectionName}`);

try {
// Taken before the load: a write that lands mid-load then disagrees with this
// fingerprint, which costs one extra re-index but never loses the change.
const fingerprint = computeTreeFingerprint({ translationsFolder });

const tree = core.loadResourceTree({
translationsFolder,
path: '',
Expand All @@ -456,6 +577,12 @@ export class CollectionCacheService {
}

this.setCacheStatus(indexingCollectionName, CacheStatus.READY, tree, undefined, localeCount);

if (this.#cachedCollection) {
this.#cachedCollection.translationsFolder = translationsFolder;
this.#cachedCollection.fingerprint = fingerprint;
}
this.#lastRevalidationAt = Date.now();
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('ResourcesController', () => {
getCacheStats: jest.fn(),
indexCollection: jest.fn(),
clearCache: jest.fn(),
revalidate: jest.fn().mockReturnValue(false),
addResourceToCache: jest.fn().mockReturnValue(true),
removeResourceFromCache: jest.fn().mockReturnValue(true),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,10 @@ export class ResourcesController {
const collection = config.collections[decodedCollectionName];
const translationsFolder = collection.translationsFolder;

// Pick up changes made outside this process (CLI commands, git checkouts, hand edits)
// before trusting the cache.
this.#cacheService.revalidate(decodedCollectionName, translationsFolder);

// Check cache status
const cacheStatus = this.#cacheService.getCacheStatus(decodedCollectionName);

Expand Down Expand Up @@ -631,10 +635,12 @@ export class ResourcesController {
throw new NotFoundException(`Collection "${decodedCollectionName}" not found`);
}

const collection = config.collections[decodedCollectionName];
this.#cacheService.revalidate(decodedCollectionName, collection.translationsFolder);

const cacheStatus = this.#cacheService.getCacheStatus(decodedCollectionName);

// If cache is not started, trigger indexing asynchronously
const collection = config.collections[decodedCollectionName];
if (cacheStatus === CacheStatus.NOT_STARTED) {
const translationsFolder = collection.translationsFolder;
const locales = collection.locales ?? config.locales ?? [];
Expand Down Expand Up @@ -717,6 +723,7 @@ export class ResourcesController {
const maxResults = Math.min(dto.maxResults || 100, 500);

// Try to use cached tree for faster search
this.#cacheService.revalidate(decodedCollectionName, translationsFolder);
const cachedTree = this.#cacheService.getCache(decodedCollectionName);
let searchResults: SearchResult[];

Expand Down
Loading