Skip to content

Commit 6c9cfa5

Browse files
fullsend-ai-coder[bot]gabemonteroclaude
authored
feat(#3305): add DocumentSyncService for content hash tracking (#3570)
* feat(#3305): add DocumentSyncService for content hash tracking Implement DocumentSyncService (task 1.4) as a cache-backed service that tracks content hashes for RAG pipeline document change detection. Uses Backstage coreServices.cache with a 365-day TTL per the cache-migration spec requirement for effectively no expiry. The service provides setHash, getHash, deleteHash, and hasChanged methods with namespaced cache keys (doc-sync:<documentId>), enabling the RAG pipeline to detect which documents have changed and only re-ingest modified files. Content hashes are shared across instances for consistent sync behavior. Follows the established cache service pattern (ConversationRegistry, BackendApprovalStore) with full test coverage (8 tests, 100% coverage on DocumentSyncService.ts). Closes #3305 * fix: add warning log for unexpected cache value types in DocumentSyncService Log a warning when getHash receives a non-string, non-undefined value from the cache, making cache corruption distinguishable from a cache miss. Adds corresponding test coverage. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Co-authored-by: gabemontero <gmontero@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7a2d895 commit 6c9cfa5

6 files changed

Lines changed: 281 additions & 0 deletions

File tree

workspaces/boost/plugins/boost-backend/report.api.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,22 @@ export function createToolResourceLoader(): ResourceLoader;
334334
// @public
335335
export function createToolRoutes(options: ToolRoutesOptions): Router;
336336

337+
// @public
338+
export class DocumentSyncService {
339+
constructor(options: DocumentSyncServiceOptions);
340+
deleteHash(documentId: string): Promise<void>;
341+
getHash(documentId: string): Promise<string | undefined>;
342+
hasChanged(documentId: string, currentHash: string): Promise<boolean>;
343+
setHash(documentId: string, hash: string): Promise<void>;
344+
static readonly TTL_MS: number;
345+
}
346+
347+
// @public
348+
export interface DocumentSyncServiceOptions {
349+
cache: CacheService;
350+
logger: LoggerService;
351+
}
352+
337353
// @public
338354
export function isDbWritable(key: BoostConfigKey): boolean;
339355

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type {
18+
CacheService,
19+
LoggerService,
20+
} from '@backstage/backend-plugin-api';
21+
import { DocumentSyncService } from './DocumentSyncService';
22+
23+
function createMockLogger(): LoggerService {
24+
return {
25+
info: jest.fn(),
26+
warn: jest.fn(),
27+
error: jest.fn(),
28+
debug: jest.fn(),
29+
child: jest.fn().mockReturnThis(),
30+
};
31+
}
32+
33+
function createMockCache(): CacheService {
34+
const store = new Map<string, unknown>();
35+
return {
36+
get: jest.fn(async (key: string) => store.get(key)) as CacheService['get'],
37+
set: jest.fn(async (key: string, value: unknown) => {
38+
store.set(key, value);
39+
}),
40+
delete: jest.fn(async (key: string) => {
41+
store.delete(key);
42+
}),
43+
withOptions: jest.fn().mockReturnThis(),
44+
};
45+
}
46+
47+
describe('DocumentSyncService', () => {
48+
let service: DocumentSyncService;
49+
let mockCache: CacheService;
50+
let mockLogger: LoggerService;
51+
52+
beforeEach(() => {
53+
mockCache = createMockCache();
54+
mockLogger = createMockLogger();
55+
service = new DocumentSyncService({
56+
cache: mockCache,
57+
logger: mockLogger,
58+
});
59+
});
60+
61+
it('uses cacheService with 365-day TTL', () => {
62+
expect(mockCache.withOptions).toHaveBeenCalledWith({
63+
defaultTtl: 365 * 24 * 60 * 60 * 1000,
64+
});
65+
});
66+
67+
it('sets and gets a content hash', async () => {
68+
await service.setHash('doc-1', 'abc123');
69+
const result = await service.getHash('doc-1');
70+
expect(result).toBe('abc123');
71+
});
72+
73+
it('returns undefined for unknown document ID', async () => {
74+
const result = await service.getHash('unknown');
75+
expect(result).toBeUndefined();
76+
});
77+
78+
it('deletes a content hash', async () => {
79+
await service.setHash('doc-1', 'abc123');
80+
await service.deleteHash('doc-1');
81+
const result = await service.getHash('doc-1');
82+
expect(result).toBeUndefined();
83+
});
84+
85+
it('warns on unexpected non-string cache value', async () => {
86+
(mockCache.get as jest.Mock).mockResolvedValueOnce(42);
87+
const result = await service.getHash('doc-1');
88+
expect(result).toBeUndefined();
89+
expect(mockLogger.warn).toHaveBeenCalledWith(
90+
expect.stringContaining('Unexpected cache value type'),
91+
);
92+
});
93+
94+
it('uses namespaced cache keys', async () => {
95+
await service.setHash('doc-1', 'abc123');
96+
expect(mockCache.set).toHaveBeenCalledWith('doc-sync:doc-1', 'abc123');
97+
});
98+
99+
describe('hasChanged', () => {
100+
it('returns true for a new document', async () => {
101+
const result = await service.hasChanged('doc-new', 'hash1');
102+
expect(result).toBe(true);
103+
});
104+
105+
it('returns false when hash matches', async () => {
106+
await service.setHash('doc-1', 'hash1');
107+
const result = await service.hasChanged('doc-1', 'hash1');
108+
expect(result).toBe(false);
109+
});
110+
111+
it('returns true when hash differs', async () => {
112+
await service.setHash('doc-1', 'hash1');
113+
const result = await service.hasChanged('doc-1', 'hash2');
114+
expect(result).toBe(true);
115+
});
116+
});
117+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type {
18+
CacheService,
19+
LoggerService,
20+
} from '@backstage/backend-plugin-api';
21+
22+
/**
23+
* Options for creating a DocumentSyncService.
24+
*
25+
* @public
26+
*/
27+
export interface DocumentSyncServiceOptions {
28+
/** The Backstage cache service. */
29+
cache: CacheService;
30+
/** The Backstage logger service. */
31+
logger: LoggerService;
32+
}
33+
34+
/**
35+
* Cache-backed service that tracks content hashes for document change detection.
36+
* Uses Backstage cacheService with a long TTL per the cache-migration spec (task 1.4).
37+
*
38+
* Content hashes are shared across instances for consistent sync behavior,
39+
* enabling the RAG pipeline to detect which documents have changed and
40+
* only re-ingest modified files.
41+
*
42+
* @public
43+
*/
44+
export class DocumentSyncService {
45+
private readonly cache: CacheService;
46+
private readonly logger: LoggerService;
47+
48+
/** Cache TTL for content hashes: 365 days (effectively no expiry). */
49+
static readonly TTL_MS = 365 * 24 * 60 * 60 * 1000;
50+
51+
constructor(options: DocumentSyncServiceOptions) {
52+
this.cache = options.cache.withOptions({
53+
defaultTtl: DocumentSyncService.TTL_MS,
54+
});
55+
this.logger = options.logger;
56+
}
57+
58+
/**
59+
* Stores a content hash for a document.
60+
*
61+
* @param documentId - The unique document identifier.
62+
* @param hash - The content hash of the document.
63+
*/
64+
async setHash(documentId: string, hash: string): Promise<void> {
65+
const key = `doc-sync:${documentId}`;
66+
await this.cache.set(key, hash);
67+
this.logger.debug(`Stored content hash for document ${documentId}`);
68+
}
69+
70+
/**
71+
* Retrieves the stored content hash for a document.
72+
*
73+
* @param documentId - The unique document identifier.
74+
* @returns The content hash or undefined if not cached.
75+
*/
76+
async getHash(documentId: string): Promise<string | undefined> {
77+
const key = `doc-sync:${documentId}`;
78+
const value = await this.cache.get(key);
79+
if (typeof value === 'string') {
80+
return value;
81+
}
82+
if (value !== undefined) {
83+
this.logger.warn(
84+
`Unexpected cache value type for document ${documentId}: ${typeof value}`,
85+
);
86+
}
87+
return undefined;
88+
}
89+
90+
/**
91+
* Removes the content hash for a document.
92+
*
93+
* @param documentId - The unique document identifier.
94+
*/
95+
async deleteHash(documentId: string): Promise<void> {
96+
const key = `doc-sync:${documentId}`;
97+
await this.cache.delete(key);
98+
this.logger.debug(`Removed content hash for document ${documentId}`);
99+
}
100+
101+
/**
102+
* Checks whether a document has changed by comparing its current content
103+
* hash against the stored hash.
104+
*
105+
* @param documentId - The unique document identifier.
106+
* @param currentHash - The current content hash to compare.
107+
* @returns True if the document is new or has changed, false otherwise.
108+
*/
109+
async hasChanged(documentId: string, currentHash: string): Promise<boolean> {
110+
const storedHash = await this.getHash(documentId);
111+
return storedHash !== currentHash;
112+
}
113+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export {
18+
DocumentSyncService,
19+
type DocumentSyncServiceOptions,
20+
} from './DocumentSyncService';

workspaces/boost/plugins/boost-backend/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export {
7777
BackendApprovalStore,
7878
type BackendApprovalStoreOptions,
7979
} from './approval';
80+
export {
81+
DocumentSyncService,
82+
type DocumentSyncServiceOptions,
83+
} from './documents';
8084
export {
8185
createChatRoutes,
8286
ConversationAgentCache,

workspaces/boost/plugins/boost-backend/src/plugin.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { ConversationRegistry } from './chat/ConversationRegistry';
5050
import { ConversationStore } from './chat/ConversationStore';
5151
import { RateLimiter } from './chat/RateLimiter';
5252
import { BackendApprovalStore } from './approval/BackendApprovalStore';
53+
import { DocumentSyncService } from './documents/DocumentSyncService';
5354

5455
/**
5556
* The ProviderManager instance shared between the plugin and the
@@ -227,6 +228,16 @@ export const boostPlugin = createBackendPlugin({
227228
logger,
228229
});
229230

231+
// Initialize document sync service — tracks content hashes
232+
// for RAG pipeline change detection (task 1.4). Available for
233+
// document ingestion and sync features.
234+
// @ts-ignore TS6133 — retained for RAG pipeline / document sync
235+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
236+
const _documentSyncService = new DocumentSyncService({
237+
cache,
238+
logger,
239+
});
240+
230241
// Register all boost permissions with the framework
231242
permissionsRegistry.addPermissions([...boostPermissions]);
232243
logger.info(`Registered ${boostPermissions.length} boost permissions`);

0 commit comments

Comments
 (0)