From 0305dc2c1ecf103b1160a139cb685d3f5b2aedc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 12 May 2026 09:19:23 +0200 Subject: [PATCH] fix(catalog-backend): use shared NULL_SENTINEL constant in dedup keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move NULL_SENTINEL to util.ts and import it in buildEntitySearch and syncSearchRows instead of hardcoding '\x01'. Keeps the dedup keys consistent with filterSentinelValues and the SQL COALESCE(…, chr(1)) logic, avoiding drift if the sentinel ever changes. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../operations/stitcher/buildEntitySearch.ts | 3 ++- .../database/operations/stitcher/syncSearchRows.ts | 14 ++++---------- .../src/database/operations/stitcher/util.ts | 6 ++++++ 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts b/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts index bbee57c84f..341420655d 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch.ts @@ -17,6 +17,7 @@ import { DEFAULT_NAMESPACE, Entity } from '@backstage/catalog-model'; import { InputError } from '@backstage/errors'; import { DbSearchRow } from '../../tables'; +import { NULL_SENTINEL } from './util'; // 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 @@ -235,7 +236,7 @@ export function buildEntitySearch( // violate the unique constraint on (entity_id, key, value). const seen = new Set(); return rows.filter(row => { - const k = `${row.key}\0${row.value === null ? '\x01' : row.value}`; + const k = `${row.key}\0${row.value === null ? NULL_SENTINEL : row.value}`; if (seen.has(k)) return false; seen.add(k); return true; diff --git a/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts b/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts index dce65b4109..1dde84c017 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/syncSearchRows.ts @@ -16,15 +16,7 @@ import { Knex } from 'knex'; import { DbSearchRow } from '../../tables'; -import { BATCH_SIZE } from './util'; - -// The Postgres sync uses COALESCE(x, NULL_SENTINEL) to allow Postgres to -// include nullable columns in the Hash Cond of anti-joins (IS NOT DISTINCT -// FROM prevents this). As a consequence, values that are exactly this -// sentinel character are not searchable — they would be treated as NULL. -// This is the SOH (Start of Heading) control character which does not -// appear in real entity metadata. -const NULL_SENTINEL = '\x01'; +import { BATCH_SIZE, NULL_SENTINEL } from './util'; function filterSentinelValues(entries: DbSearchRow[]): DbSearchRow[] { return entries.filter( @@ -55,7 +47,9 @@ export async function syncSearchRows( // consistently pick the same original_value for a given input order. const dedupMap = new Map(); for (const entry of searchEntries) { - const k = `${entry.key}\0${entry.value === null ? '\x01' : entry.value}`; + const k = `${entry.key}\0${ + entry.value === null ? NULL_SENTINEL : entry.value + }`; if (!dedupMap.has(k)) { dedupMap.set(k, entry); } diff --git a/plugins/catalog-backend/src/database/operations/stitcher/util.ts b/plugins/catalog-backend/src/database/operations/stitcher/util.ts index 4a2dabcfa6..bac099514a 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/util.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/util.ts @@ -24,6 +24,12 @@ import stableStringify from 'fast-json-stable-stringify'; // enough to get the speed benefits. export const BATCH_SIZE = 50; +// The SOH (Start of Heading) control character, used as a stand-in for NULL +// in contexts where NULL cannot participate in equality comparisons (SQL +// COALESCE, JS dedup keys). It cannot appear in real entity metadata values +// since they are human-readable strings. +export const NULL_SENTINEL = '\x01'; + export function generateStableHash(entity: Entity) { return createHash('sha1') .update(stableStringify({ ...entity }))