@backstage/plugin-catalog-backend 4.0.1-next.1 → 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.
Files changed (28) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/config.schema.json +2 -2
  3. package/dist/database/operations/provider/deleteWithEagerPruningOfChildren.cjs.js +0 -1
  4. package/dist/database/operations/provider/deleteWithEagerPruningOfChildren.cjs.js.map +1 -1
  5. package/dist/database/operations/stitcher/getDeferredStitchableEntities.cjs.js +50 -8
  6. package/dist/database/operations/stitcher/getDeferredStitchableEntities.cjs.js.map +1 -1
  7. package/dist/database/operations/stitcher/markDeferredStitchCompleted.cjs.js +3 -7
  8. package/dist/database/operations/stitcher/markDeferredStitchCompleted.cjs.js.map +1 -1
  9. package/dist/database/operations/stitcher/markDeferredStitchFailed.cjs.js +12 -0
  10. package/dist/database/operations/stitcher/markDeferredStitchFailed.cjs.js.map +1 -0
  11. package/dist/database/operations/stitcher/markForStitching.cjs.js +4 -2
  12. package/dist/database/operations/stitcher/markForStitching.cjs.js.map +1 -1
  13. package/dist/database/operations/stitcher/performStitching.cjs.js +78 -36
  14. package/dist/database/operations/stitcher/performStitching.cjs.js.map +1 -1
  15. package/dist/database/operations/stitcher/syncSearchRows.cjs.js +24 -39
  16. package/dist/database/operations/stitcher/syncSearchRows.cjs.js.map +1 -1
  17. package/dist/database/operations/util/deleteOrphanedEntities.cjs.js +49 -22
  18. package/dist/database/operations/util/deleteOrphanedEntities.cjs.js.map +1 -1
  19. package/dist/database/util.cjs.js +3 -0
  20. package/dist/database/util.cjs.js.map +1 -1
  21. package/dist/processing/DefaultCatalogProcessingEngine.cjs.js +36 -30
  22. package/dist/processing/DefaultCatalogProcessingEngine.cjs.js.map +1 -1
  23. package/dist/stitching/DefaultStitcher.cjs.js +4 -2
  24. package/dist/stitching/DefaultStitcher.cjs.js.map +1 -1
  25. package/dist/stitching/types.cjs.js +13 -0
  26. package/dist/stitching/types.cjs.js.map +1 -1
  27. package/migrations/20261007000000_stitch_failure_count.js +31 -0
  28. package/package.json +8 -8
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
14
+ ## 4.0.1-next.2
15
+
16
+ ### Patch Changes
17
+
18
+ - 589dcef: Fixed an issue where successfully processed entities could remain unavailable if stitch scheduling was interrupted.
19
+ - a69d971: Improved the reliability of deferred stitching. Pending stitching work is no longer lost when entities are deleted and re-added, and updated instances prevent timed-out stitching attempts from overwriting or removing work taken over by another updated instance. During rolling upgrades from older versions, instances may still perform redundant stitching. On MySQL, protection against overlapping writes remains best-effort.
20
+ - 476f042: Fixed entities disappearing from filtered and sorted catalog queries after a database error during stitching. The final entity and its search index rows are now written together, so a failure part way through is rolled back and retried in full. Previously the entity could be left with a search index that no longer matched it, and because the entity itself looked up to date, every later stitch attempt skipped it. Such an entity stayed readable by direct lookup while missing from list queries that filter or sort, until it was next edited.
21
+
22
+ PostgreSQL and MySQL deadlocks trigger prompt, bounded retries of the entire write transaction, skipping the write if newer stitching work has superseded it. If retries are exhausted, queued work remains available for recovery after the stitching timeout.
23
+
24
+ - Updated dependencies
25
+ - @backstage/integration@2.1.3-next.1
26
+ - @backstage/backend-openapi-utils@0.7.3-next.2
27
+
3
28
  ## 4.0.1-next.1
4
29
 
5
30
  ### Patch Changes
@@ -169,7 +169,7 @@
169
169
  "type": "string"
170
170
  }
171
171
  ],
172
- "description": "Polling interval for tasks in seconds"
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": "How long to wait for a stitch to complete before giving up in seconds"
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."
@@ -22,7 +22,6 @@ async function deleteWithEagerPruningOfChildren(options) {
22
22
  entityRefs: refsToDelete
23
23
  });
24
24
  await knex.delete().from("refresh_state").whereIn("entity_ref", refsToDelete);
25
- await knex.delete().from("stitch_queue").whereIn("entity_ref", refsToDelete);
26
25
  }
27
26
  await knex("refresh_state_references").where("source_key", "=", sourceKey).whereIn("target_entity_ref", refs).delete();
28
27
  removedCount += orphanEntityRefs.length;
@@ -1 +1 @@
1
- {"version":3,"file":"deleteWithEagerPruningOfChildren.cjs.js","sources":["../../../../src/database/operations/provider/deleteWithEagerPruningOfChildren.ts"],"sourcesContent":["/*\n * Copyright 2022 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 lodash from 'lodash';\nimport { DbRefreshStateReferencesRow } from '../../tables';\nimport { markForStitching } from '../stitcher/markForStitching';\n\n/**\n * Given a number of entity refs originally created by a given entity provider\n * (source key), remove those entities from the refresh state, and at the same\n * time recursively remove every child that is a direct or indirect result of\n * processing those entities, if they would have otherwise become orphaned by\n * the removal of their parents.\n */\nexport async function deleteWithEagerPruningOfChildren(options: {\n knex: Knex | Knex.Transaction;\n entityRefs: string[];\n sourceKey: string;\n}): Promise<number> {\n const { knex, entityRefs, sourceKey } = options;\n\n // Split up the operation by (large) chunks, so that we do not hit database\n // limits for the number of permitted bindings on a precompiled statement\n let removedCount = 0;\n for (const refs of lodash.chunk(entityRefs, 1000)) {\n const { orphanEntityRefs } =\n await findDescendantsThatWouldHaveBeenOrphanedByDeletion({\n knex: options.knex,\n refs,\n sourceKey,\n });\n\n // Chunk again - these can be many more than the outer chunk size\n for (const refsToDelete of lodash.chunk(orphanEntityRefs, 1000)) {\n await markEntitiesAffectedByDeletionForStitching({\n knex: options.knex,\n entityRefs: refsToDelete,\n });\n await knex\n .delete()\n .from('refresh_state')\n .whereIn('entity_ref', refsToDelete);\n await knex\n .delete()\n .from('stitch_queue')\n .whereIn('entity_ref', refsToDelete);\n }\n\n // Delete the references that originate only from this entity provider. Note\n // that there may be more than one entity provider making a \"claim\" for a\n // given root entity, if they emit with the same location key.\n await knex<DbRefreshStateReferencesRow>('refresh_state_references')\n .where('source_key', '=', sourceKey)\n .whereIn('target_entity_ref', refs)\n .delete();\n\n removedCount += orphanEntityRefs.length;\n }\n\n return removedCount;\n}\n\nasync function findDescendantsThatWouldHaveBeenOrphanedByDeletion(options: {\n knex: Knex | Knex.Transaction;\n refs: string[];\n sourceKey: string;\n}): Promise<{ orphanEntityRefs: string[] }> {\n const { knex, refs, sourceKey } = options;\n\n const orphans: string[] =\n // First find all nodes that can be reached downwards from the roots\n // (deletion targets), including the roots themselves, by traversing\n // down the refresh_state_references table. Note that this query\n // starts with a condition that source_key = our source key, and\n // target_entity_ref is one of the deletion targets. This has two\n // effects: it won't match attempts at deleting something that didn't\n // originate from us in the first place, and also won't match non-root\n // entities (source_key would be null for those).\n //\n // KeyA - R1 - R2 Legend:\n // \\ -----------------------------------------\n // R3 Key* Source key\n // / R* Entity ref\n // KeyA - R4 - R5 lines Individual references; sources to\n // / the left and targets to the right\n // KeyB --- R6\n //\n // The scenario is that KeyA wants to delete R1.\n //\n // The query starts with the KeyA-R1 reference, and then traverses\n // down to also find R2 and R3. It uses union instead of union all,\n // because it wants to find the set of unique descendants even if\n // the tree has unexpected loops etc.\n await knex\n .withRecursive('descendants', ['entity_ref'], initial =>\n initial\n .select('target_entity_ref')\n .from('refresh_state_references')\n .where('source_key', '=', sourceKey)\n .whereIn('target_entity_ref', refs)\n .union(recursive =>\n recursive\n .select('refresh_state_references.target_entity_ref')\n .from('descendants')\n .join(\n 'refresh_state_references',\n 'descendants.entity_ref',\n 'refresh_state_references.source_entity_ref',\n ),\n ),\n )\n // Then for each descendant, traverse all the way back upwards through\n // the refresh_state_references table to get an exhaustive list of all\n // references that are part of keeping that particular descendant\n // alive.\n //\n // Continuing the scenario from above, starting from R3, it goes\n // upwards to find every pair along every relation line.\n //\n // Top branch: R2-R3, R1-R2, KeyA-R1\n // Middle branch: R5-R3, R4-R5, KeyA-R4\n // Bottom branch: R6-R5, KeyB-R6\n //\n // Note that this all applied to the subject R3. The exact same thing\n // will be done starting from each other descendant (R2 and R1). They\n // only have one and two references to find, respectively.\n //\n // This query also uses union instead of union all, to get the set of\n // distinct relations even if the tree has unexpected loops etc.\n .withRecursive(\n 'ancestors',\n ['source_key', 'source_entity_ref', 'target_entity_ref', 'subject'],\n initial =>\n initial\n .select(\n 'refresh_state_references.source_key',\n 'refresh_state_references.source_entity_ref',\n 'refresh_state_references.target_entity_ref',\n 'descendants.entity_ref',\n )\n .from('descendants')\n .join(\n 'refresh_state_references',\n 'refresh_state_references.target_entity_ref',\n 'descendants.entity_ref',\n )\n .union(recursive =>\n recursive\n .select(\n 'refresh_state_references.source_key',\n 'refresh_state_references.source_entity_ref',\n 'refresh_state_references.target_entity_ref',\n 'ancestors.subject',\n )\n .from('ancestors')\n .join(\n 'refresh_state_references',\n 'refresh_state_references.target_entity_ref',\n 'ancestors.source_entity_ref',\n ),\n ),\n )\n // Finally, from that list of ancestor relations per descendant, pick\n // out the ones that are roots (have a source_key). Specifically, find\n // ones that seem to be be either (1) from another source, or (2)\n // aren't part of the deletion targets. Those are markers that tell us\n // that the corresponding descendant should be kept alive and NOT\n // subject to eager deletion, because there's \"something else\" (not\n // targeted for deletion) that has references down through the tree to\n // it.\n //\n // Continuing the scenario from above, for R3 we have\n //\n // KeyA-R1, KeyA-R4, KeyB-R6\n //\n // This tells us that R3 should be kept alive for two reasons: it's\n // referenced by a node that isn't being deleted (R4), and also by\n // another source (KeyB). What about R1 and R2? They both have\n //\n // KeyA-R1\n //\n // So those should be deleted, since they are definitely only being\n // kept alive by something that's about to be deleted.\n //\n // Final shape of the tree:\n //\n // R3\n // /\n // KeyA - R4 - R5\n // /\n // KeyB --- R6\n .with('retained', ['entity_ref'], notPartOfDeletion =>\n notPartOfDeletion\n .select('subject')\n .from('ancestors')\n .whereNotNull('ancestors.source_key')\n .where(foreignKeyOrRef =>\n foreignKeyOrRef\n .where('ancestors.source_key', '!=', sourceKey)\n .orWhereNotIn('ancestors.target_entity_ref', refs),\n ),\n )\n // Return all descendants minus the retained ones\n .select('descendants.entity_ref AS entity_ref')\n .from('descendants')\n .leftOuterJoin(\n 'retained',\n 'retained.entity_ref',\n 'descendants.entity_ref',\n )\n .whereNull('retained.entity_ref')\n .then(rows => rows.map(row => row.entity_ref));\n\n return { orphanEntityRefs: orphans };\n}\n\nasync function markEntitiesAffectedByDeletionForStitching(options: {\n knex: Knex | Knex.Transaction;\n entityRefs: string[];\n}) {\n const { knex, entityRefs } = options;\n\n // We want to re-stitch anything that has a relation pointing to the\n // soon-to-be-deleted entity. In many circumstances we also re-stitch children\n // in the refresh_state_references graph because their orphan state might\n // change, but not here - this code by its very definition is meant to not\n // leave any orphans behind, so we can simplify away that.\n const affectedIds = await knex\n .distinct('refresh_state.entity_id AS entity_id')\n .from('relations')\n .join(\n 'refresh_state',\n 'relations.source_entity_ref',\n 'refresh_state.entity_ref',\n )\n .whereIn('relations.target_entity_ref', entityRefs)\n .then(rows => rows.map(row => row.entity_id));\n\n await markForStitching({\n knex,\n entityIds: affectedIds,\n });\n}\n"],"names":["lodash","markForStitching"],"mappings":"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"}
1
+ {"version":3,"file":"deleteWithEagerPruningOfChildren.cjs.js","sources":["../../../../src/database/operations/provider/deleteWithEagerPruningOfChildren.ts"],"sourcesContent":["/*\n * Copyright 2022 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 lodash from 'lodash';\nimport { DbRefreshStateReferencesRow } from '../../tables';\nimport { markForStitching } from '../stitcher/markForStitching';\n\n/**\n * Given a number of entity refs originally created by a given entity provider\n * (source key), remove those entities from the refresh state, and at the same\n * time recursively remove every child that is a direct or indirect result of\n * processing those entities, if they would have otherwise become orphaned by\n * the removal of their parents.\n */\nexport async function deleteWithEagerPruningOfChildren(options: {\n knex: Knex | Knex.Transaction;\n entityRefs: string[];\n sourceKey: string;\n}): Promise<number> {\n const { knex, entityRefs, sourceKey } = options;\n\n // Split up the operation by (large) chunks, so that we do not hit database\n // limits for the number of permitted bindings on a precompiled statement\n let removedCount = 0;\n for (const refs of lodash.chunk(entityRefs, 1000)) {\n const { orphanEntityRefs } =\n await findDescendantsThatWouldHaveBeenOrphanedByDeletion({\n knex: options.knex,\n refs,\n sourceKey,\n });\n\n // Chunk again - these can be many more than the outer chunk size\n for (const refsToDelete of lodash.chunk(orphanEntityRefs, 1000)) {\n await markEntitiesAffectedByDeletionForStitching({\n knex: options.knex,\n entityRefs: refsToDelete,\n });\n await knex\n .delete()\n .from('refresh_state')\n .whereIn('entity_ref', refsToDelete);\n // Leave pending stitches intact. The stitcher safely settles missing\n // entities, while a concurrent reintroduction may still need this work.\n }\n\n // Delete the references that originate only from this entity provider. Note\n // that there may be more than one entity provider making a \"claim\" for a\n // given root entity, if they emit with the same location key.\n await knex<DbRefreshStateReferencesRow>('refresh_state_references')\n .where('source_key', '=', sourceKey)\n .whereIn('target_entity_ref', refs)\n .delete();\n\n removedCount += orphanEntityRefs.length;\n }\n\n return removedCount;\n}\n\nasync function findDescendantsThatWouldHaveBeenOrphanedByDeletion(options: {\n knex: Knex | Knex.Transaction;\n refs: string[];\n sourceKey: string;\n}): Promise<{ orphanEntityRefs: string[] }> {\n const { knex, refs, sourceKey } = options;\n\n const orphans: string[] =\n // First find all nodes that can be reached downwards from the roots\n // (deletion targets), including the roots themselves, by traversing\n // down the refresh_state_references table. Note that this query\n // starts with a condition that source_key = our source key, and\n // target_entity_ref is one of the deletion targets. This has two\n // effects: it won't match attempts at deleting something that didn't\n // originate from us in the first place, and also won't match non-root\n // entities (source_key would be null for those).\n //\n // KeyA - R1 - R2 Legend:\n // \\ -----------------------------------------\n // R3 Key* Source key\n // / R* Entity ref\n // KeyA - R4 - R5 lines Individual references; sources to\n // / the left and targets to the right\n // KeyB --- R6\n //\n // The scenario is that KeyA wants to delete R1.\n //\n // The query starts with the KeyA-R1 reference, and then traverses\n // down to also find R2 and R3. It uses union instead of union all,\n // because it wants to find the set of unique descendants even if\n // the tree has unexpected loops etc.\n await knex\n .withRecursive('descendants', ['entity_ref'], initial =>\n initial\n .select('target_entity_ref')\n .from('refresh_state_references')\n .where('source_key', '=', sourceKey)\n .whereIn('target_entity_ref', refs)\n .union(recursive =>\n recursive\n .select('refresh_state_references.target_entity_ref')\n .from('descendants')\n .join(\n 'refresh_state_references',\n 'descendants.entity_ref',\n 'refresh_state_references.source_entity_ref',\n ),\n ),\n )\n // Then for each descendant, traverse all the way back upwards through\n // the refresh_state_references table to get an exhaustive list of all\n // references that are part of keeping that particular descendant\n // alive.\n //\n // Continuing the scenario from above, starting from R3, it goes\n // upwards to find every pair along every relation line.\n //\n // Top branch: R2-R3, R1-R2, KeyA-R1\n // Middle branch: R5-R3, R4-R5, KeyA-R4\n // Bottom branch: R6-R5, KeyB-R6\n //\n // Note that this all applied to the subject R3. The exact same thing\n // will be done starting from each other descendant (R2 and R1). They\n // only have one and two references to find, respectively.\n //\n // This query also uses union instead of union all, to get the set of\n // distinct relations even if the tree has unexpected loops etc.\n .withRecursive(\n 'ancestors',\n ['source_key', 'source_entity_ref', 'target_entity_ref', 'subject'],\n initial =>\n initial\n .select(\n 'refresh_state_references.source_key',\n 'refresh_state_references.source_entity_ref',\n 'refresh_state_references.target_entity_ref',\n 'descendants.entity_ref',\n )\n .from('descendants')\n .join(\n 'refresh_state_references',\n 'refresh_state_references.target_entity_ref',\n 'descendants.entity_ref',\n )\n .union(recursive =>\n recursive\n .select(\n 'refresh_state_references.source_key',\n 'refresh_state_references.source_entity_ref',\n 'refresh_state_references.target_entity_ref',\n 'ancestors.subject',\n )\n .from('ancestors')\n .join(\n 'refresh_state_references',\n 'refresh_state_references.target_entity_ref',\n 'ancestors.source_entity_ref',\n ),\n ),\n )\n // Finally, from that list of ancestor relations per descendant, pick\n // out the ones that are roots (have a source_key). Specifically, find\n // ones that seem to be be either (1) from another source, or (2)\n // aren't part of the deletion targets. Those are markers that tell us\n // that the corresponding descendant should be kept alive and NOT\n // subject to eager deletion, because there's \"something else\" (not\n // targeted for deletion) that has references down through the tree to\n // it.\n //\n // Continuing the scenario from above, for R3 we have\n //\n // KeyA-R1, KeyA-R4, KeyB-R6\n //\n // This tells us that R3 should be kept alive for two reasons: it's\n // referenced by a node that isn't being deleted (R4), and also by\n // another source (KeyB). What about R1 and R2? They both have\n //\n // KeyA-R1\n //\n // So those should be deleted, since they are definitely only being\n // kept alive by something that's about to be deleted.\n //\n // Final shape of the tree:\n //\n // R3\n // /\n // KeyA - R4 - R5\n // /\n // KeyB --- R6\n .with('retained', ['entity_ref'], notPartOfDeletion =>\n notPartOfDeletion\n .select('subject')\n .from('ancestors')\n .whereNotNull('ancestors.source_key')\n .where(foreignKeyOrRef =>\n foreignKeyOrRef\n .where('ancestors.source_key', '!=', sourceKey)\n .orWhereNotIn('ancestors.target_entity_ref', refs),\n ),\n )\n // Return all descendants minus the retained ones\n .select('descendants.entity_ref AS entity_ref')\n .from('descendants')\n .leftOuterJoin(\n 'retained',\n 'retained.entity_ref',\n 'descendants.entity_ref',\n )\n .whereNull('retained.entity_ref')\n .then(rows => rows.map(row => row.entity_ref));\n\n return { orphanEntityRefs: orphans };\n}\n\nasync function markEntitiesAffectedByDeletionForStitching(options: {\n knex: Knex | Knex.Transaction;\n entityRefs: string[];\n}) {\n const { knex, entityRefs } = options;\n\n // We want to re-stitch anything that has a relation pointing to the\n // soon-to-be-deleted entity. In many circumstances we also re-stitch children\n // in the refresh_state_references graph because their orphan state might\n // change, but not here - this code by its very definition is meant to not\n // leave any orphans behind, so we can simplify away that.\n const affectedIds = await knex\n .distinct('refresh_state.entity_id AS entity_id')\n .from('relations')\n .join(\n 'refresh_state',\n 'relations.source_entity_ref',\n 'refresh_state.entity_ref',\n )\n .whereIn('relations.target_entity_ref', entityRefs)\n .then(rows => rows.map(row => row.entity_id));\n\n await markForStitching({\n knex,\n entityIds: affectedIds,\n });\n}\n"],"names":["lodash","markForStitching"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  var types = require('@backstage/types');
4
+ var node_crypto = require('node:crypto');
4
5
  var conversion = require('../../conversion.cjs.js');
5
6
 
6
7
  async function getDeferredStitchableEntities(options) {
@@ -9,7 +10,7 @@ async function getDeferredStitchableEntities(options) {
9
10
  knex.client.config.client
10
11
  );
11
12
  const run = async (tx) => {
12
- 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) => {
13
14
  if (useLocking) {
14
15
  qb.forUpdate().skipLocked();
15
16
  }
@@ -17,22 +18,63 @@ async function getDeferredStitchableEntities(options) {
17
18
  if (!items.length) {
18
19
  return [];
19
20
  }
20
- await tx("stitch_queue").whereIn(
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
+ };
31
+ const update = tx("stitch_queue").whereIn(
21
32
  "entity_ref",
22
33
  items.map((i) => i.entity_ref)
23
34
  ).update({
24
- next_stitch_at: nowPlus(tx, stitchTimeout)
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
+ ),
42
+ stitch_ticket: stitchTicket
25
43
  });
44
+ let leases;
45
+ if (tx.client.config.client === "pg") {
46
+ leases = await update.returning([
47
+ "entity_ref",
48
+ tx.raw("next_stitch_at::text as next_stitch_at")
49
+ ]);
50
+ } else if (String(tx.client.config.client).includes("mysql")) {
51
+ await update;
52
+ leases = await tx("stitch_queue").select(
53
+ "entity_ref",
54
+ tx.raw("CAST(next_stitch_at AS CHAR) as next_stitch_at")
55
+ ).whereIn(
56
+ "entity_ref",
57
+ items.map((i) => i.entity_ref)
58
+ );
59
+ } else {
60
+ leases = await update.returning(["entity_ref", "next_stitch_at"]);
61
+ }
62
+ const leaseByRef = new Map(
63
+ leases.map((i) => [i.entity_ref, i.next_stitch_at])
64
+ );
26
65
  return items.map((i) => ({
27
66
  entityRef: i.entity_ref,
28
- stitchTicket: i.stitch_ticket,
29
- stitchRequestedAt: conversion.timestampToDateTime(i.next_stitch_at)
67
+ stitchTicket,
68
+ stitchRequestedAt: conversion.timestampToDateTime(i.next_stitch_at),
69
+ stitchLeaseExpiresAt: leaseByRef.get(
70
+ i.entity_ref
71
+ )
30
72
  }));
31
73
  };
32
- return knex.isTransaction || !useLocking ? await run(knex) : await knex.transaction(run);
74
+ return knex.isTransaction ? await run(knex) : await knex.transaction(run);
33
75
  }
34
- function nowPlus(knex, duration) {
35
- const seconds = types.durationToMilliseconds(duration) / 1e3;
76
+ function nowPlus(knex, milliseconds) {
77
+ const seconds = milliseconds / 1e3;
36
78
  if (knex.client.config.client.includes("sqlite3")) {
37
79
  return knex.raw(`datetime('now', ?)`, [`${seconds} seconds`]);
38
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 { timestampToDateTime } from '../../conversion';\nimport { DbStitchQueueRow } from '../../tables';\n\n// TODO(freben): There is no retry counter or similar. If items start\n// perpetually crashing during stitching, they'll just get silently retried over\n// and over again, for better or worse. This will be visible in metrics though.\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 * They are expected to already have the stitch_ticket set (by\n * markForStitching) so that their tickets can be returned with each item.\n *\n * All returned items have their next_stitch_at updated to be moved forward by\n * the given timeout duration. 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 }>\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 const run = async (tx: Knex | Knex.Transaction) => {\n const items: DbStitchQueueRow[] = await tx('stitch_queue')\n .select('entity_ref', 'next_stitch_at', 'stitch_ticket')\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 await tx('stitch_queue')\n .whereIn(\n 'entity_ref',\n items.map(i => i.entity_ref),\n )\n .update({\n next_stitch_at: nowPlus(tx, stitchTimeout),\n });\n\n return items.map(i => ({\n entityRef: i.entity_ref,\n stitchTicket: i.stitch_ticket,\n stitchRequestedAt: timestampToDateTime(i.next_stitch_at),\n }));\n };\n\n return knex.isTransaction || !useLocking\n ? await run(knex)\n : await knex.transaction(run);\n}\n\nfunction nowPlus(knex: Knex, duration: HumanDuration): Knex.Raw {\n const seconds = durationToMilliseconds(duration) / 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":["timestampToDateTime","durationToMilliseconds"],"mappings":";;;;;AAyCA,eAAsB,8BAA8B,OAAA,EAUlD;AACA,EAAA,MAAM,EAAE,IAAA,EAAM,SAAA,EAAW,aAAA,EAAc,GAAI,OAAA;AAC3C,EAAA,MAAM,UAAA,GAAa,CAAC,OAAA,EAAS,QAAA,EAAU,IAAI,CAAA,CAAE,QAAA;AAAA,IAC3C,IAAA,CAAK,OAAO,MAAA,CAAO;AAAA,GACrB;AAOA,EAAA,MAAM,GAAA,GAAM,OAAO,EAAA,KAAgC;AACjD,IAAA,MAAM,KAAA,GAA4B,MAAM,EAAA,CAAG,cAAc,CAAA,CACtD,MAAA,CAAO,YAAA,EAAc,gBAAA,EAAkB,eAAe,CAAA,CACtD,KAAA,CAAM,gBAAA,EAAkB,IAAA,EAAM,GAAG,EAAA,CAAG,GAAA,EAAK,CAAA,CACzC,OAAA,CAAQ,gBAAA,EAAkB,KAAK,CAAA,CAC/B,KAAA,CAAM,SAAS,CAAA,CACf,MAAA,CAAO,CAAA,EAAA,KAAM;AACZ,MAAA,IAAI,UAAA,EAAY;AACd,QAAA,EAAA,CAAG,SAAA,GAAY,UAAA,EAAW;AAAA,MAC5B;AAAA,IACF,CAAC,CAAA;AAEH,IAAA,IAAI,CAAC,MAAM,MAAA,EAAQ;AACjB,MAAA,OAAO,EAAC;AAAA,IACV;AAEA,IAAA,MAAM,EAAA,CAAG,cAAc,CAAA,CACpB,OAAA;AAAA,MACC,YAAA;AAAA,MACA,KAAA,CAAM,GAAA,CAAI,CAAA,CAAA,KAAK,CAAA,CAAE,UAAU;AAAA,MAE5B,MAAA,CAAO;AAAA,MACN,cAAA,EAAgB,OAAA,CAAQ,EAAA,EAAI,aAAa;AAAA,KAC1C,CAAA;AAEH,IAAA,OAAO,KAAA,CAAM,IAAI,CAAA,CAAA,MAAM;AAAA,MACrB,WAAW,CAAA,CAAE,UAAA;AAAA,MACb,cAAc,CAAA,CAAE,aAAA;AAAA,MAChB,iBAAA,EAAmBA,8BAAA,CAAoB,CAAA,CAAE,cAAc;AAAA,KACzD,CAAE,CAAA;AAAA,EACJ,CAAA;AAEA,EAAA,OAAO,IAAA,CAAK,aAAA,IAAiB,CAAC,UAAA,GAC1B,MAAM,GAAA,CAAI,IAAI,CAAA,GACd,MAAM,IAAA,CAAK,WAAA,CAAY,GAAG,CAAA;AAChC;AAEA,SAAS,OAAA,CAAQ,MAAY,QAAA,EAAmC;AAC9D,EAAA,MAAM,OAAA,GAAUC,4BAAA,CAAuB,QAAQ,CAAA,GAAI,GAAA;AACnD,EAAA,IAAI,KAAK,MAAA,CAAO,MAAA,CAAO,MAAA,CAAO,QAAA,CAAS,SAAS,CAAA,EAAG;AACjD,IAAA,OAAO,KAAK,GAAA,CAAI,CAAA,kBAAA,CAAA,EAAsB,CAAC,CAAA,EAAG,OAAO,UAAU,CAAC,CAAA;AAAA,EAC9D,WAAW,IAAA,CAAK,MAAA,CAAO,OAAO,MAAA,CAAO,QAAA,CAAS,OAAO,CAAA,EAAG;AACtD,IAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,iBAAA,EAAoB,OAAO,CAAA,OAAA,CAAS,CAAA;AAAA,EACtD;AACA,EAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,kBAAA,EAAqB,OAAO,CAAA,SAAA,CAAW,CAAA;AACzD;;"}
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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"}
@@ -1,14 +1,10 @@
1
1
  'use strict';
2
2
 
3
3
  async function markDeferredStitchCompleted(option) {
4
- const { knex, entityRef, stitchTicket, result } = option;
5
- const deleted = await knex("stitch_queue").where("entity_ref", "=", entityRef).andWhere("stitch_ticket", "=", stitchTicket).delete();
4
+ const { knex, entityRef, stitchTicket, stitchLeaseExpiresAt } = option;
5
+ const deleted = await knex("stitch_queue").where("entity_ref", "=", entityRef).andWhere("stitch_ticket", "=", stitchTicket).andWhere("next_stitch_at", "=", stitchLeaseExpiresAt).delete();
6
6
  if (!deleted) {
7
- const update = knex("stitch_queue").where("entity_ref", "=", entityRef).update({ next_stitch_at: knex.fn.now() });
8
- if (result === "abandoned") {
9
- update.where("next_stitch_at", ">", knex.fn.now());
10
- }
11
- await update;
7
+ await knex("stitch_queue").where("entity_ref", "=", entityRef).where("next_stitch_at", "=", stitchLeaseExpiresAt).update({ next_stitch_at: knex.fn.now() });
12
8
  }
13
9
  }
14
10
 
@@ -1 +1 @@
1
- {"version":3,"file":"markDeferredStitchCompleted.cjs.js","sources":["../../../../src/database/operations/stitcher/markDeferredStitchCompleted.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 { DbStitchQueueRow } from '../../tables';\n\n/**\n * Marks a single entity as having been stitched.\n *\n * @remarks\n *\n * If the ticket still matches, the stitch_queue entry is deleted — no\n * further stitching is needed.\n *\n * If the ticket changed (a new stitch was requested while this one was\n * in progress), the entry is kept and its next_stitch_at is bumped to\n * now() so the re-stitch becomes immediately eligible for pickup.\n *\n * The `result` parameter controls how the bump behaves when the ticket\n * doesn't match:\n *\n * - `'succeeded'`: The worker wrote its result successfully. A ticket\n * mismatch means a re-stitch was requested. Bump to now()\n * unconditionally — we're done, the next worker should start ASAP.\n *\n * - `'abandoned'`: The worker's write was blocked by a stale ticket.\n * We can't tell whether the ticket changed because of a re-stitch\n * request (nobody else is active) or because we timed out and\n * another worker claimed the entry. Bump to now() only if the\n * timestamp hasn't moved past what we'd have set — i.e. only move\n * it earlier, never later. This prevents extending the timeout\n * window of an active worker, while still making overdue entries\n * eligible immediately.\n */\nexport async function markDeferredStitchCompleted(option: {\n knex: Knex | Knex.Transaction;\n entityRef: string;\n stitchTicket: string;\n result: 'succeeded' | 'abandoned';\n}): Promise<void> {\n const { knex, entityRef, stitchTicket, result } = option;\n\n const deleted = await knex<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', '=', entityRef)\n .andWhere('stitch_ticket', '=', stitchTicket)\n .delete();\n\n if (!deleted) {\n const update = knex<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', '=', entityRef)\n .update({ next_stitch_at: knex.fn.now() });\n\n if (result === 'abandoned') {\n // Only move the timestamp earlier, never later — if another\n // worker pushed it forward, we don't want to undercut their\n // timeout window.\n update.where('next_stitch_at', '>', knex.fn.now());\n }\n\n await update;\n }\n}\n"],"names":[],"mappings":";;AA+CA,eAAsB,4BAA4B,MAAA,EAKhC;AAChB,EAAA,MAAM,EAAE,IAAA,EAAM,SAAA,EAAW,YAAA,EAAc,QAAO,GAAI,MAAA;AAElD,EAAA,MAAM,OAAA,GAAU,MAAM,IAAA,CAAuB,cAAc,EACxD,KAAA,CAAM,YAAA,EAAc,GAAA,EAAK,SAAS,EAClC,QAAA,CAAS,eAAA,EAAiB,GAAA,EAAK,YAAY,EAC3C,MAAA,EAAO;AAEV,EAAA,IAAI,CAAC,OAAA,EAAS;AACZ,IAAA,MAAM,SAAS,IAAA,CAAuB,cAAc,CAAA,CACjD,KAAA,CAAM,cAAc,GAAA,EAAK,SAAS,CAAA,CAClC,MAAA,CAAO,EAAE,cAAA,EAAgB,IAAA,CAAK,EAAA,CAAG,GAAA,IAAO,CAAA;AAE3C,IAAA,IAAI,WAAW,WAAA,EAAa;AAI1B,MAAA,MAAA,CAAO,MAAM,gBAAA,EAAkB,GAAA,EAAK,IAAA,CAAK,EAAA,CAAG,KAAK,CAAA;AAAA,IACnD;AAEA,IAAA,MAAM,MAAA;AAAA,EACR;AACF;;"}
1
+ {"version":3,"file":"markDeferredStitchCompleted.cjs.js","sources":["../../../../src/database/operations/stitcher/markDeferredStitchCompleted.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 { DbStitchQueueRow } from '../../tables';\nimport { StitchLeaseExpiresAt } from './getDeferredStitchableEntities';\n\n/**\n * Marks a single entity as having been stitched.\n *\n * @remarks\n *\n * If the ticket and captured lease still match, the stitch_queue entry is\n * deleted — no further stitching is needed. Both guards are needed during\n * mixed-version rollouts: old workers reclaim without replacing the ticket.\n *\n * A changed ticket with the same lease means a new request arrived during this\n * attempt. Make it immediately eligible, whether this attempt succeeded or was\n * abandoned. A changed lease belongs to a successor and must not be disturbed.\n * The captured lease is opaque and must be passed through unchanged from\n * getDeferredStitchableEntities, not reconstructed from a Date or ISO string.\n */\nexport async function markDeferredStitchCompleted(option: {\n knex: Knex | Knex.Transaction;\n entityRef: string;\n stitchTicket: string;\n stitchLeaseExpiresAt: StitchLeaseExpiresAt;\n}): Promise<void> {\n const { knex, entityRef, stitchTicket, stitchLeaseExpiresAt } = option;\n\n const deleted = await knex<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', '=', entityRef)\n .andWhere('stitch_ticket', '=', stitchTicket)\n .andWhere('next_stitch_at', '=', stitchLeaseExpiresAt)\n .delete();\n\n if (!deleted) {\n await knex<DbStitchQueueRow>('stitch_queue')\n .where('entity_ref', '=', entityRef)\n .where('next_stitch_at', '=', stitchLeaseExpiresAt)\n .update({ next_stitch_at: knex.fn.now() });\n }\n}\n"],"names":[],"mappings":";;AAmCA,eAAsB,4BAA4B,MAAA,EAKhC;AAChB,EAAA,MAAM,EAAE,IAAA,EAAM,SAAA,EAAW,YAAA,EAAc,sBAAqB,GAAI,MAAA;AAEhE,EAAA,MAAM,OAAA,GAAU,MAAM,IAAA,CAAuB,cAAc,EACxD,KAAA,CAAM,YAAA,EAAc,KAAK,SAAS,CAAA,CAClC,SAAS,eAAA,EAAiB,GAAA,EAAK,YAAY,CAAA,CAC3C,QAAA,CAAS,kBAAkB,GAAA,EAAK,oBAAoB,EACpD,MAAA,EAAO;AAEV,EAAA,IAAI,CAAC,OAAA,EAAS;AACZ,IAAA,MAAM,IAAA,CAAuB,cAAc,CAAA,CACxC,KAAA,CAAM,cAAc,GAAA,EAAK,SAAS,EAClC,KAAA,CAAM,gBAAA,EAAkB,KAAK,oBAAoB,CAAA,CACjD,OAAO,EAAE,cAAA,EAAgB,KAAK,EAAA,CAAG,GAAA,IAAO,CAAA;AAAA,EAC7C;AACF;;"}
@@ -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,UAEH,UAAA,CAAW,YAAY,EACvB,KAAA,CAAM,CAAC,eAAe,CAAC,CAAA;AAAA,MAC5B;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,cAAA,EAAgB,IAAA,CAAK,EAAA,CAAG,GAAA;AAAI,WAC9B,CAAE;AAAA,UAEH,UAAA,CAAW,YAAY,EACvB,KAAA,CAAM,CAAC,eAAe,CAAC,CAAA;AAAA,MAC5B;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;;"}
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,12 +6,16 @@ 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');
11
+ var util = require('../../util.cjs.js');
10
12
 
11
13
  function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
12
14
 
13
15
  var stableStringify__default = /*#__PURE__*/_interopDefaultCompat(stableStringify);
14
16
 
17
+ class StitchPublicationSupersededError extends Error {
18
+ }
15
19
  function generateStableHash(entity) {
16
20
  return node_crypto.createHash("sha1").update(stableStringify__default.default({ ...entity })).digest("hex");
17
21
  }
@@ -20,7 +24,7 @@ const scriptProtocolPattern = (
20
24
  /^[\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
21
25
  );
22
26
  async function performStitching(options) {
23
- const { knex, logger, entityRef, stitchTicket } = options;
27
+ const { knex, logger, entityRef, stitchTicket, stitchLeaseExpiresAt } = options;
24
28
  let stitchResult;
25
29
  try {
26
30
  const [processedResult, relationsResult] = await Promise.all([
@@ -113,46 +117,84 @@ async function performStitching(options) {
113
117
  }
114
118
  const searchEntries = buildEntitySearch.buildEntitySearch(entityId, entity);
115
119
  const isMySQL = String(knex.client.config.client).includes("mysql");
116
- if (isMySQL) {
117
- const ticketValid = await knex("stitch_queue").where("entity_ref", entityRef).where("stitch_ticket", stitchTicket).first();
118
- if (!ticketValid) {
119
- logger.debug(
120
- `Entity ${entityRef} is already stitched, skipping write.`
121
- );
122
- stitchResult = "abandoned";
123
- return "abandoned";
124
- }
125
- }
126
- let upsert = knex("final_entities").insert({
127
- entity_id: entityId,
128
- entity_ref: entityRef,
129
- final_entity: JSON.stringify(entity),
130
- hash,
131
- last_updated_at: knex.fn.now()
132
- }).onConflict("entity_id").merge(["final_entity", "hash", "last_updated_at"]);
133
- if (!isMySQL) {
134
- upsert = upsert.where(
135
- knex.raw(
136
- "exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)",
137
- [entityRef, stitchTicket]
138
- )
139
- );
140
- }
141
- await upsert;
142
- if (!isMySQL) {
143
- const written = await knex("final_entities").where("entity_id", entityId).where("hash", hash).select(knex.raw("1")).first();
144
- if (!written) {
145
- logger.debug(
146
- `Entity ${entityRef} is already stitched, skipping write.`
147
- );
148
- stitchResult = "abandoned";
120
+ const isPostgres = knex.client.config.client === "pg";
121
+ const writeOutcome = await util.retryOnDeadlock(
122
+ () => knex.transaction(async (tx) => {
123
+ if (!isPostgres) {
124
+ const ticketValid = await tx("stitch_queue").where("entity_ref", entityRef).where("stitch_ticket", stitchTicket).first();
125
+ if (!ticketValid) return "abandoned";
126
+ }
127
+ let upsert = tx("final_entities").insert(
128
+ !isPostgres ? {
129
+ entity_id: entityId,
130
+ entity_ref: entityRef,
131
+ final_entity: JSON.stringify(entity),
132
+ hash,
133
+ last_updated_at: tx.fn.now()
134
+ } : tx.raw(
135
+ "(entity_id, entity_ref, final_entity, hash, last_updated_at) select ?, ?, ?, ?, CURRENT_TIMESTAMP where exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)",
136
+ [
137
+ entityId,
138
+ entityRef,
139
+ JSON.stringify(entity),
140
+ hash,
141
+ entityRef,
142
+ stitchTicket
143
+ ]
144
+ )
145
+ ).onConflict("entity_id").merge(["final_entity", "hash", "last_updated_at"]);
146
+ if (!isMySQL) {
147
+ upsert = upsert.where(
148
+ tx.raw(
149
+ "exists (select 1 from stitch_queue where entity_ref = ? and stitch_ticket = ?)",
150
+ [entityRef, stitchTicket]
151
+ )
152
+ );
153
+ }
154
+ await upsert;
155
+ if (!isMySQL) {
156
+ const written = await tx("final_entities").where("entity_id", entityId).where("hash", hash).modify((qb) => {
157
+ if (isPostgres) {
158
+ qb.whereExists(
159
+ tx("stitch_queue").select(tx.raw("1")).where("entity_ref", entityRef).where("stitch_ticket", stitchTicket).where("next_stitch_at", stitchLeaseExpiresAt)
160
+ );
161
+ }
162
+ }).select(tx.raw("1")).first();
163
+ if (!written) {
164
+ throw new StitchPublicationSupersededError();
165
+ }
166
+ }
167
+ await syncSearchRows.syncSearchRows(tx, entityId, searchEntries);
168
+ return "changed";
169
+ }),
170
+ knex
171
+ ).catch((error) => {
172
+ if (error instanceof StitchPublicationSupersededError) {
149
173
  return "abandoned";
150
174
  }
175
+ throw error;
176
+ });
177
+ if (writeOutcome === "abandoned") {
178
+ logger.debug(`Entity ${entityRef} is already stitched, skipping write.`);
179
+ stitchResult = "abandoned";
180
+ return "abandoned";
151
181
  }
152
- await syncSearchRows.syncSearchRows(knex, entityId, searchEntries);
153
182
  stitchResult = "succeeded";
154
183
  return "changed";
155
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
+ }
156
198
  throw error;
157
199
  } finally {
158
200
  if (stitchResult) {
@@ -160,7 +202,7 @@ async function performStitching(options) {
160
202
  knex,
161
203
  entityRef,
162
204
  stitchTicket,
163
- result: stitchResult
205
+ stitchLeaseExpiresAt
164
206
  });
165
207
  }
166
208
  }