app-backend: add static asset store namespacing

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-01-13 16:22:31 +01:00
parent 793b2a6896
commit 3b674b8ba1
3 changed files with 99 additions and 7 deletions
@@ -0,0 +1,38 @@
/*
* 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.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.alterTable('static_assets_cache', table => {
// The namespace is used to allow operations on asset groups, e.g. delete all assets in a namespace
table.text('namespace').comment('The namespace of the file');
table.index('namespace', 'static_asset_cache_namespace_idx');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('static_assets_cache', table => {
table.dropIndex([], 'static_asset_cache_namespace_idx');
table.dropColumn('namespace');
});
};
@@ -177,4 +177,44 @@ describe('StaticAssetsStore', () => {
await expect(store.getAsset('old')).resolves.toBeUndefined();
},
);
it.each(databases.eachSupportedId())(
'should isolate assets in namespace, %p',
async databaseId => {
const knex = await databases.init(databaseId);
const database = createDatabaseManager(knex);
const store = await StaticAssetsStore.create({
logger,
database,
});
const otherStore = store.withNamespace('other');
await store.storeAssets([
{
path: 'foo',
content: async () => Buffer.alloc(0),
},
]);
await otherStore.storeAssets([
{
path: 'bar',
content: async () => Buffer.alloc(0),
},
]);
await expect(store.getAsset('foo')).resolves.toBeDefined();
await expect(store.getAsset('bar')).resolves.not.toBeDefined();
await expect(otherStore.getAsset('foo')).resolves.not.toBeDefined();
await expect(otherStore.getAsset('bar')).resolves.toBeDefined();
await store.trimAssets({ maxAgeSeconds: 0 });
await expect(store.getAsset('foo')).resolves.not.toBeDefined();
await expect(otherStore.getAsset('bar')).resolves.toBeDefined();
await otherStore.trimAssets({ maxAgeSeconds: 0 });
await expect(otherStore.getAsset('bar')).resolves.not.toBeDefined();
},
);
});
@@ -32,6 +32,7 @@ const migrationsDir = resolvePackagePath(
interface StaticAssetRow {
path: string;
content: Buffer;
namespace: string | null;
last_modified_at: Date;
}
@@ -49,6 +50,7 @@ export interface StaticAssetsStoreOptions {
export class StaticAssetsStore implements StaticAssetProvider {
#db: Knex;
#logger: Logger;
#namespace: string | null;
static async create(options: StaticAssetsStoreOptions) {
const { database } = options;
@@ -63,9 +65,17 @@ export class StaticAssetsStore implements StaticAssetProvider {
return new StaticAssetsStore(client, options.logger);
}
private constructor(client: Knex, logger: Logger) {
private constructor(client: Knex, logger: Logger, namespace?: string) {
this.#db = client;
this.#logger = logger;
this.#namespace = namespace ?? null;
}
/**
* Creates a new store with the provided namespace, using the same underlying storage.
*/
withNamespace(namespace: string): StaticAssetsStore {
return new StaticAssetsStore(this.#db, this.#logger, namespace);
}
/**
@@ -75,12 +85,12 @@ export class StaticAssetsStore implements StaticAssetProvider {
* updated, but the contents will not.
*/
async storeAssets(assets: StaticAssetInput[]) {
const existingRows = await this.#db<StaticAssetRow>(
'static_assets_cache',
).whereIn(
'path',
assets.map(a => a.path),
);
const existingRows = await this.#db<StaticAssetRow>('static_assets_cache')
.where('namespace', this.#namespace)
.whereIn(
'path',
assets.map(a => a.path),
);
const existingAssetPaths = new Set(existingRows.map(r => r.path));
const [modified, added] = partition(assets, asset =>
@@ -95,6 +105,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
.update({
last_modified_at: this.#db.fn.now(),
})
.where('namespace', this.#namespace)
.whereIn(
'path',
modified.map(a => a.path),
@@ -107,6 +118,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
.insert({
path: asset.path,
content: await asset.content(),
namespace: this.#namespace,
})
.onConflict('path')
.ignore();
@@ -119,6 +131,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
async getAsset(path: string): Promise<StaticAsset | undefined> {
const [row] = await this.#db<StaticAssetRow>('static_assets_cache').where({
path,
namespace: this.#namespace,
});
if (!row) {
return undefined;
@@ -151,6 +164,7 @@ export class StaticAssetsStore implements StaticAssetProvider {
]);
}
await this.#db<StaticAssetRow>('static_assets_cache')
.where('namespace', this.#namespace)
.where('last_modified_at', '<=', lastModifiedInterval)
.delete();
}