update the stitcher including search
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -33,7 +33,6 @@ exports.up = async function up(knex) {
|
||||
);
|
||||
table
|
||||
.text('entity_ref')
|
||||
.unique()
|
||||
.notNullable()
|
||||
.comment('A reference to the entity that the refresh state is tied to');
|
||||
table
|
||||
@@ -59,13 +58,14 @@ exports.up = async function up(knex) {
|
||||
.notNullable()
|
||||
.comment('JSON array containing all errors related to entity');
|
||||
table
|
||||
.dateTime('next_update_at') // TOOD: timezone or change to epoch-millis or similar
|
||||
.dateTime('next_update_at') // TODO: timezone or change to epoch-millis or similar
|
||||
.notNullable()
|
||||
.comment('Timestamp of when entity should be updated');
|
||||
table
|
||||
.dateTime('last_discovery_at') // TOOD: timezone or change to epoch-millis or similar
|
||||
.dateTime('last_discovery_at') // TODO: timezone or change to epoch-millis or similar
|
||||
.notNullable()
|
||||
.comment('The last timestamp of which this entity was discovered');
|
||||
table.unique(['entity_ref'], 'refresh_state_entity_ref_uniq');
|
||||
table.index('entity_id', 'refresh_state_entity_id_idx');
|
||||
table.index('entity_ref', 'refresh_state_entity_ref_idx');
|
||||
table.index('next_update_at', 'refresh_state_next_update_at_idx');
|
||||
@@ -188,6 +188,7 @@ exports.down = async function down(knex) {
|
||||
table.dropIndex([], 'refresh_state_references_target_entity_id_idx');
|
||||
});
|
||||
await knex.schema.alterTable('refresh_state', table => {
|
||||
table.dropUnique([], 'refresh_state_entity_ref_uniq');
|
||||
table.dropIndex([], 'refresh_state_entity_id_idx');
|
||||
table.dropIndex([], 'refresh_state_entity_ref_idx');
|
||||
table.dropIndex([], 'refresh_state_next_update_at_idx');
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { Knex } from 'knex';
|
||||
import { DatabaseManager } from './database/DatabaseManager';
|
||||
import {
|
||||
DbRefreshStateReferencesRow,
|
||||
DbRefreshStateRow,
|
||||
DbRelationsRow,
|
||||
} from './database/DefaultProcessingDatabase';
|
||||
import { DbSearchRow } from './search';
|
||||
import { DbFinalEntitiesRow, Stitcher } from './Stitcher';
|
||||
|
||||
describe('Stitcher', () => {
|
||||
let db: Knex;
|
||||
const logger = getVoidLogger();
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await DatabaseManager.createTestDatabaseConnection();
|
||||
await DatabaseManager.createDatabase(db);
|
||||
});
|
||||
|
||||
it('runs the happy path', async () => {
|
||||
const stitcher = new Stitcher(db, logger);
|
||||
|
||||
await db.transaction(async tx => {
|
||||
await tx<DbRefreshStateRow>('refresh_state').insert([
|
||||
{
|
||||
entity_id: 'my-id',
|
||||
entity_ref: 'k:ns/n',
|
||||
unprocessed_entity: JSON.stringify({}),
|
||||
processed_entity: JSON.stringify({
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
}),
|
||||
errors: '[]',
|
||||
next_update_at: tx.fn.now(),
|
||||
last_discovery_at: tx.fn.now(),
|
||||
},
|
||||
]);
|
||||
await tx<DbRefreshStateReferencesRow>('refresh_state_references').insert([
|
||||
{ source_key: 'a', target_entity_ref: 'k:ns/n' },
|
||||
]);
|
||||
await tx<DbRelationsRow>('relations').insert([
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/other',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await stitcher.stitch(new Set(['k:ns/n']));
|
||||
|
||||
let firstEtag: string;
|
||||
await db.transaction(async tx => {
|
||||
const entities = await tx<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
const entity = JSON.parse(entities[0].finalized_entity);
|
||||
expect(entity).toEqual({
|
||||
relations: [
|
||||
{
|
||||
type: 'looksAt',
|
||||
target: {
|
||||
kind: 'k',
|
||||
namespace: 'ns',
|
||||
name: 'other',
|
||||
},
|
||||
},
|
||||
],
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
etag: expect.any(String),
|
||||
generation: 1,
|
||||
uid: 'my-id',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
});
|
||||
|
||||
firstEtag = entity.metadata.etag;
|
||||
|
||||
const search = await tx<DbSearchRow>('search');
|
||||
expect(search).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' },
|
||||
{ entity_id: 'my-id', key: 'apiversion', value: 'a' },
|
||||
{ entity_id: 'my-id', key: 'kind', value: 'k' },
|
||||
{ entity_id: 'my-id', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' },
|
||||
{ entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' },
|
||||
{ entity_id: 'my-id', key: 'spec.k', value: 'v' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// Re-stitch without any changes
|
||||
await stitcher.stitch(new Set(['k:ns/n']));
|
||||
|
||||
await db.transaction(async tx => {
|
||||
const entities = await tx<DbFinalEntitiesRow>('final_entities');
|
||||
expect(entities.length).toBe(1);
|
||||
const entity = JSON.parse(entities[0].finalized_entity);
|
||||
expect(entities[0].etag).toEqual(firstEtag);
|
||||
expect(entity.metadata.etag).toEqual(firstEtag);
|
||||
});
|
||||
|
||||
// Now add one more relation and re-stitch
|
||||
await db.transaction(async tx => {
|
||||
await tx<DbRelationsRow>('relations').insert([
|
||||
{
|
||||
originating_entity_id: 'my-id',
|
||||
source_entity_ref: 'k:ns/n',
|
||||
type: 'looksAt',
|
||||
target_entity_ref: 'k:ns/third',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
await stitcher.stitch(new Set(['k:ns/n']));
|
||||
|
||||
await db.transaction(async tx => {
|
||||
const entities = await tx<DbFinalEntitiesRow>('final_entities');
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
const entity = JSON.parse(entities[0].finalized_entity);
|
||||
expect(entity).toEqual({
|
||||
relations: expect.arrayContaining([
|
||||
{
|
||||
type: 'looksAt',
|
||||
target: {
|
||||
kind: 'k',
|
||||
namespace: 'ns',
|
||||
name: 'other',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'looksAt',
|
||||
target: {
|
||||
kind: 'k',
|
||||
namespace: 'ns',
|
||||
name: 'third',
|
||||
},
|
||||
},
|
||||
]),
|
||||
apiVersion: 'a',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
etag: expect.any(String),
|
||||
generation: 1,
|
||||
uid: 'my-id',
|
||||
},
|
||||
spec: {
|
||||
k: 'v',
|
||||
},
|
||||
});
|
||||
|
||||
expect(entities[0].etag).not.toEqual(firstEtag);
|
||||
expect(entities[0].etag).toEqual(entity.metadata.etag);
|
||||
|
||||
const search = await tx<DbSearchRow>('search');
|
||||
expect(search).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/other' },
|
||||
{ entity_id: 'my-id', key: 'relations', value: 'looksat:k:ns/third' },
|
||||
{ entity_id: 'my-id', key: 'apiversion', value: 'a' },
|
||||
{ entity_id: 'my-id', key: 'kind', value: 'k' },
|
||||
{ entity_id: 'my-id', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'my-id', key: 'metadata.namespace', value: 'ns' },
|
||||
{ entity_id: 'my-id', key: 'metadata.uid', value: 'my-id' },
|
||||
{ entity_id: 'my-id', key: 'spec.k', value: 'v' },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,20 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, parseEntityRef } from '@backstage/catalog-model';
|
||||
import { ConflictError } from '@backstage/errors';
|
||||
import { createHash } from 'crypto';
|
||||
import stableStringify from 'fast-json-stable-stringify';
|
||||
import { Knex } from 'knex';
|
||||
import { Logger } from 'winston';
|
||||
import { Transaction } from '../database';
|
||||
import { ConflictError } from '@backstage/errors';
|
||||
import {
|
||||
DbRefreshStateReferencesRow,
|
||||
DbRefreshStateRow,
|
||||
DbRelationsRow,
|
||||
} from './database/DefaultProcessingDatabase';
|
||||
import { Entity, parseEntityRef } from '@backstage/catalog-model';
|
||||
import { createHash } from 'crypto';
|
||||
import stableStringify from 'fast-json-stable-stringify';
|
||||
import { buildEntitySearch } from '../database/search';
|
||||
import { DbEntitiesSearchRow } from '../database/types';
|
||||
import { buildEntitySearch, DbSearchRow } from './search';
|
||||
|
||||
// The number of items that are sent per batch to the database layer, when
|
||||
// doing .batchInsert calls to knex. This needs to be low enough to not cause
|
||||
@@ -58,6 +52,13 @@ export class Stitcher {
|
||||
await this.transaction(async txOpaque => {
|
||||
const tx = txOpaque as Knex.Transaction;
|
||||
|
||||
// Selecting from refresh_state and final_entities should yield exactly
|
||||
// one row (except in abnormal cases where the stitch was invoked for
|
||||
// something that didn't exist at all, in which case it's zero rows).
|
||||
// The join with the temporary incoming_references still gives one row.
|
||||
// The only result set "expanding" join is the one with relations, so
|
||||
// the output should be at least one row (if zero or one relations were
|
||||
// found), or at most the same number of rows as relations.
|
||||
const result: Array<{
|
||||
entityId: string;
|
||||
processedEntity?: string;
|
||||
@@ -90,8 +91,14 @@ export class Stitcher {
|
||||
.leftOuterJoin('relations', {
|
||||
'relations.source_entity_ref': 'refresh_state.entity_ref',
|
||||
})
|
||||
.where({ 'refresh_state.entity_ref': entityRef });
|
||||
.where({ 'refresh_state.entity_ref': entityRef })
|
||||
.orderBy('relationType', 'asc')
|
||||
.orderBy('relationTarget', 'asc');
|
||||
|
||||
// If there were no rows returned, it would mean that there was no
|
||||
// matching row even in the refresh_state. This can happen for example
|
||||
// if we emit a relation to something that hasn't been ingested yet.
|
||||
// It's safe to ignore this stitch attempt in that case.
|
||||
if (!result.length) {
|
||||
this.logger.debug(
|
||||
`Unable to stitch ${entityRef}, item does not exist in refresh state table`,
|
||||
@@ -102,11 +109,15 @@ export class Stitcher {
|
||||
const {
|
||||
entityId,
|
||||
processedEntity,
|
||||
errors,
|
||||
// errors,
|
||||
incomingReferenceCount,
|
||||
previousEtag,
|
||||
} = result[0];
|
||||
|
||||
// If there was no processed entity in place, the target hasn't been
|
||||
// through the processing steps yet. It's safe to ignore this stitch
|
||||
// attempt in that case, since another stitch will be triggered when
|
||||
// that processing has finished.
|
||||
if (!processedEntity) {
|
||||
this.logger.debug(
|
||||
`Unable to stitch ${entityRef}, the entity has not yet been processed`,
|
||||
@@ -114,6 +125,7 @@ export class Stitcher {
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab the processed entity and stitch all of the relevant data into it
|
||||
const entity = JSON.parse(processedEntity) as Entity;
|
||||
const isOrphan = Number(incomingReferenceCount) === 0;
|
||||
|
||||
@@ -132,15 +144,16 @@ export class Stitcher {
|
||||
type: row.relationType!,
|
||||
target: parseEntityRef(row.relationTarget!),
|
||||
}));
|
||||
entity.metadata.generation = 1;
|
||||
|
||||
// If the output entity was actually not changed, just abort
|
||||
const etag = generateEntityEtag(entity);
|
||||
if (etag === previousEtag) {
|
||||
console.log(`Skipped stitching of ${entityRef}, no changes`);
|
||||
this.logger.debug(`Skipped stitching of ${entityRef}, no changes`);
|
||||
return;
|
||||
}
|
||||
|
||||
entity.metadata.uid = entityId;
|
||||
entity.metadata.generation = 1;
|
||||
entity.metadata.etag = etag;
|
||||
|
||||
await tx<DbFinalEntitiesRow>('final_entities')
|
||||
@@ -152,19 +165,9 @@ export class Stitcher {
|
||||
.onConflict('entity_id')
|
||||
.merge(['finalized_entity', 'etag']);
|
||||
|
||||
try {
|
||||
const entries = buildEntitySearch(entityId, entity);
|
||||
await tx<DbEntitiesSearchRow>('search')
|
||||
.where({ entity_id: entityId })
|
||||
.delete();
|
||||
await tx.batchInsert('search', entries, BATCH_SIZE);
|
||||
} catch (e) {
|
||||
this.logger.debug(
|
||||
`Failed to write search entries for ${entityId}`,
|
||||
e,
|
||||
);
|
||||
// intentionally ignored
|
||||
}
|
||||
const searchEntries = buildEntitySearch(entityId, entity);
|
||||
await tx<DbSearchRow>('search').where({ entity_id: entityId }).delete();
|
||||
await tx.batchInsert('search', searchEntries, BATCH_SIZE);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
|
||||
import { buildEntitySearch, mapToRows, traverse } from './search';
|
||||
|
||||
describe('search', () => {
|
||||
describe('traverse', () => {
|
||||
it('expands lists of strings to several rows', () => {
|
||||
const input = { a: ['b', 'c', 'd'] };
|
||||
const output = traverse(input);
|
||||
expect(output).toEqual([
|
||||
{ key: 'a', value: 'b' },
|
||||
{ key: 'a.b', value: true },
|
||||
{ key: 'a', value: 'c' },
|
||||
{ key: 'a.c', value: true },
|
||||
{ key: 'a', value: 'd' },
|
||||
{ key: 'a.d', value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands objects', () => {
|
||||
const input = { a: { b: { c: 'd' }, e: 'f' } };
|
||||
const output = traverse(input);
|
||||
expect(output).toEqual([
|
||||
{ key: 'a.b.c', value: 'd' },
|
||||
{ key: 'a.e', value: 'f' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands list of objects', () => {
|
||||
const input = { root: { list: [{ a: 1 }, { a: 2 }] } };
|
||||
const output = traverse(input);
|
||||
expect(output).toEqual([
|
||||
{ key: 'root.list.a', value: 1 },
|
||||
{ key: 'root.list.a', value: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips over special keys', () => {
|
||||
const input = {
|
||||
state: { x: 1 },
|
||||
relations: [{ y: 2 }],
|
||||
a: 'a',
|
||||
metadata: {
|
||||
b: 'b',
|
||||
name: 'name',
|
||||
namespace: 'namespace',
|
||||
uid: 'uid',
|
||||
etag: 'etag',
|
||||
generation: 'generation',
|
||||
c: 'c',
|
||||
},
|
||||
d: 'd',
|
||||
};
|
||||
const output = traverse(input);
|
||||
expect(output).toEqual([
|
||||
{ key: 'a', value: 'a' },
|
||||
{ key: 'metadata.b', value: 'b' },
|
||||
{ key: 'metadata.c', value: 'c' },
|
||||
{ key: 'd', value: 'd' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToRows', () => {
|
||||
it('converts base types to strings or null', () => {
|
||||
const input = [
|
||||
{ key: 'a', value: true },
|
||||
{ key: 'b', value: false },
|
||||
{ key: 'c', value: 7 },
|
||||
{ key: 'd', value: 'string' },
|
||||
{ key: 'e', value: null },
|
||||
{ key: 'f', value: undefined },
|
||||
];
|
||||
const output = mapToRows(input, 'eid');
|
||||
expect(output).toEqual([
|
||||
{ entity_id: 'eid', key: 'a', value: 'true' },
|
||||
{ entity_id: 'eid', key: 'b', value: 'false' },
|
||||
{ entity_id: 'eid', key: 'c', value: '7' },
|
||||
{ entity_id: 'eid', key: 'd', value: 'string' },
|
||||
{ entity_id: 'eid', key: 'e', value: null },
|
||||
{ entity_id: 'eid', key: 'f', value: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits lowercase version of keys and values', () => {
|
||||
const input = [{ key: 'fOo', value: 'BaR' }];
|
||||
const output = mapToRows(input, 'eid');
|
||||
expect(output).toEqual([{ entity_id: 'eid', key: 'foo', value: 'bar' }]);
|
||||
});
|
||||
|
||||
it('skips very large values', () => {
|
||||
const input = [{ key: 'foo', value: 'a'.repeat(10000) }];
|
||||
const output = mapToRows(input, 'eid');
|
||||
expect(output).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEntitySearch', () => {
|
||||
it('adds special keys even if missing', () => {
|
||||
const input: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: { name: 'n' },
|
||||
};
|
||||
expect(buildEntitySearch('eid', input)).toEqual([
|
||||
{ entity_id: 'eid', key: 'apiversion', value: 'a' },
|
||||
{ entity_id: 'eid', key: 'kind', value: 'b' },
|
||||
{ entity_id: 'eid', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'metadata.uid', value: null },
|
||||
{
|
||||
entity_id: 'eid',
|
||||
key: 'metadata.namespace',
|
||||
value: ENTITY_DEFAULT_NAMESPACE,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds relations', () => {
|
||||
const input: Entity = {
|
||||
relations: [
|
||||
{ type: 't1', target: { kind: 'k', namespace: 'ns', name: 'a' } },
|
||||
{ type: 't2', target: { kind: 'k', namespace: 'ns', name: 'b' } },
|
||||
],
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: { name: 'n' },
|
||||
};
|
||||
expect(buildEntitySearch('eid', input)).toEqual([
|
||||
{ entity_id: 'eid', key: 'apiversion', value: 'a' },
|
||||
{ entity_id: 'eid', key: 'kind', value: 'b' },
|
||||
{ entity_id: 'eid', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'metadata.uid', value: null },
|
||||
{
|
||||
entity_id: 'eid',
|
||||
key: 'metadata.namespace',
|
||||
value: ENTITY_DEFAULT_NAMESPACE,
|
||||
},
|
||||
{ entity_id: 'eid', key: 'relations', value: 't1:k:ns/a' },
|
||||
{ entity_id: 'eid', key: 'relations', value: 't2:k:ns/b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* 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 {
|
||||
Entity,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
export type DbSearchRow = {
|
||||
entity_id: string;
|
||||
key: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
// These are excluded in the generic loop, either because they do not make sense
|
||||
// to index, or because they are special-case always inserted whether they are
|
||||
// null or not
|
||||
const SPECIAL_KEYS = [
|
||||
'state',
|
||||
'relations',
|
||||
'metadata.name',
|
||||
'metadata.namespace',
|
||||
'metadata.uid',
|
||||
'metadata.etag',
|
||||
'metadata.generation',
|
||||
];
|
||||
|
||||
// The maximum length allowed for search values. These columns are indexed, and
|
||||
// database engines do not like to index on massive values. For example,
|
||||
// postgres will balk after 8191 byte line sizes.
|
||||
const MAX_VALUE_LENGTH = 200;
|
||||
|
||||
type Kv = {
|
||||
key: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
// Helper for traversing through a nested structure and outputting a list of
|
||||
// path->value entries of the leaves.
|
||||
//
|
||||
// For example, this yaml structure
|
||||
//
|
||||
// a: 1
|
||||
// b:
|
||||
// c: null
|
||||
// e: [f, g]
|
||||
// h:
|
||||
// - i: 1
|
||||
// j: k
|
||||
// - i: 2
|
||||
// j: l
|
||||
//
|
||||
// will result in
|
||||
//
|
||||
// "a", 1
|
||||
// "b.c", null
|
||||
// "b.e": "f"
|
||||
// "b.e.f": true
|
||||
// "b.e": "g"
|
||||
// "b.e.g": true
|
||||
// "h.i": 1
|
||||
// "h.j": "k"
|
||||
// "h.i": 2
|
||||
// "h.j": "l"
|
||||
export function traverse(root: unknown): Kv[] {
|
||||
const output: Kv[] = [];
|
||||
|
||||
function visit(path: string, current: unknown) {
|
||||
if (SPECIAL_KEYS.includes(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// empty or scalar
|
||||
if (
|
||||
current === undefined ||
|
||||
current === null ||
|
||||
['string', 'number', 'boolean'].includes(typeof current)
|
||||
) {
|
||||
output.push({ key: path, value: current });
|
||||
return;
|
||||
}
|
||||
|
||||
// unknown
|
||||
if (typeof current !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
// array
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) {
|
||||
// NOTE(freben): The reason that these are output in two different ways,
|
||||
// is to support use cases where you want to express that MORE than one
|
||||
// tag is present in a list. Since the EntityFilters structure is a
|
||||
// record, you can't have several entries of the same key. Therefore
|
||||
// you will have to match on
|
||||
//
|
||||
// { "a.b": ["true"], "a.c": ["true"] }
|
||||
//
|
||||
// rather than
|
||||
//
|
||||
// { "a": ["b", "c"] }
|
||||
//
|
||||
// because the latter means EITHER b or c has to be present.
|
||||
visit(path, item);
|
||||
if (typeof item === 'string') {
|
||||
output.push({ key: `${path}.${item}`, value: true });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// object
|
||||
for (const [key, value] of Object.entries(current!)) {
|
||||
visit(path ? `${path}.${key}` : key, value);
|
||||
}
|
||||
}
|
||||
|
||||
visit('', root);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Translates a number of raw data rows to search table rows
|
||||
export function mapToRows(input: Kv[], entityId: string): DbSearchRow[] {
|
||||
const result: DbSearchRow[] = [];
|
||||
|
||||
for (const { key: rawKey, value: rawValue } of input) {
|
||||
const key = rawKey.toLocaleLowerCase('en-US');
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
result.push({ entity_id: entityId, key, value: null });
|
||||
} else {
|
||||
const value = String(rawValue).toLocaleLowerCase('en-US');
|
||||
if (value.length <= MAX_VALUE_LENGTH) {
|
||||
result.push({ entity_id: entityId, key, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates all of the search rows that are relevant for this entity.
|
||||
*
|
||||
* @param entityId The uid of the entity
|
||||
* @param entity The entity
|
||||
* @returns A list of entity search rows
|
||||
*/
|
||||
export function buildEntitySearch(
|
||||
entityId: string,
|
||||
entity: Entity,
|
||||
): DbSearchRow[] {
|
||||
// Visit the base structure recursively
|
||||
const raw = traverse(entity);
|
||||
|
||||
// Start with some special keys that are always present because you want to
|
||||
// be able to easily search for null specifically
|
||||
raw.push({ key: 'metadata.name', value: entity.metadata.name });
|
||||
raw.push({ key: 'metadata.namespace', value: entity.metadata.namespace });
|
||||
raw.push({ key: 'metadata.uid', value: entity.metadata.uid });
|
||||
|
||||
// Namespace not specified has the default value "default", so we want to
|
||||
// match on that as well
|
||||
if (!entity.metadata.namespace) {
|
||||
raw.push({ key: 'metadata.namespace', value: ENTITY_DEFAULT_NAMESPACE });
|
||||
}
|
||||
|
||||
// Visit relations
|
||||
for (const relation of entity.relations ?? []) {
|
||||
raw.push({
|
||||
key: 'relations',
|
||||
value: `${relation.type}:${stringifyEntityRef(relation.target)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return mapToRows(raw, entityId);
|
||||
}
|
||||
@@ -120,6 +120,7 @@ export type ReplaceProcessingItemsRequest =
|
||||
removed: Entity[];
|
||||
type: 'delta';
|
||||
};
|
||||
|
||||
export interface ProcessingStateManager {
|
||||
setProcessingItemResult(result: ProcessingItemResult): Promise<void>;
|
||||
getNextProcessingItem(): Promise<ProcessingItem>;
|
||||
|
||||
Reference in New Issue
Block a user