@backstage/plugin-catalog-backend 4.0.1-next.2 → 4.0.1-next.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/config.schema.json +2 -2
- package/dist/database/operations/stitcher/getDeferredStitchableEntities.cjs.js +19 -4
- package/dist/database/operations/stitcher/getDeferredStitchableEntities.cjs.js.map +1 -1
- package/dist/database/operations/stitcher/markDeferredStitchFailed.cjs.js +12 -0
- package/dist/database/operations/stitcher/markDeferredStitchFailed.cjs.js.map +1 -0
- package/dist/database/operations/stitcher/markForStitching.cjs.js +4 -2
- package/dist/database/operations/stitcher/markForStitching.cjs.js.map +1 -1
- package/dist/database/operations/stitcher/performStitching.cjs.js +14 -0
- package/dist/database/operations/stitcher/performStitching.cjs.js.map +1 -1
- package/dist/database/operations/util/deleteOrphanedEntities.cjs.js +49 -22
- package/dist/database/operations/util/deleteOrphanedEntities.cjs.js.map +1 -1
- package/dist/stitching/types.cjs.js +13 -0
- package/dist/stitching/types.cjs.js.map +1 -1
- package/migrations/20261007000000_stitch_failure_count.js +31 -0
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# @backstage/plugin-catalog-backend
|
|
2
2
|
|
|
3
|
+
## 4.0.1-next.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- aa6e7c6: Reduce repeated work for entities that fail stitching by gradually increasing retry delays with jitter. Failed entities remain eligible for recovery, and new stitching requests reset their failure count.
|
|
8
|
+
- d7cc6e5: Reject non-positive stitching polling intervals and lease durations at startup to prevent continuous polling and immediate lease expiry. Configuration errors identify the setting that needs correction.
|
|
9
|
+
- 7c376ff: Improved the performance and planner stability of orphan entity cleanup on large PostgreSQL catalogs. Orphan deletion and updates to related entities are now atomic, so a failed cleanup does not leave related entities waiting indefinitely for updated relations. Cleanup also preserves entities when a new reference is committed while it waits to delete them.
|
|
10
|
+
- Updated dependencies
|
|
11
|
+
- @backstage/catalog-client@1.16.3-next.1
|
|
12
|
+
- @backstage/backend-plugin-api@1.11.0-next.2
|
|
13
|
+
|
|
3
14
|
## 4.0.1-next.2
|
|
4
15
|
|
|
5
16
|
### Patch Changes
|
package/config.schema.json
CHANGED
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
"type": "string"
|
|
170
170
|
}
|
|
171
171
|
],
|
|
172
|
-
"description": "
|
|
172
|
+
"description": "Interval between queue polls. Must be greater than zero."
|
|
173
173
|
},
|
|
174
174
|
"stitchTimeout": {
|
|
175
175
|
"anyOf": [
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
"type": "string"
|
|
208
208
|
}
|
|
209
209
|
],
|
|
210
|
-
"description": "
|
|
210
|
+
"description": "Lease duration before unfinished work can be reclaimed. Must be greater than zero. This does not cancel an attempt when its lease expires."
|
|
211
211
|
}
|
|
212
212
|
},
|
|
213
213
|
"description": "The strategy to use when stitching together the final entities."
|
|
@@ -10,7 +10,7 @@ async function getDeferredStitchableEntities(options) {
|
|
|
10
10
|
knex.client.config.client
|
|
11
11
|
);
|
|
12
12
|
const run = async (tx) => {
|
|
13
|
-
const items = await tx("stitch_queue").select("entity_ref", "next_stitch_at", "stitch_ticket").where("next_stitch_at", "<=", tx.fn.now()).orderBy("next_stitch_at", "asc").limit(batchSize).modify((qb) => {
|
|
13
|
+
const items = await tx("stitch_queue").select("entity_ref", "next_stitch_at", "stitch_ticket", "failure_count").where("next_stitch_at", "<=", tx.fn.now()).orderBy("next_stitch_at", "asc").limit(batchSize).modify((qb) => {
|
|
14
14
|
if (useLocking) {
|
|
15
15
|
qb.forUpdate().skipLocked();
|
|
16
16
|
}
|
|
@@ -19,11 +19,26 @@ async function getDeferredStitchableEntities(options) {
|
|
|
19
19
|
return [];
|
|
20
20
|
}
|
|
21
21
|
const stitchTicket = node_crypto.randomUUID();
|
|
22
|
+
const base = types.durationToMilliseconds(stitchTimeout);
|
|
23
|
+
const nextStitchAt = (failureCount) => {
|
|
24
|
+
const delay = Math.max(
|
|
25
|
+
base,
|
|
26
|
+
Math.min(base * (failureCount + 1) ** 2, 36e5)
|
|
27
|
+
);
|
|
28
|
+
const jitter = failureCount > 0 ? delay * 0.1 * Math.random() : 0;
|
|
29
|
+
return nowPlus(tx, delay + jitter);
|
|
30
|
+
};
|
|
22
31
|
const update = tx("stitch_queue").whereIn(
|
|
23
32
|
"entity_ref",
|
|
24
33
|
items.map((i) => i.entity_ref)
|
|
25
34
|
).update({
|
|
26
|
-
next_stitch_at:
|
|
35
|
+
next_stitch_at: tx.raw(
|
|
36
|
+
`case entity_ref ${items.map(() => "when ? then ?").join(" ")} end`,
|
|
37
|
+
items.flatMap((item) => [
|
|
38
|
+
item.entity_ref,
|
|
39
|
+
nextStitchAt(item.failure_count)
|
|
40
|
+
])
|
|
41
|
+
),
|
|
27
42
|
stitch_ticket: stitchTicket
|
|
28
43
|
});
|
|
29
44
|
let leases;
|
|
@@ -58,8 +73,8 @@ async function getDeferredStitchableEntities(options) {
|
|
|
58
73
|
};
|
|
59
74
|
return knex.isTransaction ? await run(knex) : await knex.transaction(run);
|
|
60
75
|
}
|
|
61
|
-
function nowPlus(knex,
|
|
62
|
-
const seconds =
|
|
76
|
+
function nowPlus(knex, milliseconds) {
|
|
77
|
+
const seconds = milliseconds / 1e3;
|
|
63
78
|
if (knex.client.config.client.includes("sqlite3")) {
|
|
64
79
|
return knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]);
|
|
65
80
|
} else if (knex.client.config.client.includes("mysql")) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"getDeferredStitchableEntities.cjs.js","sources":["../../../../src/database/operations/stitcher/getDeferredStitchableEntities.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { durationToMilliseconds, HumanDuration } from '@backstage/types';\nimport { Knex } from 'knex';\nimport { DateTime } from 'luxon';\nimport { randomUUID } from 'node:crypto';\nimport { timestampToDateTime } from '../../conversion';\nimport { DbStitchQueueRow } from '../../tables';\n\n/**\n * Opaque lease value captured by a deferred stitch claim. Pass it unchanged\n * back to completion using the same database. Do not parse or normalize it:\n * PostgreSQL requires its exact timestamp text to retain microsecond precision;\n * MySQL uses timestamp text to avoid timezone/DST conversion through a Date.\n * SQLite also returns its stored timestamp text.\n */\nexport type StitchLeaseExpiresAt = DbStitchQueueRow['next_stitch_at'] & {\n readonly __stitchLeaseExpiresAt: unique symbol;\n};\n\n
|
|
1
|
+
{"version":3,"file":"getDeferredStitchableEntities.cjs.js","sources":["../../../../src/database/operations/stitcher/getDeferredStitchableEntities.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { durationToMilliseconds, HumanDuration } from '@backstage/types';\nimport { Knex } from 'knex';\nimport { DateTime } from 'luxon';\nimport { randomUUID } from 'node:crypto';\nimport { timestampToDateTime } from '../../conversion';\nimport { DbStitchQueueRow } from '../../tables';\n\n/**\n * Opaque lease value captured by a deferred stitch claim. Pass it unchanged\n * back to completion using the same database. Do not parse or normalize it:\n * PostgreSQL requires its exact timestamp text to retain microsecond precision;\n * MySQL uses timestamp text to avoid timezone/DST conversion through a Date.\n * SQLite also returns its stored timestamp text.\n */\nexport type StitchLeaseExpiresAt = DbStitchQueueRow['next_stitch_at'] & {\n readonly __stitchLeaseExpiresAt: unique symbol;\n};\n\n/**\n * Finds entities that are marked for deferred stitching.\n *\n * @remarks\n *\n * This assumes that the stitching strategy is set to deferred.\n *\n * Each claim replaces stitch_ticket, fencing workers whose leases expired even\n * when no new stitch request was made. A ticket may be shared across a batch;\n * it is always checked together with entity_ref.\n *\n * All returned items have their next_stitch_at updated to be moved forward by\n * the configured timeout, extended by quadratic backoff and jitter after\n * failures. This has the effect that they will be picked up\n * for stitching again in the future, if it hasn't completed by that point for\n * some reason (restarts, crashes, etc).\n */\nexport async function getDeferredStitchableEntities(options: {\n knex: Knex | Knex.Transaction;\n batchSize: number;\n stitchTimeout: HumanDuration;\n}): Promise<\n Array<{\n entityRef: string;\n stitchTicket: string;\n stitchRequestedAt: DateTime; // the time BEFORE moving it forward by the timeout\n stitchLeaseExpiresAt: StitchLeaseExpiresAt;\n }>\n> {\n const { knex, batchSize, stitchTimeout } = options;\n const useLocking = ['mysql', 'mysql2', 'pg'].includes(\n knex.client.config.client,\n );\n\n // The SELECT FOR UPDATE SKIP LOCKED + UPDATE must run inside a single\n // transaction so that the row locks held by FOR UPDATE persist until\n // next_stitch_at has been bumped. Without the transaction the locks\n // are released after the SELECT auto-commits, and another worker can\n // claim the same rows before the UPDATE runs.\n // SQLite also keeps selection and ticket/lease replacement in one transaction,\n // without attempting the row-locking clauses that it does not support.\n const run = async (tx: Knex | Knex.Transaction) => {\n const items: DbStitchQueueRow[] = await tx('stitch_queue')\n .select('entity_ref', 'next_stitch_at', 'stitch_ticket', 'failure_count')\n .where('next_stitch_at', '<=', tx.fn.now())\n .orderBy('next_stitch_at', 'asc')\n .limit(batchSize)\n .modify(qb => {\n if (useLocking) {\n qb.forUpdate().skipLocked();\n }\n });\n\n if (!items.length) {\n return [];\n }\n\n const stitchTicket = randomUUID();\n const base = durationToMilliseconds(stitchTimeout);\n const nextStitchAt = (failureCount: number) => {\n const delay = Math.max(\n base,\n Math.min(base * (failureCount + 1) ** 2, 3_600_000),\n );\n const jitter = failureCount > 0 ? delay * 0.1 * Math.random() : 0;\n return nowPlus(tx, delay + jitter);\n };\n const update = tx<DbStitchQueueRow>('stitch_queue')\n .whereIn(\n 'entity_ref',\n items.map(i => i.entity_ref),\n )\n .update({\n next_stitch_at: tx.raw(\n `case entity_ref ${items.map(() => 'when ? then ?').join(' ')} end`,\n items.flatMap(item => [\n item.entity_ref,\n nextStitchAt(item.failure_count),\n ]),\n ),\n stitch_ticket: stitchTicket,\n });\n\n // Preserve PostgreSQL's full timestamp precision for the completion guard;\n // converting it to a JavaScript Date would truncate microseconds.\n let leases: Pick<DbStitchQueueRow, 'entity_ref' | 'next_stitch_at'>[];\n if (tx.client.config.client === 'pg') {\n leases = await update.returning([\n 'entity_ref',\n tx.raw('next_stitch_at::text as next_stitch_at'),\n ]);\n } else if (String(tx.client.config.client).includes('mysql')) {\n // MySQL has no UPDATE RETURNING; read the lease on the same locked\n // connection before committing the claim. Cast to text so the driver\n // cannot normalize the value through a Date across a local DST gap.\n await update;\n leases = await tx<DbStitchQueueRow>('stitch_queue')\n .select<Pick<DbStitchQueueRow, 'entity_ref' | 'next_stitch_at'>[]>(\n 'entity_ref',\n tx.raw('CAST(next_stitch_at AS CHAR) as next_stitch_at'),\n )\n .whereIn(\n 'entity_ref',\n items.map(i => i.entity_ref),\n );\n } else {\n leases = await update.returning(['entity_ref', 'next_stitch_at']);\n }\n const leaseByRef = new Map(\n leases.map(i => [i.entity_ref, i.next_stitch_at]),\n );\n\n return items.map(i => ({\n entityRef: i.entity_ref,\n stitchTicket,\n stitchRequestedAt: timestampToDateTime(i.next_stitch_at),\n stitchLeaseExpiresAt: leaseByRef.get(\n i.entity_ref,\n )! as StitchLeaseExpiresAt,\n }));\n };\n\n return knex.isTransaction ? await run(knex) : await knex.transaction(run);\n}\n\nfunction nowPlus(knex: Knex, milliseconds: number): Knex.Raw {\n const seconds = milliseconds / 1000;\n if (knex.client.config.client.includes('sqlite3')) {\n return knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]);\n } else if (knex.client.config.client.includes('mysql')) {\n return knex.raw(`now() + interval ${seconds} second`);\n }\n return knex.raw(`now() + interval '${seconds} seconds'`);\n}\n"],"names":["randomUUID","durationToMilliseconds","timestampToDateTime"],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
async function markDeferredStitchFailed(options) {
|
|
4
|
+
const { knex, entityRef, stitchTicket, stitchLeaseExpiresAt } = options;
|
|
5
|
+
const updated = await knex("stitch_queue").where("entity_ref", entityRef).where("stitch_ticket", stitchTicket).where("next_stitch_at", stitchLeaseExpiresAt).increment("failure_count", 1);
|
|
6
|
+
if (!updated) {
|
|
7
|
+
await knex("stitch_queue").where("entity_ref", entityRef).whereNot("stitch_ticket", stitchTicket).where("next_stitch_at", stitchLeaseExpiresAt).update({ next_stitch_at: knex.fn.now() });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
exports.markDeferredStitchFailed = markDeferredStitchFailed;
|
|
12
|
+
//# sourceMappingURL=markDeferredStitchFailed.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markDeferredStitchFailed.cjs.js","sources":["../../../../src/database/operations/stitcher/markDeferredStitchFailed.ts"],"sourcesContent":["/*\n * Copyright 2026 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport { DbStitchQueueRow } from '../../tables';\nimport { StitchLeaseExpiresAt } from './getDeferredStitchableEntities';\n\n/**\n * Records a failed attempt only while its claim still owns the queued request.\n * New requests keep their fresh retry budget and become eligible after this\n * attempt settles. Reclaimed leases must never be changed by a stale failure.\n */\nexport async function markDeferredStitchFailed(options: {\n knex: Knex | Knex.Transaction;\n entityRef: string;\n stitchTicket: string;\n stitchLeaseExpiresAt: StitchLeaseExpiresAt;\n}): Promise<void> {\n const { knex, entityRef, stitchTicket, stitchLeaseExpiresAt } = options;\n const updated = await knex<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', entityRef)\n .where('stitch_ticket', stitchTicket)\n .where('next_stitch_at', stitchLeaseExpiresAt)\n .increment('failure_count', 1);\n\n if (!updated) {\n await knex<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', entityRef)\n .whereNot('stitch_ticket', stitchTicket)\n .where('next_stitch_at', stitchLeaseExpiresAt)\n .update({ next_stitch_at: knex.fn.now() });\n }\n}\n"],"names":[],"mappings":";;AAyBA,eAAsB,yBAAyB,OAAA,EAK7B;AAChB,EAAA,MAAM,EAAE,IAAA,EAAM,SAAA,EAAW,YAAA,EAAc,sBAAqB,GAAI,OAAA;AAChE,EAAA,MAAM,UAAU,MAAM,IAAA,CAAuB,cAAc,CAAA,CACxD,KAAA,CAAM,cAAc,SAAS,CAAA,CAC7B,MAAM,eAAA,EAAiB,YAAY,EACnC,KAAA,CAAM,gBAAA,EAAkB,oBAAoB,CAAA,CAC5C,SAAA,CAAU,iBAAiB,CAAC,CAAA;AAE/B,EAAA,IAAI,CAAC,OAAA,EAAS;AACZ,IAAA,MAAM,IAAA,CAAuB,cAAc,CAAA,CACxC,KAAA,CAAM,cAAc,SAAS,CAAA,CAC7B,QAAA,CAAS,eAAA,EAAiB,YAAY,CAAA,CACtC,MAAM,gBAAA,EAAkB,oBAAoB,EAC5C,MAAA,CAAO,EAAE,gBAAgB,IAAA,CAAK,EAAA,CAAG,GAAA,EAAI,EAAG,CAAA;AAAA,EAC7C;AACF;;"}
|
|
@@ -21,9 +21,10 @@ async function markForStitching(options) {
|
|
|
21
21
|
chunk.map((ref) => ({
|
|
22
22
|
entity_ref: ref,
|
|
23
23
|
stitch_ticket: ticket,
|
|
24
|
+
failure_count: 0,
|
|
24
25
|
next_stitch_at: knex.fn.now()
|
|
25
26
|
}))
|
|
26
|
-
).onConflict("entity_ref").merge(["stitch_ticket"]);
|
|
27
|
+
).onConflict("entity_ref").merge(["stitch_ticket", "failure_count"]);
|
|
27
28
|
}
|
|
28
29
|
}, knex);
|
|
29
30
|
}
|
|
@@ -35,9 +36,10 @@ async function markForStitching(options) {
|
|
|
35
36
|
refreshStateRows.map((row) => ({
|
|
36
37
|
entity_ref: row.entity_ref,
|
|
37
38
|
stitch_ticket: ticket,
|
|
39
|
+
failure_count: 0,
|
|
38
40
|
next_stitch_at: knex.fn.now()
|
|
39
41
|
}))
|
|
40
|
-
).onConflict("entity_ref").merge(["stitch_ticket"]);
|
|
42
|
+
).onConflict("entity_ref").merge(["stitch_ticket", "failure_count"]);
|
|
41
43
|
}
|
|
42
44
|
}, knex);
|
|
43
45
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markForStitching.cjs.js","sources":["../../../../src/database/operations/stitcher/markForStitching.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport splitToChunks from 'lodash/chunk';\nimport { randomUUID as uuid } from 'node:crypto';\nimport { DbRefreshStateRow, DbStitchQueueRow } from '../../tables';\nimport { retryOnDeadlock } from '../../util';\n\nconst UPDATE_CHUNK_SIZE = 100; // Smaller chunks reduce contention\n\n/**\n * Marks a number of entities for stitching some time in the near\n * future.\n *\n * @remarks\n *\n * If there is no existing stitch_queue entry, a new row is created with\n * next_stitch_at set to now() (immediately eligible). If an entry already\n * exists, only the stitch_ticket is updated — the timestamp is left\n * unchanged. This prevents interrupting an in-progress stitch: the worker\n * that claimed the entry pushed next_stitch_at forward by the timeout\n * duration, and we don't want to yank it back to now() which would allow\n * another worker to claim the same entity and cause overlapping stitches.\n *\n * When the in-progress stitch completes, markDeferredStitchCompleted\n * detects the ticket change and makes the entry immediately eligible for\n * the follow-up stitch.\n */\nexport async function markForStitching(options: {\n knex: Knex | Knex.Transaction;\n entityRefs?: Iterable<string>;\n entityIds?: Iterable<string>;\n}): Promise<void> {\n const entityRefs = sortSplit(options.entityRefs);\n const entityIds = sortSplit(options.entityIds);\n const knex = options.knex;\n\n // It's OK that this is shared across stitch_queue rows; it just needs to\n // be uniquely generated for every new stitch request.\n const ticket = uuid();\n\n for (const chunk of entityRefs) {\n await retryOnDeadlock(async () => {\n if (chunk.length > 0) {\n await knex<DbStitchQueueRow>('stitch_queue')\n .insert(\n chunk.map(ref => ({\n entity_ref: ref,\n stitch_ticket: ticket,\n next_stitch_at: knex.fn.now(),\n })),\n )\n .onConflict('entity_ref')\n .merge(['stitch_ticket']);\n }\n }, knex);\n }\n\n for (const chunk of entityIds) {\n await retryOnDeadlock(async () => {\n // Look up entity_refs from refresh_state by entity_id\n const refreshStateRows = await knex<DbRefreshStateRow>('refresh_state')\n .select('entity_ref')\n .whereIn('entity_id', chunk);\n\n if (refreshStateRows.length > 0) {\n await knex<DbStitchQueueRow>('stitch_queue')\n .insert(\n refreshStateRows.map(row => ({\n entity_ref: row.entity_ref,\n stitch_ticket: ticket,\n next_stitch_at: knex.fn.now(),\n })),\n )\n .onConflict('entity_ref')\n .merge(['stitch_ticket']);\n }\n }, knex);\n }\n}\n\nfunction sortSplit(input: Iterable<string> | undefined): string[][] {\n if (!input) {\n return [];\n }\n const array = Array.isArray(input) ? input.slice() : [...input];\n array.sort();\n return splitToChunks(array, UPDATE_CHUNK_SIZE);\n}\n"],"names":["uuid","retryOnDeadlock","splitToChunks"],"mappings":";;;;;;;;;;AAsBA,MAAM,iBAAA,GAAoB,GAAA;AAoB1B,eAAsB,iBAAiB,OAAA,EAIrB;AAChB,EAAA,MAAM,UAAA,GAAa,SAAA,CAAU,OAAA,CAAQ,UAAU,CAAA;AAC/C,EAAA,MAAM,SAAA,GAAY,SAAA,CAAU,OAAA,CAAQ,SAAS,CAAA;AAC7C,EAAA,MAAM,OAAO,OAAA,CAAQ,IAAA;AAIrB,EAAA,MAAM,SAASA,sBAAA,EAAK;AAEpB,EAAA,KAAA,MAAW,SAAS,UAAA,EAAY;AAC9B,IAAA,MAAMC,qBAAgB,YAAY;AAChC,MAAA,IAAI,KAAA,CAAM,SAAS,CAAA,EAAG;AACpB,QAAA,MAAM,IAAA,CAAuB,cAAc,CAAA,CACxC,MAAA;AAAA,UACC,KAAA,CAAM,IAAI,CAAA,GAAA,MAAQ;AAAA,YAChB,UAAA,EAAY,GAAA;AAAA,YACZ,aAAA,EAAe,MAAA;AAAA,YACf,cAAA,EAAgB,IAAA,CAAK,EAAA,CAAG,GAAA;AAAI,WAC9B,CAAE;AAAA,
|
|
1
|
+
{"version":3,"file":"markForStitching.cjs.js","sources":["../../../../src/database/operations/stitcher/markForStitching.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport splitToChunks from 'lodash/chunk';\nimport { randomUUID as uuid } from 'node:crypto';\nimport { DbRefreshStateRow, DbStitchQueueRow } from '../../tables';\nimport { retryOnDeadlock } from '../../util';\n\nconst UPDATE_CHUNK_SIZE = 100; // Smaller chunks reduce contention\n\n/**\n * Marks a number of entities for stitching some time in the near\n * future.\n *\n * @remarks\n *\n * If there is no existing stitch_queue entry, a new row is created with\n * next_stitch_at set to now() (immediately eligible). If an entry already\n * exists, only the stitch_ticket is updated — the timestamp is left\n * unchanged. This prevents interrupting an in-progress stitch: the worker\n * that claimed the entry pushed next_stitch_at forward by the timeout\n * duration, and we don't want to yank it back to now() which would allow\n * another worker to claim the same entity and cause overlapping stitches.\n *\n * When the in-progress stitch completes, markDeferredStitchCompleted\n * detects the ticket change and makes the entry immediately eligible for\n * the follow-up stitch.\n */\nexport async function markForStitching(options: {\n knex: Knex | Knex.Transaction;\n entityRefs?: Iterable<string>;\n entityIds?: Iterable<string>;\n}): Promise<void> {\n const entityRefs = sortSplit(options.entityRefs);\n const entityIds = sortSplit(options.entityIds);\n const knex = options.knex;\n\n // It's OK that this is shared across stitch_queue rows; it just needs to\n // be uniquely generated for every new stitch request.\n const ticket = uuid();\n\n for (const chunk of entityRefs) {\n await retryOnDeadlock(async () => {\n if (chunk.length > 0) {\n await knex<DbStitchQueueRow>('stitch_queue')\n .insert(\n chunk.map(ref => ({\n entity_ref: ref,\n stitch_ticket: ticket,\n failure_count: 0,\n next_stitch_at: knex.fn.now(),\n })),\n )\n .onConflict('entity_ref')\n .merge(['stitch_ticket', 'failure_count']);\n }\n }, knex);\n }\n\n for (const chunk of entityIds) {\n await retryOnDeadlock(async () => {\n // Look up entity_refs from refresh_state by entity_id\n const refreshStateRows = await knex<DbRefreshStateRow>('refresh_state')\n .select('entity_ref')\n .whereIn('entity_id', chunk);\n\n if (refreshStateRows.length > 0) {\n await knex<DbStitchQueueRow>('stitch_queue')\n .insert(\n refreshStateRows.map(row => ({\n entity_ref: row.entity_ref,\n stitch_ticket: ticket,\n failure_count: 0,\n next_stitch_at: knex.fn.now(),\n })),\n )\n .onConflict('entity_ref')\n .merge(['stitch_ticket', 'failure_count']);\n }\n }, knex);\n }\n}\n\nfunction sortSplit(input: Iterable<string> | undefined): string[][] {\n if (!input) {\n return [];\n }\n const array = Array.isArray(input) ? input.slice() : [...input];\n array.sort();\n return splitToChunks(array, UPDATE_CHUNK_SIZE);\n}\n"],"names":["uuid","retryOnDeadlock","splitToChunks"],"mappings":";;;;;;;;;;AAsBA,MAAM,iBAAA,GAAoB,GAAA;AAoB1B,eAAsB,iBAAiB,OAAA,EAIrB;AAChB,EAAA,MAAM,UAAA,GAAa,SAAA,CAAU,OAAA,CAAQ,UAAU,CAAA;AAC/C,EAAA,MAAM,SAAA,GAAY,SAAA,CAAU,OAAA,CAAQ,SAAS,CAAA;AAC7C,EAAA,MAAM,OAAO,OAAA,CAAQ,IAAA;AAIrB,EAAA,MAAM,SAASA,sBAAA,EAAK;AAEpB,EAAA,KAAA,MAAW,SAAS,UAAA,EAAY;AAC9B,IAAA,MAAMC,qBAAgB,YAAY;AAChC,MAAA,IAAI,KAAA,CAAM,SAAS,CAAA,EAAG;AACpB,QAAA,MAAM,IAAA,CAAuB,cAAc,CAAA,CACxC,MAAA;AAAA,UACC,KAAA,CAAM,IAAI,CAAA,GAAA,MAAQ;AAAA,YAChB,UAAA,EAAY,GAAA;AAAA,YACZ,aAAA,EAAe,MAAA;AAAA,YACf,aAAA,EAAe,CAAA;AAAA,YACf,cAAA,EAAgB,IAAA,CAAK,EAAA,CAAG,GAAA;AAAI,WAC9B,CAAE;AAAA,SACJ,CACC,WAAW,YAAY,CAAA,CACvB,MAAM,CAAC,eAAA,EAAiB,eAAe,CAAC,CAAA;AAAA,MAC7C;AAAA,IACF,GAAG,IAAI,CAAA;AAAA,EACT;AAEA,EAAA,KAAA,MAAW,SAAS,SAAA,EAAW;AAC7B,IAAA,MAAMA,qBAAgB,YAAY;AAEhC,MAAA,MAAM,gBAAA,GAAmB,MAAM,IAAA,CAAwB,eAAe,CAAA,CACnE,OAAO,YAAY,CAAA,CACnB,OAAA,CAAQ,WAAA,EAAa,KAAK,CAAA;AAE7B,MAAA,IAAI,gBAAA,CAAiB,SAAS,CAAA,EAAG;AAC/B,QAAA,MAAM,IAAA,CAAuB,cAAc,CAAA,CACxC,MAAA;AAAA,UACC,gBAAA,CAAiB,IAAI,CAAA,GAAA,MAAQ;AAAA,YAC3B,YAAY,GAAA,CAAI,UAAA;AAAA,YAChB,aAAA,EAAe,MAAA;AAAA,YACf,aAAA,EAAe,CAAA;AAAA,YACf,cAAA,EAAgB,IAAA,CAAK,EAAA,CAAG,GAAA;AAAI,WAC9B,CAAE;AAAA,SACJ,CACC,WAAW,YAAY,CAAA,CACvB,MAAM,CAAC,eAAA,EAAiB,eAAe,CAAC,CAAA;AAAA,MAC7C;AAAA,IACF,GAAG,IAAI,CAAA;AAAA,EACT;AACF;AAEA,SAAS,UAAU,KAAA,EAAiD;AAClE,EAAA,IAAI,CAAC,KAAA,EAAO;AACV,IAAA,OAAO,EAAC;AAAA,EACV;AACA,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,GAAI,MAAM,KAAA,EAAM,GAAI,CAAC,GAAG,KAAK,CAAA;AAC9D,EAAA,KAAA,CAAM,IAAA,EAAK;AACX,EAAA,OAAOC,8BAAA,CAAc,OAAO,iBAAiB,CAAA;AAC/C;;"}
|
|
@@ -6,6 +6,7 @@ var node_crypto = require('node:crypto');
|
|
|
6
6
|
var stableStringify = require('fast-json-stable-stringify');
|
|
7
7
|
var buildEntitySearch = require('./buildEntitySearch.cjs.js');
|
|
8
8
|
var markDeferredStitchCompleted = require('./markDeferredStitchCompleted.cjs.js');
|
|
9
|
+
var markDeferredStitchFailed = require('./markDeferredStitchFailed.cjs.js');
|
|
9
10
|
var syncSearchRows = require('./syncSearchRows.cjs.js');
|
|
10
11
|
var util = require('../../util.cjs.js');
|
|
11
12
|
|
|
@@ -181,6 +182,19 @@ async function performStitching(options) {
|
|
|
181
182
|
stitchResult = "succeeded";
|
|
182
183
|
return "changed";
|
|
183
184
|
} catch (error) {
|
|
185
|
+
try {
|
|
186
|
+
await markDeferredStitchFailed.markDeferredStitchFailed({
|
|
187
|
+
knex,
|
|
188
|
+
entityRef,
|
|
189
|
+
stitchTicket,
|
|
190
|
+
stitchLeaseExpiresAt
|
|
191
|
+
});
|
|
192
|
+
} catch (recordError) {
|
|
193
|
+
logger.warn(
|
|
194
|
+
`Failed to record stitch failure for ${entityRef}`,
|
|
195
|
+
recordError
|
|
196
|
+
);
|
|
197
|
+
}
|
|
184
198
|
throw error;
|
|
185
199
|
} finally {
|
|
186
200
|
if (stitchResult) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"performStitching.cjs.js","sources":["../../../../src/database/operations/stitcher/performStitching.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from '@backstage/catalog-client';\nimport {\n ANNOTATION_EDIT_URL,\n ANNOTATION_VIEW_URL,\n Entity,\n EntityRelation,\n} from '@backstage/catalog-model';\nimport { AlphaEntity, EntityStatusItem } from '@backstage/catalog-model/alpha';\nimport { SerializedError } from '@backstage/errors';\nimport { Knex } from 'knex';\nimport { createHash } from 'node:crypto';\nimport stableStringify from 'fast-json-stable-stringify';\nimport { DbFinalEntitiesRow, DbStitchQueueRow } from '../../tables';\nimport { buildEntitySearch } from './buildEntitySearch';\nimport { markDeferredStitchCompleted } from './markDeferredStitchCompleted';\nimport { syncSearchRows } from './syncSearchRows';\nimport { StitchLeaseExpiresAt } from './getDeferredStitchableEntities';\nimport { LoggerService } from '@backstage/backend-plugin-api';\nimport { retryOnDeadlock } from '../../util';\n\nclass StitchPublicationSupersededError extends Error {}\n\nfunction generateStableHash(entity: Entity) {\n return createHash('sha1')\n .update(stableStringify({ ...entity }))\n .digest('hex');\n}\n\n// See https://github.com/facebook/react/blob/f0cf832e1d0c8544c36aa8b310960885a11a847c/packages/react-dom-bindings/src/shared/sanitizeURL.js\nconst scriptProtocolPattern =\n // eslint-disable-next-line no-control-regex\n /^[\\u0000-\\u001F ]*j[\\r\\n\\t]*a[\\r\\n\\t]*v[\\r\\n\\t]*a[\\r\\n\\t]*s[\\r\\n\\t]*c[\\r\\n\\t]*r[\\r\\n\\t]*i[\\r\\n\\t]*p[\\r\\n\\t]*t[\\r\\n\\t]*\\:/i;\n\n/**\n * Performs the act of stitching - to take all of the various outputs from the\n * ingestion process, and stitching them together into the final entity JSON\n * shape.\n */\nexport async function performStitching(options: {\n knex: Knex | Knex.Transaction;\n logger: LoggerService;\n entityRef: string;\n stitchTicket: string;\n stitchLeaseExpiresAt: StitchLeaseExpiresAt;\n}): Promise<'changed' | 'unchanged' | 'abandoned'> {\n const { knex, logger, entityRef, stitchTicket, stitchLeaseExpiresAt } =\n options;\n\n // Settle the claim on any completion, without disturbing a successor's\n // lease. A new request during this lease becomes eligible immediately.\n // Exceptions skip cleanup so the entity gets retried at a later time.\n let stitchResult: 'succeeded' | 'abandoned' | undefined;\n\n try {\n // Selecting from refresh_state (with an optional left join to\n // final_entities for the previous hash) should yield exactly one row,\n // except in abnormal cases where the entity was deleted between the\n // stitch request and now.\n const [processedResult, relationsResult] = await Promise.all([\n knex\n .with('incoming_references', function incomingReferences(builder) {\n return builder\n .from('refresh_state_references')\n .where({ target_entity_ref: entityRef })\n .count({ count: '*' });\n })\n .select({\n entityId: 'refresh_state.entity_id',\n processedEntity: 'refresh_state.processed_entity',\n errors: 'refresh_state.errors',\n incomingReferenceCount: 'incoming_references.count',\n previousHash: 'final_entities.hash',\n })\n .from('refresh_state')\n .where({ 'refresh_state.entity_ref': entityRef })\n .crossJoin(knex.raw('incoming_references'))\n .leftOuterJoin('final_entities', {\n 'final_entities.entity_id': 'refresh_state.entity_id',\n }),\n knex\n .distinct({\n relationType: 'type',\n relationTarget: 'target_entity_ref',\n })\n .from('relations')\n .where({ source_entity_ref: entityRef })\n .orderBy('relationType', 'asc')\n .orderBy('relationTarget', 'asc'),\n ]);\n\n // If there were no rows returned, it would mean that there was no\n // matching row even in the refresh_state. This can happen for example\n // if we emit a relation to something that hasn't been ingested yet.\n // It's safe to ignore this stitch attempt in that case.\n if (!processedResult.length) {\n logger.debug(\n `Unable to stitch ${entityRef}, item does not exist in refresh state table`,\n );\n stitchResult = 'abandoned';\n return 'abandoned';\n }\n\n const {\n entityId,\n processedEntity,\n errors,\n incomingReferenceCount,\n previousHash,\n } = processedResult[0];\n\n // If there was no processed entity in place, the target hasn't been\n // through the processing steps yet. It's safe to ignore this stitch\n // attempt in that case, since another stitch will be triggered when\n // that processing has finished.\n if (!processedEntity) {\n logger.debug(\n `Unable to stitch ${entityRef}, the entity has not yet been processed`,\n );\n stitchResult = 'abandoned';\n return 'abandoned';\n }\n\n // Grab the processed entity and stitch all of the relevant data into\n // it\n const entity = JSON.parse(processedEntity) as AlphaEntity;\n const isOrphan = Number(incomingReferenceCount) === 0;\n let statusItems: EntityStatusItem[] = [];\n\n if (isOrphan) {\n logger.debug(`${entityRef} is an orphan`);\n entity.metadata.annotations = {\n ...entity.metadata.annotations,\n ['backstage.io/orphan']: 'true',\n };\n }\n if (errors) {\n const parsedErrors = JSON.parse(errors) as SerializedError[];\n if (Array.isArray(parsedErrors) && parsedErrors.length) {\n statusItems = parsedErrors.map(e => ({\n type: ENTITY_STATUS_CATALOG_PROCESSING_TYPE,\n level: 'error',\n message: `${e.name}: ${e.message}`,\n error: e,\n }));\n }\n }\n // We opt to do this check here as we otherwise can't guarantee that it will be run after all processors\n for (const annotation of [ANNOTATION_VIEW_URL, ANNOTATION_EDIT_URL]) {\n const value = entity.metadata.annotations?.[annotation];\n if (typeof value === 'string' && scriptProtocolPattern.test(value)) {\n entity.metadata.annotations![annotation] =\n 'https://backstage.io/annotation-rejected-for-security-reasons';\n }\n }\n\n // TODO: entityRef is lower case and should be uppercase in the final\n // result\n entity.relations = relationsResult\n .filter(row => row.relationType /* exclude null row, if relevant */)\n .map<EntityRelation>(row => ({\n type: row.relationType!,\n targetRef: row.relationTarget!,\n }));\n if (statusItems.length) {\n entity.status = {\n ...entity.status,\n items: [...(entity.status?.items ?? []), ...statusItems],\n };\n }\n\n // If the output entity was actually not changed, just abort\n const hash = generateStableHash(entity);\n if (hash === previousHash) {\n logger.debug(`Skipped stitching of ${entityRef}, no changes`);\n stitchResult = 'succeeded';\n return 'unchanged';\n }\n\n entity.metadata.uid = entityId;\n if (!entity.metadata.etag) {\n // If the original data source did not have its own etag handling,\n // use the hash as a good-quality etag\n entity.metadata.etag = hash;\n }\n\n // This may throw if the entity is invalid, so we call it before\n // the final_entities write, even though we may end up not needing\n // to write the search index.\n const searchEntries = buildEntitySearch(entityId, entity);\n\n // Guard against concurrent stitchers: if our stitch_ticket no longer\n // matches stitch_queue, another worker has newer data and we should\n // not overwrite it. PostgreSQL guards both insert and merge in the\n // publication statement and rechecks the captured claim after the upsert.\n // SQLite checks inside its transaction as well\n // as guarding the merge path. MySQL does not support\n // ON CONFLICT ... DO UPDATE ... WHERE, so its separate check retains\n // a best-effort TOCTOU window. Do not turn it into a queue lock here:\n // that would introduce a new lock-order dependency during publication.\n const isMySQL = String(knex.client.config.client).includes('mysql');\n const isPostgres = knex.client.config.client === 'pg';\n\n // The final_entities row and the search index rows have to land\n // together. If the search write fails on its own, the next stitch\n // attempt reads back the hash we just wrote and returns early as\n // unchanged, so the entity keeps a stale search index for good. It\n // stays readable by direct lookup and drops out of every filtered or\n // sorted list query.\n const writeOutcome = await retryOnDeadlock(\n () =>\n knex.transaction(async tx => {\n // Recheck on every attempt: a request or reclaimed claim may have\n // superseded this worker while its previous transaction rolled back.\n // Do not lock the queue here; processing locks refresh state before queue.\n if (!isPostgres) {\n const ticketValid = await tx<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', entityRef)\n .where('stitch_ticket', stitchTicket)\n .first();\n if (!ticketValid) return 'abandoned' as const;\n }\n // Knex's typed insert overload excludes raw INSERT SELECT bodies.\n let upsert = tx('final_entities')\n .insert(\n !isPostgres\n ? {\n entity_id: entityId,\n entity_ref: entityRef,\n final_entity: JSON.stringify(entity),\n hash,\n last_updated_at: tx.fn.now(),\n }\n : tx.raw(\n '(entity_id, entity_ref, final_entity, hash, last_updated_at) ' +\n 'select ?, ?, ?, ?, CURRENT_TIMESTAMP ' +\n 'where exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)',\n [\n entityId,\n entityRef,\n JSON.stringify(entity),\n hash,\n entityRef,\n stitchTicket,\n ],\n ),\n )\n .onConflict('entity_id')\n .merge(['final_entity', 'hash', 'last_updated_at']);\n\n if (!isMySQL) {\n upsert = upsert.where(\n tx.raw(\n 'exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)',\n [entityRef, stitchTicket],\n ),\n );\n }\n\n await upsert;\n\n // Verify the write took effect. On PostgreSQL, also recheck ownership\n // using this statement's fresh Read Committed snapshot: the upsert's\n // snapshot may predate a reclaim that committed while it was running.\n // A successful upsert holds the final-row lock until commit, so a\n // successor cannot publish past us after this check. Do not lock the\n // queue, which would introduce a publication/deletion lock inversion.\n if (!isMySQL) {\n const written = await tx<DbFinalEntitiesRow>('final_entities')\n .where('entity_id', entityId)\n .where('hash', hash)\n .modify(qb => {\n if (isPostgres) {\n qb.whereExists(\n tx<DbStitchQueueRow>('stitch_queue')\n .select(tx.raw('1'))\n .where('entity_ref', entityRef)\n .where('stitch_ticket', stitchTicket)\n .where('next_stitch_at', stitchLeaseExpiresAt),\n );\n }\n })\n .select(tx.raw('1'))\n .first();\n if (!written) {\n // Returning normally would commit any stale write already made.\n throw new StitchPublicationSupersededError();\n }\n }\n\n await syncSearchRows(tx, entityId, searchEntries);\n\n return 'changed' as const;\n }),\n knex,\n ).catch(error => {\n // Knex has rolled the publication back before rejecting the transaction.\n // Supersession is a normal abandonment, not a failure or deadlock retry.\n if (error instanceof StitchPublicationSupersededError) {\n return 'abandoned' as const;\n }\n throw error;\n });\n\n if (writeOutcome === 'abandoned') {\n logger.debug(`Entity ${entityRef} is already stitched, skipping write.`);\n stitchResult = 'abandoned';\n return 'abandoned';\n }\n\n stitchResult = 'succeeded';\n return 'changed';\n } catch (error) {\n throw error;\n } finally {\n if (stitchResult) {\n await markDeferredStitchCompleted({\n knex: knex,\n entityRef,\n stitchTicket,\n stitchLeaseExpiresAt,\n });\n }\n }\n}\n"],"names":["createHash","stableStringify","ENTITY_STATUS_CATALOG_PROCESSING_TYPE","ANNOTATION_VIEW_URL","ANNOTATION_EDIT_URL","buildEntitySearch","retryOnDeadlock","syncSearchRows","markDeferredStitchCompleted"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"performStitching.cjs.js","sources":["../../../../src/database/operations/stitcher/performStitching.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from '@backstage/catalog-client';\nimport {\n ANNOTATION_EDIT_URL,\n ANNOTATION_VIEW_URL,\n Entity,\n EntityRelation,\n} from '@backstage/catalog-model';\nimport { AlphaEntity, EntityStatusItem } from '@backstage/catalog-model/alpha';\nimport { SerializedError } from '@backstage/errors';\nimport { Knex } from 'knex';\nimport { createHash } from 'node:crypto';\nimport stableStringify from 'fast-json-stable-stringify';\nimport { DbFinalEntitiesRow, DbStitchQueueRow } from '../../tables';\nimport { buildEntitySearch } from './buildEntitySearch';\nimport { markDeferredStitchCompleted } from './markDeferredStitchCompleted';\nimport { markDeferredStitchFailed } from './markDeferredStitchFailed';\nimport { syncSearchRows } from './syncSearchRows';\nimport { StitchLeaseExpiresAt } from './getDeferredStitchableEntities';\nimport { LoggerService } from '@backstage/backend-plugin-api';\nimport { retryOnDeadlock } from '../../util';\n\nclass StitchPublicationSupersededError extends Error {}\n\nfunction generateStableHash(entity: Entity) {\n return createHash('sha1')\n .update(stableStringify({ ...entity }))\n .digest('hex');\n}\n\n// See https://github.com/facebook/react/blob/f0cf832e1d0c8544c36aa8b310960885a11a847c/packages/react-dom-bindings/src/shared/sanitizeURL.js\nconst scriptProtocolPattern =\n // eslint-disable-next-line no-control-regex\n /^[\\u0000-\\u001F ]*j[\\r\\n\\t]*a[\\r\\n\\t]*v[\\r\\n\\t]*a[\\r\\n\\t]*s[\\r\\n\\t]*c[\\r\\n\\t]*r[\\r\\n\\t]*i[\\r\\n\\t]*p[\\r\\n\\t]*t[\\r\\n\\t]*\\:/i;\n\n/**\n * Performs the act of stitching - to take all of the various outputs from the\n * ingestion process, and stitching them together into the final entity JSON\n * shape.\n */\nexport async function performStitching(options: {\n knex: Knex | Knex.Transaction;\n logger: LoggerService;\n entityRef: string;\n stitchTicket: string;\n stitchLeaseExpiresAt: StitchLeaseExpiresAt;\n}): Promise<'changed' | 'unchanged' | 'abandoned'> {\n const { knex, logger, entityRef, stitchTicket, stitchLeaseExpiresAt } =\n options;\n\n // Settle the claim on any completion, without disturbing a successor's\n // lease. A new request during this lease becomes eligible immediately.\n // Exceptions skip cleanup so the entity gets retried at a later time.\n let stitchResult: 'succeeded' | 'abandoned' | undefined;\n\n try {\n // Selecting from refresh_state (with an optional left join to\n // final_entities for the previous hash) should yield exactly one row,\n // except in abnormal cases where the entity was deleted between the\n // stitch request and now.\n const [processedResult, relationsResult] = await Promise.all([\n knex\n .with('incoming_references', function incomingReferences(builder) {\n return builder\n .from('refresh_state_references')\n .where({ target_entity_ref: entityRef })\n .count({ count: '*' });\n })\n .select({\n entityId: 'refresh_state.entity_id',\n processedEntity: 'refresh_state.processed_entity',\n errors: 'refresh_state.errors',\n incomingReferenceCount: 'incoming_references.count',\n previousHash: 'final_entities.hash',\n })\n .from('refresh_state')\n .where({ 'refresh_state.entity_ref': entityRef })\n .crossJoin(knex.raw('incoming_references'))\n .leftOuterJoin('final_entities', {\n 'final_entities.entity_id': 'refresh_state.entity_id',\n }),\n knex\n .distinct({\n relationType: 'type',\n relationTarget: 'target_entity_ref',\n })\n .from('relations')\n .where({ source_entity_ref: entityRef })\n .orderBy('relationType', 'asc')\n .orderBy('relationTarget', 'asc'),\n ]);\n\n // If there were no rows returned, it would mean that there was no\n // matching row even in the refresh_state. This can happen for example\n // if we emit a relation to something that hasn't been ingested yet.\n // It's safe to ignore this stitch attempt in that case.\n if (!processedResult.length) {\n logger.debug(\n `Unable to stitch ${entityRef}, item does not exist in refresh state table`,\n );\n stitchResult = 'abandoned';\n return 'abandoned';\n }\n\n const {\n entityId,\n processedEntity,\n errors,\n incomingReferenceCount,\n previousHash,\n } = processedResult[0];\n\n // If there was no processed entity in place, the target hasn't been\n // through the processing steps yet. It's safe to ignore this stitch\n // attempt in that case, since another stitch will be triggered when\n // that processing has finished.\n if (!processedEntity) {\n logger.debug(\n `Unable to stitch ${entityRef}, the entity has not yet been processed`,\n );\n stitchResult = 'abandoned';\n return 'abandoned';\n }\n\n // Grab the processed entity and stitch all of the relevant data into\n // it\n const entity = JSON.parse(processedEntity) as AlphaEntity;\n const isOrphan = Number(incomingReferenceCount) === 0;\n let statusItems: EntityStatusItem[] = [];\n\n if (isOrphan) {\n logger.debug(`${entityRef} is an orphan`);\n entity.metadata.annotations = {\n ...entity.metadata.annotations,\n ['backstage.io/orphan']: 'true',\n };\n }\n if (errors) {\n const parsedErrors = JSON.parse(errors) as SerializedError[];\n if (Array.isArray(parsedErrors) && parsedErrors.length) {\n statusItems = parsedErrors.map(e => ({\n type: ENTITY_STATUS_CATALOG_PROCESSING_TYPE,\n level: 'error',\n message: `${e.name}: ${e.message}`,\n error: e,\n }));\n }\n }\n // We opt to do this check here as we otherwise can't guarantee that it will be run after all processors\n for (const annotation of [ANNOTATION_VIEW_URL, ANNOTATION_EDIT_URL]) {\n const value = entity.metadata.annotations?.[annotation];\n if (typeof value === 'string' && scriptProtocolPattern.test(value)) {\n entity.metadata.annotations![annotation] =\n 'https://backstage.io/annotation-rejected-for-security-reasons';\n }\n }\n\n // TODO: entityRef is lower case and should be uppercase in the final\n // result\n entity.relations = relationsResult\n .filter(row => row.relationType /* exclude null row, if relevant */)\n .map<EntityRelation>(row => ({\n type: row.relationType!,\n targetRef: row.relationTarget!,\n }));\n if (statusItems.length) {\n entity.status = {\n ...entity.status,\n items: [...(entity.status?.items ?? []), ...statusItems],\n };\n }\n\n // If the output entity was actually not changed, just abort\n const hash = generateStableHash(entity);\n if (hash === previousHash) {\n logger.debug(`Skipped stitching of ${entityRef}, no changes`);\n stitchResult = 'succeeded';\n return 'unchanged';\n }\n\n entity.metadata.uid = entityId;\n if (!entity.metadata.etag) {\n // If the original data source did not have its own etag handling,\n // use the hash as a good-quality etag\n entity.metadata.etag = hash;\n }\n\n // This may throw if the entity is invalid, so we call it before\n // the final_entities write, even though we may end up not needing\n // to write the search index.\n const searchEntries = buildEntitySearch(entityId, entity);\n\n // Guard against concurrent stitchers: if our stitch_ticket no longer\n // matches stitch_queue, another worker has newer data and we should\n // not overwrite it. PostgreSQL guards both insert and merge in the\n // publication statement and rechecks the captured claim after the upsert.\n // SQLite checks inside its transaction as well\n // as guarding the merge path. MySQL does not support\n // ON CONFLICT ... DO UPDATE ... WHERE, so its separate check retains\n // a best-effort TOCTOU window. Do not turn it into a queue lock here:\n // that would introduce a new lock-order dependency during publication.\n const isMySQL = String(knex.client.config.client).includes('mysql');\n const isPostgres = knex.client.config.client === 'pg';\n\n // The final_entities row and the search index rows have to land\n // together. If the search write fails on its own, the next stitch\n // attempt reads back the hash we just wrote and returns early as\n // unchanged, so the entity keeps a stale search index for good. It\n // stays readable by direct lookup and drops out of every filtered or\n // sorted list query.\n const writeOutcome = await retryOnDeadlock(\n () =>\n knex.transaction(async tx => {\n // Recheck on every attempt: a request or reclaimed claim may have\n // superseded this worker while its previous transaction rolled back.\n // Do not lock the queue here; processing locks refresh state before queue.\n if (!isPostgres) {\n const ticketValid = await tx<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', entityRef)\n .where('stitch_ticket', stitchTicket)\n .first();\n if (!ticketValid) return 'abandoned' as const;\n }\n // Knex's typed insert overload excludes raw INSERT SELECT bodies.\n let upsert = tx('final_entities')\n .insert(\n !isPostgres\n ? {\n entity_id: entityId,\n entity_ref: entityRef,\n final_entity: JSON.stringify(entity),\n hash,\n last_updated_at: tx.fn.now(),\n }\n : tx.raw(\n '(entity_id, entity_ref, final_entity, hash, last_updated_at) ' +\n 'select ?, ?, ?, ?, CURRENT_TIMESTAMP ' +\n 'where exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)',\n [\n entityId,\n entityRef,\n JSON.stringify(entity),\n hash,\n entityRef,\n stitchTicket,\n ],\n ),\n )\n .onConflict('entity_id')\n .merge(['final_entity', 'hash', 'last_updated_at']);\n\n if (!isMySQL) {\n upsert = upsert.where(\n tx.raw(\n 'exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)',\n [entityRef, stitchTicket],\n ),\n );\n }\n\n await upsert;\n\n // Verify the write took effect. On PostgreSQL, also recheck ownership\n // using this statement's fresh Read Committed snapshot: the upsert's\n // snapshot may predate a reclaim that committed while it was running.\n // A successful upsert holds the final-row lock until commit, so a\n // successor cannot publish past us after this check. Do not lock the\n // queue, which would introduce a publication/deletion lock inversion.\n if (!isMySQL) {\n const written = await tx<DbFinalEntitiesRow>('final_entities')\n .where('entity_id', entityId)\n .where('hash', hash)\n .modify(qb => {\n if (isPostgres) {\n qb.whereExists(\n tx<DbStitchQueueRow>('stitch_queue')\n .select(tx.raw('1'))\n .where('entity_ref', entityRef)\n .where('stitch_ticket', stitchTicket)\n .where('next_stitch_at', stitchLeaseExpiresAt),\n );\n }\n })\n .select(tx.raw('1'))\n .first();\n if (!written) {\n // Returning normally would commit any stale write already made.\n throw new StitchPublicationSupersededError();\n }\n }\n\n await syncSearchRows(tx, entityId, searchEntries);\n\n return 'changed' as const;\n }),\n knex,\n ).catch(error => {\n // Knex has rolled the publication back before rejecting the transaction.\n // Supersession is a normal abandonment, not a failure or deadlock retry.\n if (error instanceof StitchPublicationSupersededError) {\n return 'abandoned' as const;\n }\n throw error;\n });\n\n if (writeOutcome === 'abandoned') {\n logger.debug(`Entity ${entityRef} is already stitched, skipping write.`);\n stitchResult = 'abandoned';\n return 'abandoned';\n }\n\n stitchResult = 'succeeded';\n return 'changed';\n } catch (error) {\n try {\n await markDeferredStitchFailed({\n knex,\n entityRef,\n stitchTicket,\n stitchLeaseExpiresAt,\n });\n } catch (recordError) {\n // A database outage may also prevent recording the failure. Keep the\n // queued work and report the original stitching error to the caller.\n logger.warn(\n `Failed to record stitch failure for ${entityRef}`,\n recordError,\n );\n }\n throw error;\n } finally {\n if (stitchResult) {\n await markDeferredStitchCompleted({\n knex: knex,\n entityRef,\n stitchTicket,\n stitchLeaseExpiresAt,\n });\n }\n }\n}\n"],"names":["createHash","stableStringify","ENTITY_STATUS_CATALOG_PROCESSING_TYPE","ANNOTATION_VIEW_URL","ANNOTATION_EDIT_URL","buildEntitySearch","retryOnDeadlock","syncSearchRows","markDeferredStitchFailed","markDeferredStitchCompleted"],"mappings":"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"}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var uniq = require('lodash/uniq');
|
|
4
|
+
var util = require('../../util.cjs.js');
|
|
4
5
|
var markForStitching = require('../stitcher/markForStitching.cjs.js');
|
|
5
6
|
|
|
6
7
|
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
@@ -9,41 +10,67 @@ var uniq__default = /*#__PURE__*/_interopDefaultCompat(uniq);
|
|
|
9
10
|
|
|
10
11
|
async function deleteOrphanedEntities(options) {
|
|
11
12
|
const { knex } = options;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
).
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
const runIteration = async (tx) => {
|
|
14
|
+
const findOrphanRefs = (orphanRefs) => orphanRefs.from("refresh_state").select("refresh_state.entity_ref").leftOuterJoin(
|
|
15
|
+
"refresh_state_references",
|
|
16
|
+
"refresh_state_references.target_entity_ref",
|
|
17
|
+
"refresh_state.entity_ref"
|
|
18
|
+
).whereNull("refresh_state_references.target_entity_ref");
|
|
19
|
+
const candidateQuery = tx.client.config.client.includes("pg") ? tx.withMaterialized("orphan_refs", ["entity_ref"], findOrphanRefs) : tx.with("orphan_refs", ["entity_ref"], findOrphanRefs);
|
|
20
|
+
const candidates = await candidateQuery.select({
|
|
21
|
+
entityId: "orphan.entity_id",
|
|
22
|
+
relationSourceId: "relation_source.entity_id"
|
|
23
|
+
}).from("orphan_refs").join(
|
|
24
|
+
"refresh_state as orphan",
|
|
25
|
+
"orphan.entity_ref",
|
|
26
|
+
"orphan_refs.entity_ref"
|
|
27
|
+
).leftOuterJoin(
|
|
26
28
|
"relations",
|
|
27
29
|
"relations.target_entity_ref",
|
|
28
|
-
"
|
|
30
|
+
"orphan_refs.entity_ref"
|
|
29
31
|
).leftOuterJoin(
|
|
30
|
-
"refresh_state",
|
|
31
|
-
"
|
|
32
|
+
"refresh_state as relation_source",
|
|
33
|
+
"relation_source.entity_ref",
|
|
32
34
|
"relations.source_entity_ref"
|
|
33
35
|
);
|
|
34
36
|
if (!candidates.length) {
|
|
35
|
-
|
|
37
|
+
return { deleted: 0, done: true };
|
|
36
38
|
}
|
|
37
39
|
const orphanIds = uniq__default.default(candidates.map((r) => r.entityId));
|
|
40
|
+
await tx("refresh_state").select("entity_id").whereIn("entity_id", orphanIds).forUpdate();
|
|
41
|
+
const deleted = await tx.table("refresh_state").delete().whereIn("entity_id", orphanIds).whereNotExists(
|
|
42
|
+
(references) => references.select(tx.raw("1")).from("refresh_state_references").whereRaw("?? = ??", [
|
|
43
|
+
"refresh_state_references.target_entity_ref",
|
|
44
|
+
"refresh_state.entity_ref"
|
|
45
|
+
])
|
|
46
|
+
);
|
|
47
|
+
if (deleted === 0) {
|
|
48
|
+
return { deleted, done: true };
|
|
49
|
+
}
|
|
50
|
+
let deletedIds;
|
|
51
|
+
if (deleted === orphanIds.length) {
|
|
52
|
+
deletedIds = new Set(orphanIds);
|
|
53
|
+
} else {
|
|
54
|
+
const remaining = await tx("refresh_state").select("entity_id").whereIn("entity_id", orphanIds);
|
|
55
|
+
const remainingIds = new Set(remaining.map((row) => row.entity_id));
|
|
56
|
+
deletedIds = new Set(orphanIds.filter((id) => !remainingIds.has(id)));
|
|
57
|
+
}
|
|
38
58
|
const orphanRelationIds = uniq__default.default(
|
|
39
|
-
candidates.map((
|
|
59
|
+
candidates.filter((candidate) => deletedIds.has(candidate.entityId)).map((candidate) => candidate.relationSourceId).filter(Boolean)
|
|
40
60
|
);
|
|
41
|
-
total += orphanIds.length;
|
|
42
|
-
await knex.table("refresh_state").delete().whereIn("entity_id", orphanIds);
|
|
43
61
|
await markForStitching.markForStitching({
|
|
44
|
-
knex,
|
|
62
|
+
knex: tx,
|
|
45
63
|
entityIds: orphanRelationIds
|
|
46
64
|
});
|
|
65
|
+
return { deleted, done: false };
|
|
66
|
+
};
|
|
67
|
+
let total = 0;
|
|
68
|
+
for (let i = 0; i < 100; ++i) {
|
|
69
|
+
const result = knex.isTransaction ? await runIteration(knex) : await util.retryOnDeadlock(() => knex.transaction(runIteration), knex);
|
|
70
|
+
total += result.deleted;
|
|
71
|
+
if (result.done) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
47
74
|
}
|
|
48
75
|
return total;
|
|
49
76
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deleteOrphanedEntities.cjs.js","sources":["../../../../src/database/operations/util/deleteOrphanedEntities.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport uniq from 'lodash/uniq';\nimport { DbRefreshStateRow } from '../../tables';\nimport { markForStitching } from '../stitcher/markForStitching';\n\n/**\n * Finds and deletes all orphaned entities, i.e. entities that do not have any\n * incoming references to them, and also eagerly deletes all of their children\n * that would otherwise become orphaned.\n */\nexport async function deleteOrphanedEntities(options: {\n knex: Knex.Transaction | Knex;\n}): Promise<number> {\n const { knex } = options;\n\n
|
|
1
|
+
{"version":3,"file":"deleteOrphanedEntities.cjs.js","sources":["../../../../src/database/operations/util/deleteOrphanedEntities.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport uniq from 'lodash/uniq';\nimport { DbRefreshStateRow } from '../../tables';\nimport { retryOnDeadlock } from '../../util';\nimport { markForStitching } from '../stitcher/markForStitching';\n\n/**\n * Finds and deletes all orphaned entities, i.e. entities that do not have any\n * incoming references to them, and also eagerly deletes all of their children\n * that would otherwise become orphaned.\n */\nexport async function deleteOrphanedEntities(options: {\n knex: Knex.Transaction | Knex;\n}): Promise<number> {\n const { knex } = options;\n\n const runIteration = async (tx: Knex.Transaction | Knex) => {\n // Keep orphan discovery and relation lookup in one statement so that they\n // observe the same database snapshot.\n const findOrphanRefs = (orphanRefs: Knex.QueryBuilder) =>\n orphanRefs\n .from('refresh_state')\n .select('refresh_state.entity_ref')\n .leftOuterJoin(\n 'refresh_state_references',\n 'refresh_state_references.target_entity_ref',\n 'refresh_state.entity_ref',\n )\n .whereNull('refresh_state_references.target_entity_ref');\n\n const candidateQuery = tx.client.config.client.includes('pg')\n ? tx.withMaterialized('orphan_refs', ['entity_ref'], findOrphanRefs)\n : tx.with('orphan_refs', ['entity_ref'], findOrphanRefs);\n\n const candidates = await candidateQuery\n .select({\n entityId: 'orphan.entity_id',\n relationSourceId: 'relation_source.entity_id',\n })\n .from('orphan_refs')\n .join(\n 'refresh_state as orphan',\n 'orphan.entity_ref',\n 'orphan_refs.entity_ref',\n )\n .leftOuterJoin(\n 'relations',\n 'relations.target_entity_ref',\n 'orphan_refs.entity_ref',\n )\n .leftOuterJoin(\n 'refresh_state as relation_source',\n 'relation_source.entity_ref',\n 'relations.source_entity_ref',\n );\n\n if (!candidates.length) {\n return { deleted: 0, done: true };\n }\n\n const orphanIds: string[] = uniq(candidates.map(r => r.entityId));\n\n // Serialize with concurrent reference inserts, which take a key-share lock\n // on the target row while checking their foreign key. Rechecking in the\n // following statement then observes references that committed while this\n // lock was being acquired.\n await tx<DbRefreshStateRow>('refresh_state')\n .select('entity_id')\n .whereIn('entity_id', orphanIds)\n .forUpdate();\n\n // Recheck the orphan status in the deletion statement. An entity may have\n // gained a reference since the candidate query completed.\n const deleted = await tx\n .table<DbRefreshStateRow>('refresh_state')\n .delete()\n .whereIn('entity_id', orphanIds)\n .whereNotExists(references =>\n references\n .select(tx.raw('1'))\n .from('refresh_state_references')\n .whereRaw('?? = ??', [\n 'refresh_state_references.target_entity_ref',\n 'refresh_state.entity_ref',\n ]),\n );\n\n if (deleted === 0) {\n return { deleted, done: true };\n }\n\n let deletedIds: Set<string>;\n if (deleted === orphanIds.length) {\n deletedIds = new Set(orphanIds);\n } else {\n const remaining = await tx<DbRefreshStateRow>('refresh_state')\n .select('entity_id')\n .whereIn('entity_id', orphanIds);\n const remainingIds = new Set(remaining.map(row => row.entity_id));\n deletedIds = new Set(orphanIds.filter(id => !remainingIds.has(id)));\n }\n\n const orphanRelationIds: string[] = uniq(\n candidates\n .filter(candidate => deletedIds.has(candidate.entityId))\n .map(candidate => candidate.relationSourceId)\n .filter(Boolean),\n );\n\n // Mark all of the things that the orphans had relations to for stitching\n await markForStitching({\n knex: tx,\n entityIds: orphanRelationIds,\n });\n\n return { deleted, done: false };\n };\n\n let total = 0;\n\n // Limit iterations for sanity\n for (let i = 0; i < 100; ++i) {\n const result = knex.isTransaction\n ? await runIteration(knex)\n : await retryOnDeadlock(() => knex.transaction(runIteration), knex);\n\n total += result.deleted;\n if (result.done) {\n break;\n }\n }\n\n return total;\n}\n"],"names":["uniq","markForStitching","retryOnDeadlock"],"mappings":";;;;;;;;;;AA2BA,eAAsB,uBAAuB,OAAA,EAEzB;AAClB,EAAA,MAAM,EAAE,MAAK,GAAI,OAAA;AAEjB,EAAA,MAAM,YAAA,GAAe,OAAO,EAAA,KAAgC;AAG1D,IAAA,MAAM,cAAA,GAAiB,CAAC,UAAA,KACtB,UAAA,CACG,KAAK,eAAe,CAAA,CACpB,MAAA,CAAO,0BAA0B,CAAA,CACjC,aAAA;AAAA,MACC,0BAAA;AAAA,MACA,4CAAA;AAAA,MACA;AAAA,KACF,CACC,UAAU,4CAA4C,CAAA;AAE3D,IAAA,MAAM,cAAA,GAAiB,GAAG,MAAA,CAAO,MAAA,CAAO,OAAO,QAAA,CAAS,IAAI,CAAA,GACxD,EAAA,CAAG,gBAAA,CAAiB,aAAA,EAAe,CAAC,YAAY,CAAA,EAAG,cAAc,CAAA,GACjE,EAAA,CAAG,KAAK,aAAA,EAAe,CAAC,YAAY,CAAA,EAAG,cAAc,CAAA;AAEzD,IAAA,MAAM,UAAA,GAAa,MAAM,cAAA,CACtB,MAAA,CAAO;AAAA,MACN,QAAA,EAAU,kBAAA;AAAA,MACV,gBAAA,EAAkB;AAAA,KACnB,CAAA,CACA,IAAA,CAAK,aAAa,CAAA,CAClB,IAAA;AAAA,MACC,yBAAA;AAAA,MACA,mBAAA;AAAA,MACA;AAAA,KACF,CACC,aAAA;AAAA,MACC,WAAA;AAAA,MACA,6BAAA;AAAA,MACA;AAAA,KACF,CACC,aAAA;AAAA,MACC,kCAAA;AAAA,MACA,4BAAA;AAAA,MACA;AAAA,KACF;AAEF,IAAA,IAAI,CAAC,WAAW,MAAA,EAAQ;AACtB,MAAA,OAAO,EAAE,OAAA,EAAS,CAAA,EAAG,IAAA,EAAM,IAAA,EAAK;AAAA,IAClC;AAEA,IAAA,MAAM,YAAsBA,qBAAA,CAAK,UAAA,CAAW,IAAI,CAAA,CAAA,KAAK,CAAA,CAAE,QAAQ,CAAC,CAAA;AAMhE,IAAA,MAAM,EAAA,CAAsB,eAAe,CAAA,CACxC,MAAA,CAAO,WAAW,EAClB,OAAA,CAAQ,WAAA,EAAa,SAAS,CAAA,CAC9B,SAAA,EAAU;AAIb,IAAA,MAAM,OAAA,GAAU,MAAM,EAAA,CACnB,KAAA,CAAyB,eAAe,CAAA,CACxC,MAAA,EAAO,CACP,OAAA,CAAQ,WAAA,EAAa,SAAS,CAAA,CAC9B,cAAA;AAAA,MAAe,CAAA,UAAA,KACd,UAAA,CACG,MAAA,CAAO,EAAA,CAAG,GAAA,CAAI,GAAG,CAAC,CAAA,CAClB,IAAA,CAAK,0BAA0B,CAAA,CAC/B,QAAA,CAAS,SAAA,EAAW;AAAA,QACnB,4CAAA;AAAA,QACA;AAAA,OACD;AAAA,KACL;AAEF,IAAA,IAAI,YAAY,CAAA,EAAG;AACjB,MAAA,OAAO,EAAE,OAAA,EAAS,IAAA,EAAM,IAAA,EAAK;AAAA,IAC/B;AAEA,IAAA,IAAI,UAAA;AACJ,IAAA,IAAI,OAAA,KAAY,UAAU,MAAA,EAAQ;AAChC,MAAA,UAAA,GAAa,IAAI,IAAI,SAAS,CAAA;AAAA,IAChC,CAAA,MAAO;AACL,MAAA,MAAM,SAAA,GAAY,MAAM,EAAA,CAAsB,eAAe,CAAA,CAC1D,OAAO,WAAW,CAAA,CAClB,OAAA,CAAQ,WAAA,EAAa,SAAS,CAAA;AACjC,MAAA,MAAM,YAAA,GAAe,IAAI,GAAA,CAAI,SAAA,CAAU,IAAI,CAAA,GAAA,KAAO,GAAA,CAAI,SAAS,CAAC,CAAA;AAChE,MAAA,UAAA,GAAa,IAAI,GAAA,CAAI,SAAA,CAAU,MAAA,CAAO,CAAA,EAAA,KAAM,CAAC,YAAA,CAAa,GAAA,CAAI,EAAE,CAAC,CAAC,CAAA;AAAA,IACpE;AAEA,IAAA,MAAM,iBAAA,GAA8BA,qBAAA;AAAA,MAClC,UAAA,CACG,MAAA,CAAO,CAAA,SAAA,KAAa,UAAA,CAAW,IAAI,SAAA,CAAU,QAAQ,CAAC,CAAA,CACtD,IAAI,CAAA,SAAA,KAAa,SAAA,CAAU,gBAAgB,CAAA,CAC3C,OAAO,OAAO;AAAA,KACnB;AAGA,IAAA,MAAMC,iCAAA,CAAiB;AAAA,MACrB,IAAA,EAAM,EAAA;AAAA,MACN,SAAA,EAAW;AAAA,KACZ,CAAA;AAED,IAAA,OAAO,EAAE,OAAA,EAAS,IAAA,EAAM,KAAA,EAAM;AAAA,EAChC,CAAA;AAEA,EAAA,IAAI,KAAA,GAAQ,CAAA;AAGZ,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,GAAA,EAAK,EAAE,CAAA,EAAG;AAC5B,IAAA,MAAM,MAAA,GAAS,IAAA,CAAK,aAAA,GAChB,MAAM,aAAa,IAAI,CAAA,GACvB,MAAMC,oBAAA,CAAgB,MAAM,IAAA,CAAK,WAAA,CAAY,YAAY,GAAG,IAAI,CAAA;AAEpE,IAAA,KAAA,IAAS,MAAA,CAAO,OAAA;AAChB,IAAA,IAAI,OAAO,IAAA,EAAM;AACf,MAAA;AAAA,IACF;AAAA,EACF;AAEA,EAAA,OAAO,KAAA;AACT;;"}
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var config = require('@backstage/config');
|
|
4
|
+
var types = require('@backstage/types');
|
|
5
|
+
var errors = require('@backstage/errors');
|
|
4
6
|
|
|
5
7
|
function stitchingStrategyFromConfig(config$1) {
|
|
6
8
|
const pollingIntervalKey = "catalog.stitchingStrategy.pollingInterval";
|
|
7
9
|
const stitchTimeoutKey = "catalog.stitchingStrategy.stitchTimeout";
|
|
8
10
|
const pollingInterval = config$1.has(pollingIntervalKey) ? config.readDurationFromConfig(config$1, { key: pollingIntervalKey }) : { seconds: 1 };
|
|
9
11
|
const stitchTimeout = config$1.has(stitchTimeoutKey) ? config.readDurationFromConfig(config$1, { key: stitchTimeoutKey }) : { seconds: 60 };
|
|
12
|
+
for (const [key, duration] of [
|
|
13
|
+
[pollingIntervalKey, pollingInterval],
|
|
14
|
+
[stitchTimeoutKey, stitchTimeout]
|
|
15
|
+
]) {
|
|
16
|
+
const milliseconds = types.durationToMilliseconds(duration);
|
|
17
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
|
|
18
|
+
throw new errors.InputError(
|
|
19
|
+
`Duration in config at '${key}' must be finite and greater than zero`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
10
23
|
return {
|
|
11
24
|
pollingInterval,
|
|
12
25
|
stitchTimeout
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.cjs.js","sources":["../../src/stitching/types.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Config, readDurationFromConfig } from '@backstage/config';\nimport { HumanDuration } from '@backstage/types';\n\n/**\n * Configuration for the stitching process, controlling polling and timeout\n * behavior for the deferred stitching worker.\n */\nexport type StitchingStrategy = {\n pollingInterval: HumanDuration;\n stitchTimeout: HumanDuration;\n};\n\nexport function stitchingStrategyFromConfig(config: Config): StitchingStrategy {\n const pollingIntervalKey = 'catalog.stitchingStrategy.pollingInterval';\n const stitchTimeoutKey = 'catalog.stitchingStrategy.stitchTimeout';\n\n const pollingInterval = config.has(pollingIntervalKey)\n ? readDurationFromConfig(config, { key: pollingIntervalKey })\n : { seconds: 1 };\n const stitchTimeout = config.has(stitchTimeoutKey)\n ? readDurationFromConfig(config, { key: stitchTimeoutKey })\n : { seconds: 60 };\n\n return {\n pollingInterval: pollingInterval,\n stitchTimeout: stitchTimeout,\n };\n}\n"],"names":["config","readDurationFromConfig"],"mappings":"
|
|
1
|
+
{"version":3,"file":"types.cjs.js","sources":["../../src/stitching/types.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Config, readDurationFromConfig } from '@backstage/config';\nimport { durationToMilliseconds, HumanDuration } from '@backstage/types';\nimport { InputError } from '@backstage/errors';\n\n/**\n * Configuration for the stitching process, controlling polling and timeout\n * behavior for the deferred stitching worker.\n */\nexport type StitchingStrategy = {\n pollingInterval: HumanDuration;\n stitchTimeout: HumanDuration;\n};\n\nexport function stitchingStrategyFromConfig(config: Config): StitchingStrategy {\n const pollingIntervalKey = 'catalog.stitchingStrategy.pollingInterval';\n const stitchTimeoutKey = 'catalog.stitchingStrategy.stitchTimeout';\n\n const pollingInterval = config.has(pollingIntervalKey)\n ? readDurationFromConfig(config, { key: pollingIntervalKey })\n : { seconds: 1 };\n const stitchTimeout = config.has(stitchTimeoutKey)\n ? readDurationFromConfig(config, { key: stitchTimeoutKey })\n : { seconds: 60 };\n\n for (const [key, duration] of [\n [pollingIntervalKey, pollingInterval],\n [stitchTimeoutKey, stitchTimeout],\n ] as const) {\n const milliseconds = durationToMilliseconds(duration);\n if (!Number.isFinite(milliseconds) || milliseconds <= 0) {\n throw new InputError(\n `Duration in config at '${key}' must be finite and greater than zero`,\n );\n }\n }\n\n return {\n pollingInterval: pollingInterval,\n stitchTimeout: stitchTimeout,\n };\n}\n"],"names":["config","readDurationFromConfig","durationToMilliseconds","InputError"],"mappings":";;;;;;AA6BO,SAAS,4BAA4BA,QAAA,EAAmC;AAC7E,EAAA,MAAM,kBAAA,GAAqB,2CAAA;AAC3B,EAAA,MAAM,gBAAA,GAAmB,yCAAA;AAEzB,EAAA,MAAM,eAAA,GAAkBA,QAAA,CAAO,GAAA,CAAI,kBAAkB,IACjDC,6BAAA,CAAuBD,QAAA,EAAQ,EAAE,GAAA,EAAK,kBAAA,EAAoB,CAAA,GAC1D,EAAE,SAAS,CAAA,EAAE;AACjB,EAAA,MAAM,aAAA,GAAgBA,QAAA,CAAO,GAAA,CAAI,gBAAgB,IAC7CC,6BAAA,CAAuBD,QAAA,EAAQ,EAAE,GAAA,EAAK,gBAAA,EAAkB,CAAA,GACxD,EAAE,SAAS,EAAA,EAAG;AAElB,EAAA,KAAA,MAAW,CAAC,GAAA,EAAK,QAAQ,CAAA,IAAK;AAAA,IAC5B,CAAC,oBAAoB,eAAe,CAAA;AAAA,IACpC,CAAC,kBAAkB,aAAa;AAAA,GAClC,EAAY;AACV,IAAA,MAAM,YAAA,GAAeE,6BAAuB,QAAQ,CAAA;AACpD,IAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,YAAY,CAAA,IAAK,gBAAgB,CAAA,EAAG;AACvD,MAAA,MAAM,IAAIC,iBAAA;AAAA,QACR,0BAA0B,GAAG,CAAA,sCAAA;AAAA,OAC/B;AAAA,IACF;AAAA,EACF;AAEA,EAAA,OAAO;AAAA,IACL,eAAA;AAAA,IACA;AAAA,GACF;AACF;;"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 The Backstage Authors
|
|
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
|
+
// @ts-check
|
|
18
|
+
|
|
19
|
+
/** @param {import('knex').Knex} knex */
|
|
20
|
+
exports.up = async function up(knex) {
|
|
21
|
+
await knex.schema.alterTable('stitch_queue', table => {
|
|
22
|
+
table.integer('failure_count').notNullable().defaultTo(0);
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** @param {import('knex').Knex} knex */
|
|
27
|
+
exports.down = async function down(knex) {
|
|
28
|
+
await knex.schema.alterTable('stitch_queue', table => {
|
|
29
|
+
table.dropColumn('failure_count');
|
|
30
|
+
});
|
|
31
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@backstage/plugin-catalog-backend",
|
|
3
|
-
"version": "4.0.1-next.
|
|
3
|
+
"version": "4.0.1-next.3",
|
|
4
4
|
"description": "The Backstage backend plugin that provides the Backstage catalog",
|
|
5
5
|
"backstage": {
|
|
6
6
|
"role": "backend-plugin",
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
78
|
"@backstage/backend-openapi-utils": "0.7.3-next.2",
|
|
79
|
-
"@backstage/backend-plugin-api": "1.11.0-next.
|
|
80
|
-
"@backstage/catalog-client": "1.16.3-next.
|
|
79
|
+
"@backstage/backend-plugin-api": "1.11.0-next.2",
|
|
80
|
+
"@backstage/catalog-client": "1.16.3-next.1",
|
|
81
81
|
"@backstage/catalog-model": "1.10.2-next.0",
|
|
82
82
|
"@backstage/config": "1.3.10-next.0",
|
|
83
83
|
"@backstage/errors": "1.3.2-next.0",
|
|
@@ -110,12 +110,12 @@
|
|
|
110
110
|
"zod-validation-error": "^5.0.0"
|
|
111
111
|
},
|
|
112
112
|
"devDependencies": {
|
|
113
|
-
"@backstage/backend-defaults": "0.18.1-next.
|
|
114
|
-
"@backstage/backend-test-utils": "1.11.8-next.
|
|
113
|
+
"@backstage/backend-defaults": "0.18.1-next.3",
|
|
114
|
+
"@backstage/backend-test-utils": "1.11.8-next.2",
|
|
115
115
|
"@backstage/cli": "0.36.7-next.0",
|
|
116
116
|
"@backstage/plugin-catalog-backend-module-logs": "0.1.27-next.1",
|
|
117
117
|
"@backstage/plugin-scaffolder-common": "2.3.1-next.0",
|
|
118
|
-
"@backstage/repo-tools": "0.19.2-next.
|
|
118
|
+
"@backstage/repo-tools": "0.19.2-next.3",
|
|
119
119
|
"@types/core-js": "^2.5.4",
|
|
120
120
|
"@types/express": "^4.17.6",
|
|
121
121
|
"@types/git-url-parse": "^9.0.0",
|