6b27ff0652
Аспект A — идентичность прогона реиндекса эмбеддингов. У статуса/поллинга реиндекса не было идентичности КОНКРЕТНОГО прогона, из-за чего класс багов «это тот же прогон или новый?» чинили дважды. Теперь каждый прогон получает свой runId (crypto.randomUUID в start()), он хранится в Redis- хэше рядом с total/done/startedAt и возвращается в ReindexProgress. Эндпоинт статуса (getMasked -> MaskedAiSettings) отдаёт runId и reindexStartedAt. Клиент кеит поллинг на (runId, startedAt): смена runId = НОВЫЙ прогон (сбрасываем залатанное per-run состояние поллинга), тот же runId = тот же прогон. Всё best-effort/косметика как и остальной код прогресса: пустой/отсутствующий runId деградирует мягко и никогда не ломает реиндекс. Аспект B — брендированные PageId/SlugId в MCP-клиенте + валидация формата в серверных DTO (семейство инцидентов #435: двойная идентичность страницы — внутренний id и slugId — гонялась как голая строка и молча путалась). - packages/mcp/src/lib/page-id.ts: номинальные типы PageId/SlugId + валидирующие конструкторы asPageId/asSlugId и гварды isPageId/isSlugId (формат UUID и 10-символьного slugId). PageId протянут через единственный узел канонизации и записи: resolvePageId() теперь возвращает PageId, а ключ per-page лока (withPageLock) и точки записи в collab (mutatePageContent/replacePageContent/ updatePageContentRealtime) требуют бренд — сырой slugId/непроверенный id больше не проходит проверку типов (инвариант #260 «resolve-then-lock» теперь на уровне компилятора). Публичный вход методов остаётся строкой (это legitimно UUID ИЛИ slugId), брендируется каноническое значение. - apps/server core/page/dto: PageIdDto.pageId получает валидацию формата (@Matches, UUID или 10-символьный slugId), так что кривая/подменённая идентичность отклоняется на границе, а не падает в repo голой строкой. Тесты (все зелёные, с mutation-verify каждого): - server: getMasked отдаёт runId/reindexStartedAt; стор пишет/читает runId и мягко деградирует его до ''; DTO отклоняет кривой pageId/slugId и принимает валидный. - client: чистый хелпер reindexRunKey/isNewReindexRun (кеинг поллинга на runId). - mcp: конструкторы/гварды PageId/SlugId (валидация формата). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
220 lines
8.2 KiB
TypeScript
220 lines
8.2 KiB
TypeScript
import { EmbeddingReindexProgressService } from './embedding-reindex-progress.service';
|
|
import type { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
|
import type { Redis } from 'ioredis';
|
|
|
|
/**
|
|
* Unit tests for the Redis-backed reindex-progress store.
|
|
*
|
|
* The store is a thin, BEST-EFFORT wrapper: writes (start/increment) issue an
|
|
* hset/hincrby + expire pipeline and must SWALLOW Redis errors (progress is
|
|
* cosmetic — it must never break a reindex); reads (get) must map a valid hash
|
|
* to a ReindexProgress and degrade to null on a malformed/missing record or a
|
|
* Redis failure. We drive it with a hand-rolled fake ioredis (the project mocks
|
|
* Redis with plain fakes, see public-share limiter specs).
|
|
*/
|
|
describe('EmbeddingReindexProgressService', () => {
|
|
const WORKSPACE_ID = 'ws-1';
|
|
const KEY = 'ai:reindex:progress:ws-1';
|
|
|
|
/**
|
|
* Build a fake ioredis whose `multi()` returns a chainable recorder and whose
|
|
* `hgetall`/`del` are configurable jest mocks. `execImpl` lets a test make the
|
|
* pipeline reject (to assert error-swallowing).
|
|
*/
|
|
function makeRedis(opts: { execImpl?: () => Promise<unknown> } = {}) {
|
|
const exec = jest
|
|
.fn()
|
|
.mockImplementation(opts.execImpl ?? (() => Promise.resolve([])));
|
|
// mockReturnThis() returns the call's `this` (the multi object), so the
|
|
// chain hset().expire().exec() resolves correctly.
|
|
const multiObj = {
|
|
hset: jest.fn().mockReturnThis(),
|
|
hincrby: jest.fn().mockReturnThis(),
|
|
expire: jest.fn().mockReturnThis(),
|
|
exec,
|
|
};
|
|
const multi = jest.fn(() => multiObj);
|
|
const hgetall = jest.fn().mockResolvedValue({});
|
|
const del = jest.fn().mockResolvedValue(1);
|
|
const redis = { multi, hgetall, del } as unknown as Redis;
|
|
return { redis, multiObj, multi, hgetall, del, exec };
|
|
}
|
|
|
|
function makeService(redis: Redis) {
|
|
const redisService = {
|
|
getOrThrow: () => redis,
|
|
} as unknown as RedisService;
|
|
return new EmbeddingReindexProgressService(redisService);
|
|
}
|
|
|
|
describe('get', () => {
|
|
it('maps a valid hash to a ReindexProgress object (incl. the run identity)', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockResolvedValue({
|
|
total: '478',
|
|
done: '120',
|
|
startedAt: '1000',
|
|
runId: 'run-xyz',
|
|
});
|
|
const service = makeService(redis);
|
|
|
|
await expect(service.get(WORKSPACE_ID)).resolves.toEqual({
|
|
total: 478,
|
|
done: 120,
|
|
startedAt: 1000,
|
|
runId: 'run-xyz',
|
|
});
|
|
expect(hgetall).toHaveBeenCalledWith(KEY);
|
|
});
|
|
|
|
it('degrades a missing runId to an empty string (legacy/partial record)', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
// A record written before runId existed: get() must still succeed and
|
|
// report runId='' so the client treats it as "no identity", never breaks.
|
|
hgetall.mockResolvedValue({ total: '10', done: '3', startedAt: '5' });
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toEqual({
|
|
total: 10,
|
|
done: 3,
|
|
startedAt: 5,
|
|
runId: '',
|
|
});
|
|
});
|
|
|
|
it('returns null for an empty hash (no record)', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockResolvedValue({});
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns null when `total` is missing (partial record)', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockResolvedValue({ done: '5' });
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns null for a non-numeric total', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockResolvedValue({ total: 'abc', done: '1', startedAt: '1' });
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns null for a non-numeric done', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockResolvedValue({ total: '10', done: 'xyz', startedAt: '1' });
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toBeNull();
|
|
});
|
|
|
|
it('coerces a non-finite startedAt to 0', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockResolvedValue({
|
|
total: '10',
|
|
done: '2',
|
|
startedAt: 'nope',
|
|
runId: 'run-1',
|
|
});
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toEqual({
|
|
total: 10,
|
|
done: 2,
|
|
startedAt: 0,
|
|
runId: 'run-1',
|
|
});
|
|
});
|
|
|
|
it('degrades to null when hgetall throws (degradation contract)', async () => {
|
|
const { redis, hgetall } = makeRedis();
|
|
hgetall.mockRejectedValue(new Error('redis down'));
|
|
await expect(makeService(redis).get(WORKSPACE_ID)).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('start', () => {
|
|
it('issues hset + expire on the workspace key', async () => {
|
|
const { redis, multiObj } = makeRedis();
|
|
await makeService(redis).start(WORKSPACE_ID, 478);
|
|
|
|
expect(multiObj.hset).toHaveBeenCalledWith(
|
|
KEY,
|
|
expect.objectContaining({ total: '478', done: '0' }),
|
|
);
|
|
expect(multiObj.expire).toHaveBeenCalledWith(KEY, expect.any(Number));
|
|
expect(multiObj.exec).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('mints a fresh non-empty runId into the record on each start', async () => {
|
|
const { redis, multiObj } = makeRedis();
|
|
const service = makeService(redis);
|
|
await service.start(WORKSPACE_ID, 1);
|
|
await service.start(WORKSPACE_ID, 1);
|
|
|
|
const firstRunId = multiObj.hset.mock.calls[0][1].runId;
|
|
const secondRunId = multiObj.hset.mock.calls[1][1].runId;
|
|
expect(typeof firstRunId).toBe('string');
|
|
expect(firstRunId).not.toBe('');
|
|
// Each run gets its OWN identity so the client can tell a re-trigger apart
|
|
// from the run it is already watching.
|
|
expect(secondRunId).not.toBe(firstRunId);
|
|
});
|
|
|
|
it('defaults the expire TTL to the full 1h record TTL', async () => {
|
|
const { redis, multiObj } = makeRedis();
|
|
await makeService(redis).start(WORKSPACE_ID, 478);
|
|
// Default ttl = full record TTL (60 * 60) so a real run never expires
|
|
// mid-flight before the worker refreshes it on each increment.
|
|
expect(multiObj.expire).toHaveBeenCalledWith(KEY, 60 * 60);
|
|
});
|
|
|
|
it('honours an explicit short ttlSeconds for the enqueue-time pre-seed (F10)', async () => {
|
|
const { redis, multiObj } = makeRedis();
|
|
// The reindex() pre-seed passes a short ttl so a phantom record left by a
|
|
// de-duplicated enqueue expires in seconds, not after the full 1h TTL.
|
|
await makeService(redis).start(WORKSPACE_ID, 478, 45);
|
|
expect(multiObj.expire).toHaveBeenCalledWith(KEY, 45);
|
|
});
|
|
|
|
it('swallows a thrown Redis error (best-effort)', async () => {
|
|
const { redis } = makeRedis({
|
|
execImpl: () => Promise.reject(new Error('redis down')),
|
|
});
|
|
await expect(
|
|
makeService(redis).start(WORKSPACE_ID, 1),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('increment', () => {
|
|
it('issues hincrby + expire on the workspace key', async () => {
|
|
const { redis, multiObj } = makeRedis();
|
|
await makeService(redis).increment(WORKSPACE_ID);
|
|
|
|
expect(multiObj.hincrby).toHaveBeenCalledWith(KEY, 'done', 1);
|
|
expect(multiObj.expire).toHaveBeenCalledWith(KEY, expect.any(Number));
|
|
expect(multiObj.exec).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('swallows a thrown Redis error (best-effort)', async () => {
|
|
const { redis } = makeRedis({
|
|
execImpl: () => Promise.reject(new Error('redis down')),
|
|
});
|
|
await expect(
|
|
makeService(redis).increment(WORKSPACE_ID),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
it('deletes the workspace key', async () => {
|
|
const { redis, del } = makeRedis();
|
|
await makeService(redis).clear(WORKSPACE_ID);
|
|
expect(del).toHaveBeenCalledWith(KEY);
|
|
});
|
|
|
|
it('swallows a thrown Redis error (best-effort)', async () => {
|
|
const { redis, del } = makeRedis();
|
|
del.mockRejectedValue(new Error('redis down'));
|
|
await expect(
|
|
makeService(redis).clear(WORKSPACE_ID),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|