Merge pull request #6335 from backstage/iameap/techdocs-cache

[TechDocs] Optional static resource caching
This commit is contained in:
Otto Sichert
2021-12-06 10:38:30 +01:00
committed by GitHub
33 changed files with 1232 additions and 138 deletions
+1
View File
@@ -10,6 +10,7 @@ import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { Knex } from 'knex';
import { Logger as Logger_2 } from 'winston';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PreparerBuilder } from '@backstage/techdocs-common';
import { PublisherBase } from '@backstage/techdocs-common';
+26
View File
@@ -226,6 +226,32 @@ export interface Config {
};
};
/**
* @example http://localhost:7007/api/techdocs
* Techdocs cache information
*/
cache?: {
/**
* The cache time-to-live for TechDocs sites (in milliseconds). Set this
* to a non-zero value to cache TechDocs sites and assets as they are
* read from storage.
*
* Note: you must also configure `backend.cache` appropriately as well,
* and to pass a PluginCacheManager instance to TechDocs Backend's
* createRouter method in your backend.
*/
ttl: number;
/**
* The time (in milliseconds) that the TechDocs backend will wait for
* a cache service to respond before continuing on as though the cached
* object was not found (e.g. when the cache sercice is unavailable).
*
* Defaults to 1000 milliseconds.
*/
readTimeout?: number;
};
/**
* @example http://localhost:7007/api/techdocs
* @visibility frontend
+1
View File
@@ -40,6 +40,7 @@
"@backstage/search-common": "^0.2.1",
"@backstage/techdocs-common": "^0.10.8",
"@types/express": "^4.17.6",
"cross-fetch": "^3.0.6",
"dockerode": "^3.3.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
@@ -36,6 +36,7 @@ import path from 'path';
import { Writable } from 'stream';
import { Logger } from 'winston';
import { BuildMetadataStorage } from './BuildMetadataStorage';
import { TechDocsCache } from '../cache';
type DocsBuilderArguments = {
preparers: PreparerBuilder;
@@ -46,6 +47,7 @@ type DocsBuilderArguments = {
config: Config;
scmIntegrations: ScmIntegrationRegistry;
logStream?: Writable;
cache?: TechDocsCache;
};
export class DocsBuilder {
@@ -57,6 +59,7 @@ export class DocsBuilder {
private config: Config;
private scmIntegrations: ScmIntegrationRegistry;
private logStream: Writable | undefined;
private cache?: TechDocsCache;
constructor({
preparers,
@@ -67,6 +70,7 @@ export class DocsBuilder {
config,
scmIntegrations,
logStream,
cache,
}: DocsBuilderArguments) {
this.preparer = preparers.get(entity);
this.generator = generators.get(entity);
@@ -76,6 +80,7 @@ export class DocsBuilder {
this.config = config;
this.scmIntegrations = scmIntegrations;
this.logStream = logStream;
this.cache = cache;
}
/**
@@ -210,11 +215,19 @@ export class DocsBuilder {
)}`,
);
await this.publisher.publish({
const published = await this.publisher.publish({
entity: this.entity,
directory: outputDir,
});
// Invalidate the cache for any published objects.
if (this.cache && published && published?.objects?.length) {
this.logger.debug(
`Invalidating ${published.objects.length} cache objects`,
);
await this.cache.invalidateMultiple(published.objects);
}
try {
// Not a blocker hence no need to await this.
fs.remove(outputDir);
+168
View File
@@ -0,0 +1,168 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CacheClient, getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { CacheInvalidationError, TechDocsCache } from './TechDocsCache';
const cached = (str: string): string => {
return Buffer.from(str).toString('base64');
};
describe('TechDocsCache', () => {
let CacheUnderTest: TechDocsCache;
let MockClient: jest.Mocked<CacheClient>;
beforeEach(() => {
MockClient = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
CacheUnderTest = TechDocsCache.fromConfig(new ConfigReader({}), {
cache: MockClient,
logger: getVoidLogger(),
});
});
describe('get', () => {
it('returns undefined if no response', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockResolvedValueOnce(undefined);
const actual = await CacheUnderTest.get(expectedPath);
expect(MockClient.get).toHaveBeenCalledWith(expectedPath);
expect(actual).toBe(undefined);
});
it('returns undefined if cache get throws', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockRejectedValueOnce(new Error());
const actual = await CacheUnderTest.get(expectedPath);
expect(actual).toBe(undefined);
});
it('returns undefined if no response after 1s by default', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => resolve(cached('value')), 1500);
});
});
const actual = await CacheUnderTest.get(expectedPath);
expect(actual).toBe(undefined);
});
it('returns undefined if no response after configured readTimeout', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => resolve(cached('value')), 20);
});
});
CacheUnderTest = TechDocsCache.fromConfig(
new ConfigReader({
techdocs: { cache: { readTimeout: 10 } },
}),
{
cache: MockClient,
logger: getVoidLogger(),
},
);
const actual = await CacheUnderTest.get(expectedPath);
expect(actual).toBe(undefined);
});
it('returns data if cache get returns it', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockResolvedValueOnce(cached('expected value'));
const actual = await CacheUnderTest.get(expectedPath);
expect(actual?.toString()).toBe('expected value');
});
});
describe('set', () => {
it('sets a base64-encoded string', async () => {
const expectedPath = 'some/index.html';
MockClient.set.mockResolvedValueOnce(undefined);
await CacheUnderTest.set(expectedPath, Buffer.from('some data'));
expect(MockClient.set).toHaveBeenCalledWith(
expectedPath,
cached('some data'),
);
});
it('does not throw if client throws', () => {
MockClient.set.mockRejectedValueOnce(new Error());
expect(() => CacheUnderTest.set('i.html', Buffer.from(''))).not.toThrow();
});
});
describe('invalidate', () => {
it('calls delete on client', async () => {
const expectedPath = 'some/index.html';
MockClient.delete.mockResolvedValueOnce(undefined);
await CacheUnderTest.invalidate(expectedPath);
expect(MockClient.delete).toHaveBeenCalledWith(expectedPath);
});
});
describe('invalidateMultiple', () => {
it('calls delete once per given path', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockResolvedValue(undefined);
await CacheUnderTest.invalidateMultiple(expectedPaths);
expect(MockClient.delete).toHaveBeenNthCalledWith(1, expectedPaths[0]);
expect(MockClient.delete).toHaveBeenNthCalledWith(2, expectedPaths[1]);
});
it('returns an array of as many paths provided', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockResolvedValue(undefined);
const actual = await CacheUnderTest.invalidateMultiple(expectedPaths);
expect(actual.length).toBe(2);
});
it('calls delete on all paths even if the first rejects', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockRejectedValueOnce(new Error());
MockClient.delete.mockResolvedValueOnce(undefined);
await expect(
CacheUnderTest.invalidateMultiple(expectedPaths),
).rejects.toThrowError(CacheInvalidationError);
expect(MockClient.delete).toHaveBeenCalledTimes(2);
});
it('rejects with invalidations error response', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockResolvedValueOnce(undefined);
MockClient.delete.mockRejectedValueOnce(new Error());
await expect(
CacheUnderTest.invalidateMultiple.bind(CacheUnderTest, expectedPaths),
).rejects.toThrow(CacheInvalidationError);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CacheClient } from '@backstage/backend-common';
import { assertError, CustomErrorBase } from '@backstage/errors';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
export class CacheInvalidationError extends CustomErrorBase {}
export class TechDocsCache {
protected readonly cache: CacheClient;
protected readonly logger: Logger;
protected readonly readTimeout: number;
private constructor({
cache,
logger,
readTimeout,
}: {
cache: CacheClient;
logger: Logger;
readTimeout: number;
}) {
this.cache = cache;
this.logger = logger;
this.readTimeout = readTimeout;
}
static fromConfig(
config: Config,
{ cache, logger }: { cache: CacheClient; logger: Logger },
) {
const timeout = config.getOptionalNumber('techdocs.cache.readTimeout');
const readTimeout = timeout === undefined ? 1000 : timeout;
return new TechDocsCache({ cache, logger, readTimeout });
}
async get(path: string): Promise<Buffer | undefined> {
try {
// Promise.race ensures we don't hang the client for long if the cache is
// temporarily unreachable.
const response = (await Promise.race([
this.cache.get(path),
new Promise(cancelAfter => setTimeout(cancelAfter, this.readTimeout)),
])) as string | undefined;
if (response !== undefined) {
this.logger.debug(`Cache hit: ${path}`);
return Buffer.from(response, 'base64');
}
this.logger.debug(`Cache miss: ${path}`);
return response;
} catch (e) {
assertError(e);
this.logger.warn(`Error getting cache entry ${path}: ${e.message}`);
this.logger.debug(e.stack);
return undefined;
}
}
async set(path: string, data: Buffer): Promise<void> {
this.logger.debug(`Writing cache entry for ${path}`);
this.cache
.set(path, data.toString('base64'))
.catch(e => this.logger.error('write error', e));
}
async invalidate(path: string): Promise<void> {
return this.cache.delete(path);
}
async invalidateMultiple(
paths: string[],
): Promise<PromiseSettledResult<void>[]> {
const settled = await Promise.allSettled(
paths.map(path => this.cache.delete(path)),
);
const rejected = settled.filter(
s => s.status === 'rejected',
) as PromiseRejectedResult[];
if (rejected.length) {
throw new CacheInvalidationError(
'TechDocs cache invalidation error',
rejected,
);
}
return settled;
}
}
@@ -0,0 +1,109 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { createCacheMiddleware, TechDocsCache } from '.';
/**
* Mocks cached HTTP response.
*/
const getMockHttpResponseFor = (content: string): Buffer => {
return Buffer.concat([
Buffer.from(`HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Sat, 1 Jul 2021 12:00:00 GMT
Date: Sat, 1 Jul 2021 12:00:00 GMT
Connection: close
Content-Length: ${content.length}\n\n`),
Buffer.from(content),
]);
};
/**
* Wait for the socket to close. Works because, above, we set connection: close
*/
const waitForSocketClose = () => new Promise(resolve => setTimeout(resolve, 0));
describe('createCacheMiddleware', () => {
let cache: jest.Mocked<TechDocsCache>;
let app: express.Express;
beforeEach(async () => {
cache = {
get: jest.fn().mockResolvedValue(undefined),
set: jest.fn().mockResolvedValue(undefined),
invalidate: jest.fn().mockResolvedValue(undefined),
invalidateMultiple: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<TechDocsCache>;
const router = await createCacheMiddleware({
logger: getVoidLogger(),
cache,
});
app = express().use(router);
app.use((req, res, next) => {
// By default, send cacheable content.
if (req.path !== '/static/docs/error.png') {
res.send('default-response');
} else {
next(new Error());
}
});
});
describe('middleware', () => {
it('does not apply to non-static/docs paths', async () => {
await request(app)
.get('/static/not-docs')
.expect(200, 'default-response');
expect(cache.set).not.toHaveBeenCalled();
});
it('responds with cached response', async () => {
cache.get.mockResolvedValueOnce(getMockHttpResponseFor('xyz'));
await request(app).get('/static/docs/foo.html').expect(200, 'xyz');
await waitForSocketClose();
expect(cache.set).not.toHaveBeenCalled();
});
it('sets cache when content is cacheable', async () => {
const expectedPath = 'default/api/xyz/index.html';
await request(app)
.get(`/static/docs/${expectedPath}`)
.expect(200, 'default-response');
await waitForSocketClose();
expect(cache.set).toHaveBeenCalled();
const [actualPath, actualBuffer] = (cache.set as jest.Mock).mock.calls[0];
expect(actualPath).toBe(expectedPath);
expect(actualBuffer.toString()).toContain('default-response');
});
it('does not set cache on error', async () => {
await request(app).get('/static/docs/error.png').expect(500);
await waitForSocketClose();
expect(cache.set).not.toHaveBeenCalled();
});
});
});
+94
View File
@@ -0,0 +1,94 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Router } from 'express';
import router from 'express-promise-router';
import { Logger } from 'winston';
import { TechDocsCache } from './TechDocsCache';
type CacheMiddlewareOptions = {
cache: TechDocsCache;
logger: Logger;
};
type ErrorCallback = (err?: Error) => void;
export const createCacheMiddleware = ({
cache,
}: CacheMiddlewareOptions): Router => {
const cacheMiddleware = router();
// Middleware that, through socket monkey patching, captures responses as
// they're sent over /static/docs/* and caches them. Subsequent requests are
// loaded from cache. Cache key is the object's path (after `/static/docs/`).
cacheMiddleware.use(async (req, res, next) => {
const socket = res.socket;
const isCacheable = req.path.startsWith('/static/docs/');
// Continue early if this is non-cacheable, or there's no socket.
if (!isCacheable || !socket) {
next();
return;
}
// Make concrete references to these things.
const reqPath = decodeURI(req.path.match(/\/static\/docs\/(.*)$/)![1]);
const realEnd = socket.end.bind(socket);
const realWrite = socket.write.bind(socket);
let writeToCache = true;
const chunks: Buffer[] = [];
// Monkey-patch the response's socket to keep track of chunks as they are
// written over the wire.
socket.write = (
data: string | Uint8Array,
encoding?: BufferEncoding | ErrorCallback,
callback?: ErrorCallback,
) => {
chunks.push(Buffer.from(data));
if (typeof encoding === 'function') {
return realWrite(data, encoding);
}
return realWrite(data, encoding, callback);
};
// When a socket is closed, if there were no errors and the data written
// over the socket should be cached, cache it!
socket.on('close', async hadError => {
const content = Buffer.concat(chunks);
const head = content.toString('utf8', 0, 12);
if (writeToCache && !hadError && head.match(/HTTP\/\d\.\d 200/)) {
await cache.set(reqPath, content);
}
});
// Attempt to retrieve data from the cache.
const cached = await cache.get(reqPath);
// If there is a cache hit, write it out on the socket, ensure we don't re-
// cache the data, and prevent going back to canonical storage by never
// calling next().
if (cached) {
writeToCache = false;
realEnd(cached);
return;
}
// No data retrieved from cache: allow retrieval from canonical storage.
next();
});
return cacheMiddleware;
};
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createCacheMiddleware } from './cacheMiddleware';
export { TechDocsCache } from './TechDocsCache';
@@ -25,11 +25,25 @@ import {
PreparerBuilder,
PublisherBase,
} from '@backstage/techdocs-common';
import { TechDocsCache } from '../cache';
import { DocsBuilder, shouldCheckForUpdate } from '../DocsBuilder';
import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer';
jest.mock('../DocsBuilder');
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
return {
json: async () => {
return {
build_timestamp: 123,
};
},
};
},
}));
const MockedDocsBuilder = DocsBuilder as jest.MockedClass<typeof DocsBuilder>;
describe('DocsSynchronizer', () => {
@@ -52,6 +66,12 @@ describe('DocsSynchronizer', () => {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const cache: jest.Mocked<TechDocsCache> = {
get: jest.fn(),
set: jest.fn(),
invalidate: jest.fn(),
invalidateMultiple: jest.fn(),
} as unknown as jest.Mocked<TechDocsCache>;
let docsSynchronizer: DocsSynchronizer;
const mockResponseHandler: jest.Mocked<DocsSynchronizerSyncOpts> = {
@@ -71,6 +91,7 @@ describe('DocsSynchronizer', () => {
config: new ConfigReader({}),
logger: getVoidLogger(),
scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})),
cache,
});
});
@@ -193,4 +214,112 @@ describe('DocsSynchronizer', () => {
expect(mockResponseHandler.error).toBeCalledWith(error);
});
});
describe('doCacheSync', () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
uid: '0',
name: 'test',
namespace: 'default',
},
};
it('should not check metadata too often', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(false);
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: false });
expect(shouldCheckForUpdate).toBeCalledTimes(1);
});
it('should do nothing if source/cached metadata matches', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({
build_timestamp: 123,
});
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: false });
});
it('should invalidate expected files when source/cached metadata differ', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({
build_timestamp: 456,
files: ['index.html'],
});
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: true });
expect(cache.invalidateMultiple).toHaveBeenCalledWith([
'default/component/test/index.html',
]);
});
it('should invalidate expected files when source/cached metadata differ with legacy casing', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({
build_timestamp: 456,
files: ['index.html'],
});
const docsSynchronizerWithLegacy = new DocsSynchronizer({
publisher,
config: new ConfigReader({
techdocs: { legacyUseCaseSensitiveTripletPaths: true },
}),
logger: getVoidLogger(),
scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})),
cache,
});
await docsSynchronizerWithLegacy.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: true });
expect(cache.invalidateMultiple).toHaveBeenCalledWith([
'default/Component/test/index.html',
]);
});
it('should gracefully handle errors', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockRejectedValue(
new Error(),
);
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: false });
});
});
});
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, NotFoundError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -23,9 +24,15 @@ import {
PreparerBuilder,
PublisherBase,
} from '@backstage/techdocs-common';
import fetch from 'cross-fetch';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import { DocsBuilder, shouldCheckForUpdate } from '../DocsBuilder';
import { TechDocsCache } from '../cache';
import {
BuildMetadataStorage,
DocsBuilder,
shouldCheckForUpdate,
} from '../DocsBuilder';
export type DocsSynchronizerSyncOpts = {
log: (message: string) => void;
@@ -38,22 +45,26 @@ export class DocsSynchronizer {
private readonly logger: winston.Logger;
private readonly config: Config;
private readonly scmIntegrations: ScmIntegrationRegistry;
private readonly cache: TechDocsCache | undefined;
constructor({
publisher,
logger,
config,
scmIntegrations,
cache,
}: {
publisher: PublisherBase;
logger: winston.Logger;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
cache: TechDocsCache | undefined;
}) {
this.config = config;
this.logger = logger;
this.publisher = publisher;
this.scmIntegrations = scmIntegrations;
this.cache = cache;
}
async doSync({
@@ -104,6 +115,7 @@ export class DocsSynchronizer {
config: this.config,
scmIntegrations: this.scmIntegrations,
logStream,
cache: this.cache,
});
const updated = await docsBuilder.build();
@@ -145,4 +157,76 @@ export class DocsSynchronizer {
finish({ updated: true });
}
async doCacheSync({
responseHandler: { finish },
discovery,
token,
entity,
}: {
responseHandler: DocsSynchronizerSyncOpts;
discovery: PluginEndpointDiscovery;
token: string | undefined;
entity: Entity;
}) {
// Check if the last update check was too recent.
if (!shouldCheckForUpdate(entity.metadata.uid!) || !this.cache) {
finish({ updated: false });
return;
}
// Fetch techdocs_metadata.json from the publisher and from cache.
const baseUrl = await discovery.getBaseUrl('techdocs');
const namespace = entity.metadata?.namespace || ENTITY_DEFAULT_NAMESPACE;
const kind = entity.kind;
const name = entity.metadata.name;
const legacyPathCasing =
this.config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
const tripletPath = `${namespace}/${kind}/${name}`;
const entityTripletPath = `${
legacyPathCasing ? tripletPath : tripletPath.toLocaleLowerCase('en-US')
}`;
try {
const [sourceMetadata, cachedMetadata] = await Promise.all([
this.publisher.fetchTechDocsMetadata({ namespace, kind, name }),
fetch(
`${baseUrl}/static/docs/${entityTripletPath}/techdocs_metadata.json`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
).then(
f =>
f.json().catch(() => undefined) as ReturnType<
PublisherBase['fetchTechDocsMetadata']
>,
),
]);
// If build timestamps differ, merge their files[] lists and invalidate all objects.
if (sourceMetadata.build_timestamp !== cachedMetadata.build_timestamp) {
const files = [
...new Set([
...(sourceMetadata.files || []),
...(cachedMetadata.files || []),
]),
].map(f => `${entityTripletPath}/${f}`);
await this.cache.invalidateMultiple(files);
finish({ updated: true });
} else {
finish({ updated: false });
}
} catch (e) {
assertError(e);
// In case of error, log and allow the user to go about their business.
this.logger.error(
`Error syncing cache for ${entityTripletPath}: ${e.message}`,
);
finish({ updated: false });
} finally {
// Update the last check time for the entity
new BuildMetadataStorage(entity.metadata.uid!).setLastUpdated();
}
}
}
+32 -1
View File
@@ -13,7 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
PluginEndpointDiscovery,
PluginCacheManager,
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
@@ -31,6 +34,7 @@ import { Knex } from 'knex';
import { Logger } from 'winston';
import { ScmIntegrations } from '@backstage/integration';
import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer';
import { createCacheMiddleware, TechDocsCache } from '../cache';
/**
* All of the required dependencies for running TechDocs in the "out-of-the-box"
@@ -44,6 +48,7 @@ type OutOfTheBoxDeploymentOptions = {
discovery: PluginEndpointDiscovery;
database?: Knex; // TODO: Make database required when we're implementing database stuff.
config: Config;
cache?: PluginCacheManager;
};
/**
@@ -80,12 +85,22 @@ export async function createRouter(
const router = Router();
const { publisher, config, logger, discovery } = options;
const catalogClient = new CatalogClient({ discoveryApi: discovery });
// Set up a cache client if configured.
let cache: TechDocsCache | undefined;
const defaultTtl = config.getOptionalNumber('techdocs.cache.ttl');
if (isOutOfTheBoxOption(options) && options.cache && defaultTtl) {
const cacheClient = options.cache.getClient({ defaultTtl });
cache = TechDocsCache.fromConfig(config, { cache: cacheClient, logger });
}
const scmIntegrations = ScmIntegrations.fromConfig(config);
const docsSynchronizer = new DocsSynchronizer({
publisher,
logger,
config,
scmIntegrations,
cache,
});
router.get('/metadata/techdocs/:namespace/:kind/:name', async (req, res) => {
@@ -175,6 +190,17 @@ export async function createRouter(
// If set to 'external', it will assume that an external process (e.g. CI/CD pipeline
// of the repository) is responsible for building and publishing documentation to the storage provider
if (config.getString('techdocs.builder') !== 'local') {
// However, if caching is enabled, take the opportunity to check and
// invalidate stale cache entries.
if (cache) {
await docsSynchronizer.doCacheSync({
responseHandler,
discovery,
token,
entity,
});
return;
}
responseHandler.finish({ updated: false });
return;
}
@@ -199,6 +225,11 @@ export async function createRouter(
);
});
// If a cache manager was provided, attach the cache middleware.
if (cache) {
router.use(createCacheMiddleware({ logger, cache }));
}
// Route middleware which serves files from the storage set in the publisher.
router.use('/static/docs', publisher.docsRouter());