@mastra/spanner 1.9.3 → 1.9.4

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Spanner } from "@google-cloud/spanner";
2
2
  import { ErrorCategory, ErrorDomain, MastraError } from "@mastra/core/error";
3
- import { AGENTS_SCHEMA, AGENT_VERSIONS_SCHEMA, AgentsStorage, BackgroundTasksStorage, BlobStore, ChannelsStorage, DatasetsStorage, ExperimentsStorage, FavoritesStorage, MCPClientsStorage, MCPServersStorage, MCP_CLIENTS_SCHEMA, MCP_CLIENT_VERSIONS_SCHEMA, MCP_SERVERS_SCHEMA, MCP_SERVER_VERSIONS_SCHEMA, METRIC_DISTINCT_COLUMNS, MastraCompositeStore, MemoryStorage, ObservabilityStorage, PROMPT_BLOCKS_SCHEMA, PROMPT_BLOCK_VERSIONS_SCHEMA, PromptBlocksStorage, SCORER_DEFINITIONS_SCHEMA, SCORER_DEFINITION_VERSIONS_SCHEMA, SKILLS_SCHEMA, SKILL_BLOBS_SCHEMA, SKILL_VERSIONS_SCHEMA, SchedulesStorage, ScorerDefinitionsStorage, ScoresStorage, SkillsStorage, TABLE_AGENTS, TABLE_AGENT_VERSIONS, TABLE_BACKGROUND_TASKS, TABLE_CHANNEL_CONFIG, TABLE_CHANNEL_INSTALLATIONS, TABLE_CONFIGS, TABLE_DATASETS, TABLE_DATASET_ITEMS, TABLE_DATASET_VERSIONS, TABLE_EXPERIMENTS, TABLE_EXPERIMENT_RESULTS, TABLE_FAVORITES, TABLE_MCP_CLIENTS, TABLE_MCP_CLIENT_VERSIONS, TABLE_MCP_SERVERS, TABLE_MCP_SERVER_VERSIONS, TABLE_MESSAGES, TABLE_PROMPT_BLOCKS, TABLE_PROMPT_BLOCK_VERSIONS, TABLE_RESOURCES, TABLE_SCHEDULES, TABLE_SCHEDULE_TRIGGERS, TABLE_SCHEMAS, TABLE_SCORERS, TABLE_SCORER_DEFINITIONS, TABLE_SCORER_DEFINITION_VERSIONS, TABLE_SKILLS, TABLE_SKILL_BLOBS, TABLE_SKILL_VERSIONS, TABLE_SPANS, TABLE_THREADS, TABLE_WORKFLOW_DEFINITIONS, TABLE_WORKFLOW_SNAPSHOT, TABLE_WORKSPACES, TABLE_WORKSPACE_VERSIONS, TraceStatus, WORKFLOW_DEFINITIONS_SCHEMA, WORKSPACES_SCHEMA, WORKSPACE_VERSIONS_SCHEMA, WorkflowDefinitionsStorage, WorkflowsStorage, WorkspacesStorage, calculatePagination, createStorageErrorId, executeRetentionPrune, getDefaultValue, hasErrorCode, listMetricsArgsSchema, listTracesArgsSchema, matchesExpectedWorkflowStatus, normalizePerPage, normalizeScheduleTarget, resolveListOrderBy, resolveRetentionTargets, retentionCutoffMs, toTraceSpans, transformScoreRow, validateStorageMetadataFilter } from "@mastra/core/storage";
3
+ import { AGENTS_SCHEMA, AGENT_VERSIONS_SCHEMA, AgentsStorage, BackgroundTasksStorage, BlobStore, ChannelsStorage, DatasetsStorage, ExperimentsStorage, FavoritesStorage, MCPClientsStorage, MCPServersStorage, MCP_CLIENTS_SCHEMA, MCP_CLIENT_VERSIONS_SCHEMA, MCP_SERVERS_SCHEMA, MCP_SERVER_VERSIONS_SCHEMA, METRIC_DISTINCT_COLUMNS, MastraCompositeStore, MemoryStorage, ObservabilityStorage, PROMPT_BLOCKS_SCHEMA, PROMPT_BLOCK_VERSIONS_SCHEMA, PromptBlocksStorage, RUN_FENCING_TABLE_SCHEMAS, RunFenceConflictError, SCORER_DEFINITIONS_SCHEMA, SCORER_DEFINITION_VERSIONS_SCHEMA, SKILLS_SCHEMA, SKILL_BLOBS_SCHEMA, SKILL_VERSIONS_SCHEMA, SchedulesStorage, ScorerDefinitionsStorage, ScoresStorage, SkillsStorage, TABLE_AGENTS, TABLE_AGENT_VERSIONS, TABLE_BACKGROUND_TASKS, TABLE_CHANNEL_CONFIG, TABLE_CHANNEL_INSTALLATIONS, TABLE_CONFIGS, TABLE_DATASETS, TABLE_DATASET_ITEMS, TABLE_DATASET_VERSIONS, TABLE_EXPERIMENTS, TABLE_EXPERIMENT_RESULTS, TABLE_FAVORITES, TABLE_MCP_CLIENTS, TABLE_MCP_CLIENT_VERSIONS, TABLE_MCP_SERVERS, TABLE_MCP_SERVER_VERSIONS, TABLE_MEMORY_RUN_FENCES, TABLE_MESSAGES, TABLE_PROMPT_BLOCKS, TABLE_PROMPT_BLOCK_VERSIONS, TABLE_RESOURCES, TABLE_SCHEDULES, TABLE_SCHEDULE_TRIGGERS, TABLE_SCHEMAS, TABLE_SCORERS, TABLE_SCORER_DEFINITIONS, TABLE_SCORER_DEFINITION_VERSIONS, TABLE_SKILLS, TABLE_SKILL_BLOBS, TABLE_SKILL_VERSIONS, TABLE_SPANS, TABLE_THREADS, TABLE_WORKFLOW_DEFINITIONS, TABLE_WORKFLOW_RUN_OWNERS, TABLE_WORKFLOW_SNAPSHOT, TABLE_WORKSPACES, TABLE_WORKSPACE_VERSIONS, TraceStatus, WORKFLOW_DEFINITIONS_SCHEMA, WORKSPACES_SCHEMA, WORKSPACE_VERSIONS_SCHEMA, WorkflowDefinitionsStorage, WorkflowsStorage, WorkspacesStorage, calculatePagination, createStorageErrorId, executeRetentionPrune, getDefaultValue, hasErrorCode, isRunFenceConflictError, listMetricsArgsSchema, listTracesArgsSchema, matchesExpectedWorkflowStatus, matchesRunFence, normalizePerPage, normalizeScheduleTarget, resolveListOrderBy, resolveRetentionTargets, resolveRunFence, retentionCutoffMs, toTraceSpans, transformScoreRow, validateStorageMetadataFilter } from "@mastra/core/storage";
4
4
  import { MastraBase } from "@mastra/core/base";
5
5
  import { parseSqlIdentifier } from "@mastra/core/utils";
6
6
  import { MessageList } from "@mastra/core/agent";
@@ -6656,6 +6656,59 @@ var MCPServersSpanner = class MCPServersSpanner extends MCPServersStorage {
6656
6656
  }
6657
6657
  };
6658
6658
  //#endregion
6659
+ //#region src/storage/db/run-fencing.ts
6660
+ /** Epoch milliseconds on the database clock, read when the statement runs. */
6661
+ const DB_NOW_MS = "UNIX_MILLIS(CURRENT_TIMESTAMP())";
6662
+ /**
6663
+ * The check half of a fenced write; call it first inside the write's
6664
+ * read-write transaction. Reads in a read-write transaction lock the rows they
6665
+ * return until commit, so a claim that updates the row either waits for the
6666
+ * write to commit or aborts it, and the retried write sees the new claim.
6667
+ */
6668
+ async function assertRunFence(tx, claimsTable, fence, operation) {
6669
+ const [rows] = await tx.run({
6670
+ sql: `SELECT generation, ${quoteIdent("ownerId", "column name")} AS ownerId
6671
+ FROM ${quoteIdent(claimsTable, "table name")}
6672
+ WHERE ${quoteIdent("runId", "column name")} = @runId`,
6673
+ params: { runId: fence.runId },
6674
+ json: true
6675
+ });
6676
+ const row = rows[0];
6677
+ if (!matchesRunFence(row ? {
6678
+ generation: Number(row.generation),
6679
+ ownerId: String(row.ownerId)
6680
+ } : null, fence)) throw new RunFenceConflictError(fence, operation);
6681
+ }
6682
+ /**
6683
+ * Runs `fn` in a read-write transaction and commits it, retrying the whole
6684
+ * transaction when Spanner aborts it for conflicting with another one.
6685
+ */
6686
+ function inTransaction(db, fn) {
6687
+ return db.runWithAbortRetry(() => db.database.runTransactionAsync(async (tx) => {
6688
+ try {
6689
+ const result = await fn(tx);
6690
+ await tx.commit();
6691
+ return result;
6692
+ } catch (err) {
6693
+ await tx.rollback().catch((rollbackErr) => {
6694
+ throw new AggregateError([err, rollbackErr], "Transaction and rollback both failed");
6695
+ });
6696
+ throw err;
6697
+ }
6698
+ }));
6699
+ }
6700
+ /**
6701
+ * Runs `write` directly when it carries no fence, or in a transaction behind
6702
+ * the fence check when it does.
6703
+ */
6704
+ function withRunFence(db, claimsTable, fence, operation, write) {
6705
+ if (!fence) return write(void 0);
6706
+ return inTransaction(db, async (tx) => {
6707
+ await assertRunFence(tx, claimsTable, fence, operation);
6708
+ return write(tx);
6709
+ });
6710
+ }
6711
+ //#endregion
6659
6712
  //#region src/storage/domains/memory/index.ts
6660
6713
  function buildSpannerMessageMetadataFilter(metadataFilter) {
6661
6714
  if (!metadataFilter) return {
@@ -6719,7 +6772,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6719
6772
  this.skipDefaultIndexes = skipDefaultIndexes;
6720
6773
  this.indexes = indexes?.filter((idx) => MemorySpanner.MANAGED_TABLES.includes(idx.table));
6721
6774
  }
6722
- /** Creates the threads/messages/resources tables and any indexes. */
6775
+ /** Creates the threads/messages/resources and run fence tables and any indexes. */
6723
6776
  async init() {
6724
6777
  await this.db.createTable({
6725
6778
  tableName: TABLE_THREADS,
@@ -6733,6 +6786,10 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6733
6786
  tableName: TABLE_RESOURCES,
6734
6787
  schema: TABLE_SCHEMAS[TABLE_RESOURCES]
6735
6788
  });
6789
+ await this.db.createTable({
6790
+ tableName: TABLE_MEMORY_RUN_FENCES,
6791
+ schema: RUN_FENCING_TABLE_SCHEMAS[TABLE_MEMORY_RUN_FENCES]
6792
+ });
6736
6793
  await this.createDefaultIndexes();
6737
6794
  await this.createCustomIndexes();
6738
6795
  }
@@ -6758,11 +6815,92 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6758
6815
  if (!this.indexes || this.indexes.length === 0) return;
6759
6816
  await this.db.createIndexes(this.indexes);
6760
6817
  }
6761
- /** Removes every row from threads/messages/resources tables. Intended for tests. */
6818
+ /** Removes every row from threads/messages/resources and run fence tables. Intended for tests. */
6762
6819
  async dangerouslyClearAll() {
6763
6820
  await this.db.clearTable({ tableName: TABLE_MESSAGES });
6764
6821
  await this.db.clearTable({ tableName: TABLE_THREADS });
6765
6822
  await this.db.clearTable({ tableName: TABLE_RESOURCES });
6823
+ await this.db.clearTable({ tableName: TABLE_MEMORY_RUN_FENCES });
6824
+ }
6825
+ supportsRunFencing() {
6826
+ return true;
6827
+ }
6828
+ async raiseRunFence(fence) {
6829
+ const table = quoteIdent(TABLE_MEMORY_RUN_FENCES, "table name");
6830
+ const runIdCol = quoteIdent("runId", "column name");
6831
+ const ownerIdCol = quoteIdent("ownerId", "column name");
6832
+ const retiredAtCol = quoteIdent("retiredAt", "column name");
6833
+ try {
6834
+ return await inTransaction(this.db, async (tx) => {
6835
+ const [rows] = await tx.run({
6836
+ sql: `SELECT generation, ${ownerIdCol} AS ownerId FROM ${table} WHERE ${runIdCol} = @runId`,
6837
+ params: { runId: fence.runId },
6838
+ json: true
6839
+ });
6840
+ const row = rows[0];
6841
+ if (!row) {
6842
+ await tx.runUpdate({
6843
+ sql: `INSERT INTO ${table} (${runIdCol}, generation, ${ownerIdCol}) VALUES (@runId, @generation, @ownerId)`,
6844
+ params: {
6845
+ runId: fence.runId,
6846
+ generation: fence.generation,
6847
+ ownerId: fence.ownerId
6848
+ }
6849
+ });
6850
+ return true;
6851
+ }
6852
+ const generation = Number(row.generation);
6853
+ const raises = generation < fence.generation;
6854
+ const reaffirms = generation === fence.generation && String(row.ownerId) === fence.ownerId;
6855
+ if (!raises && !reaffirms) return false;
6856
+ await tx.runUpdate({
6857
+ sql: `UPDATE ${table} SET generation = @generation, ${ownerIdCol} = @ownerId, ${retiredAtCol} = NULL
6858
+ WHERE ${runIdCol} = @runId`,
6859
+ params: {
6860
+ runId: fence.runId,
6861
+ generation: fence.generation,
6862
+ ownerId: fence.ownerId
6863
+ }
6864
+ });
6865
+ return true;
6866
+ });
6867
+ } catch (error) {
6868
+ throw new MastraError({
6869
+ id: createStorageErrorId("SPANNER", "RAISE_RUN_FENCE", "FAILED"),
6870
+ domain: ErrorDomain.STORAGE,
6871
+ category: ErrorCategory.THIRD_PARTY,
6872
+ details: { runId: fence.runId }
6873
+ }, error);
6874
+ }
6875
+ }
6876
+ async retireRunFence(fence) {
6877
+ const table = quoteIdent(TABLE_MEMORY_RUN_FENCES, "table name");
6878
+ try {
6879
+ return await inTransaction(this.db, async (tx) => {
6880
+ const [count] = await tx.runUpdate({
6881
+ sql: `UPDATE ${table} SET ${quoteIdent("retiredAt", "column name")} = ${DB_NOW_MS}
6882
+ WHERE ${quoteIdent("runId", "column name")} = @runId AND generation = @generation
6883
+ AND ${quoteIdent("ownerId", "column name")} = @ownerId`,
6884
+ params: {
6885
+ runId: fence.runId,
6886
+ generation: fence.generation,
6887
+ ownerId: fence.ownerId
6888
+ }
6889
+ });
6890
+ return count === 1;
6891
+ });
6892
+ } catch (error) {
6893
+ throw new MastraError({
6894
+ id: createStorageErrorId("SPANNER", "RETIRE_RUN_FENCE", "FAILED"),
6895
+ domain: ErrorDomain.STORAGE,
6896
+ category: ErrorCategory.THIRD_PARTY,
6897
+ details: { runId: fence.runId }
6898
+ }, error);
6899
+ }
6900
+ }
6901
+ /** The fence check for a write already running in a read-write transaction. */
6902
+ async #assertFence(tx, fence, operation) {
6903
+ if (fence) await assertRunFence(tx, TABLE_MEMORY_RUN_FENCES, fence, operation);
6766
6904
  }
6767
6905
  /**
6768
6906
  * Fetches a thread row by id, or `null` when absent.
@@ -6931,9 +7069,9 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6931
7069
  }
6932
7070
  }
6933
7071
  /** Upserts a thread row by id (`INSERT OR UPDATE` semantics). */
6934
- async saveThread({ thread }) {
7072
+ async saveThread({ thread, fence }) {
6935
7073
  try {
6936
- await this.db.upsert({
7074
+ await withRunFence(this.db, TABLE_MEMORY_RUN_FENCES, resolveRunFence(this, fence), "saveThread", (tx) => this.db.upsert({
6937
7075
  tableName: TABLE_THREADS,
6938
7076
  record: {
6939
7077
  id: thread.id,
@@ -6942,10 +7080,12 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6942
7080
  metadata: thread.metadata ?? {},
6943
7081
  createdAt: thread.createdAt,
6944
7082
  updatedAt: thread.updatedAt
6945
- }
6946
- });
7083
+ },
7084
+ transaction: tx
7085
+ }));
6947
7086
  return thread;
6948
7087
  } catch (error) {
7088
+ if (isRunFenceConflictError(error)) throw error;
6949
7089
  throw new MastraError({
6950
7090
  id: createStorageErrorId("SPANNER", "SAVE_THREAD", "FAILED"),
6951
7091
  domain: ErrorDomain.STORAGE,
@@ -6955,7 +7095,8 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6955
7095
  }
6956
7096
  }
6957
7097
  /** Updates a thread's title and merges the metadata payload. */
6958
- async updateThread({ id, title, metadata }) {
7098
+ async updateThread({ id, title, metadata, fence: explicitFence }) {
7099
+ const fence = resolveRunFence(this, explicitFence);
6959
7100
  const tableThreads = quoteIdent(TABLE_THREADS, "table name");
6960
7101
  const now = /* @__PURE__ */ new Date();
6961
7102
  let merged = {};
@@ -6963,6 +7104,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
6963
7104
  try {
6964
7105
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
6965
7106
  try {
7107
+ await this.#assertFence(tx, fence, "updateThread");
6966
7108
  const [rows] = await tx.run({
6967
7109
  sql: `SELECT * FROM ${tableThreads} WHERE id = @id LIMIT 1`,
6968
7110
  params: { id },
@@ -7429,8 +7571,9 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7429
7571
  }
7430
7572
  }
7431
7573
  /** Upserts a batch of messages and bumps every touched thread's `updatedAt` in a single transaction. */
7432
- async saveMessages({ messages }) {
7574
+ async saveMessages({ messages, fence: explicitFence }) {
7433
7575
  if (messages.length === 0) return { messages: [] };
7576
+ const fence = resolveRunFence(this, explicitFence);
7434
7577
  const threadIds = /* @__PURE__ */ new Set();
7435
7578
  for (const message of messages) {
7436
7579
  if (!message.threadId) throw new MastraError({
@@ -7456,6 +7599,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7456
7599
  try {
7457
7600
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
7458
7601
  try {
7602
+ await this.#assertFence(tx, fence, "saveMessages");
7459
7603
  for (const message of messages) {
7460
7604
  if (!message.resourceId) throw new Error("Expected to find a resourceId for message");
7461
7605
  const sql = `INSERT OR UPDATE INTO ${tableMessages} (id, ${quoteIdent("thread_id", "column name")}, content, ${quoteIdent("createdAt", "column name")}, role, type, ${quoteIdent("resourceId", "column name")})
@@ -7493,6 +7637,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7493
7637
  }));
7494
7638
  return { messages: new MessageList().add(messages, "memory").get.all.db() };
7495
7639
  } catch (error) {
7640
+ if (isRunFenceConflictError(error)) throw error;
7496
7641
  throw new MastraError({
7497
7642
  id: createStorageErrorId("SPANNER", "SAVE_MESSAGES", "FAILED"),
7498
7643
  domain: ErrorDomain.STORAGE,
@@ -7502,8 +7647,9 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7502
7647
  }
7503
7648
  }
7504
7649
  /** Merges partial updates onto existing messages and bumps affected thread `updatedAt`. */
7505
- async updateMessages({ messages }) {
7650
+ async updateMessages({ messages, fence: explicitFence }) {
7506
7651
  if (!messages || messages.length === 0) return [];
7652
+ const fence = resolveRunFence(this, explicitFence);
7507
7653
  const messageIds = messages.map((m) => m.id);
7508
7654
  const params = {};
7509
7655
  const placeholders = messageIds.map((id, i) => {
@@ -7521,6 +7667,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7521
7667
  try {
7522
7668
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
7523
7669
  try {
7670
+ await this.#assertFence(tx, fence, "updateMessages");
7524
7671
  const [existingRows] = await tx.run({
7525
7672
  sql: selectSql,
7526
7673
  params,
@@ -7596,6 +7743,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7596
7743
  }
7597
7744
  }));
7598
7745
  } catch (error) {
7746
+ if (isRunFenceConflictError(error)) throw error;
7599
7747
  throw new MastraError({
7600
7748
  id: createStorageErrorId("SPANNER", "UPDATE_MESSAGES", "FAILED"),
7601
7749
  domain: ErrorDomain.STORAGE,
@@ -7619,8 +7767,9 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7619
7767
  });
7620
7768
  }
7621
7769
  /** Deletes a batch of messages and bumps the affected threads' `updatedAt`. */
7622
- async deleteMessages(messageIds) {
7770
+ async deleteMessages(messageIds, options) {
7623
7771
  if (!messageIds || messageIds.length === 0) return;
7772
+ const fence = resolveRunFence(this, options?.fence);
7624
7773
  try {
7625
7774
  const messageTableName = quoteIdent(TABLE_MESSAGES, "table name");
7626
7775
  const threadTableName = quoteIdent(TABLE_THREADS, "table name");
@@ -7638,6 +7787,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7638
7787
  const threadIds = threadRows.map((r) => r.thread_id).filter(Boolean);
7639
7788
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
7640
7789
  try {
7790
+ await this.#assertFence(tx, fence, "deleteMessages");
7641
7791
  await tx.runUpdate({
7642
7792
  sql: `DELETE FROM ${messageTableName} WHERE id IN (${placeholders})`,
7643
7793
  params
@@ -7655,6 +7805,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7655
7805
  }
7656
7806
  }));
7657
7807
  } catch (error) {
7808
+ if (isRunFenceConflictError(error)) throw error;
7658
7809
  throw new MastraError({
7659
7810
  id: createStorageErrorId("SPANNER", "DELETE_MESSAGES", "FAILED"),
7660
7811
  domain: ErrorDomain.STORAGE,
@@ -7713,13 +7864,15 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7713
7864
  return resource;
7714
7865
  }
7715
7866
  /** Updates a resource's `workingMemory` and merges its `metadata`. */
7716
- async updateResource({ resourceId, workingMemory, metadata }) {
7867
+ async updateResource({ resourceId, workingMemory, metadata, fence: explicitFence }) {
7868
+ const fence = resolveRunFence(this, explicitFence);
7717
7869
  const tableResources = quoteIdent(TABLE_RESOURCES, "table name");
7718
7870
  const now = /* @__PURE__ */ new Date();
7719
7871
  let updated = null;
7720
7872
  try {
7721
7873
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
7722
7874
  try {
7875
+ await this.#assertFence(tx, fence, "updateResource");
7723
7876
  const [rows] = await tx.run({
7724
7877
  sql: `SELECT * FROM ${tableResources} WHERE id = @id LIMIT 1`,
7725
7878
  params: { id: resourceId },
@@ -7783,6 +7936,7 @@ var MemorySpanner = class MemorySpanner extends MemoryStorage {
7783
7936
  }));
7784
7937
  return updated;
7785
7938
  } catch (error) {
7939
+ if (isRunFenceConflictError(error)) throw error;
7786
7940
  const mastraError = new MastraError({
7787
7941
  id: createStorageErrorId("SPANNER", "UPDATE_RESOURCE", "FAILED"),
7788
7942
  domain: ErrorDomain.STORAGE,
@@ -12290,6 +12444,30 @@ var WorkflowDefinitionsSpanner = class WorkflowDefinitionsSpanner extends Workfl
12290
12444
  };
12291
12445
  //#endregion
12292
12446
  //#region src/storage/domains/workflows/index.ts
12447
+ const RUN_OWNERS_TABLE = quoteIdent(TABLE_WORKFLOW_RUN_OWNERS, "table name");
12448
+ const RUN_ID = quoteIdent("runId", "column name");
12449
+ const OWNER_ID = quoteIdent("ownerId", "column name");
12450
+ const LEASE_EXPIRES_AT = quoteIdent("leaseExpiresAt", "column name");
12451
+ const UPDATED_AT = quoteIdent("updatedAt", "column name");
12452
+ /** Reads a run's ownership record, judging the lease against the database clock. */
12453
+ async function readRunOwner(q, runId) {
12454
+ const [rows] = await q.run({
12455
+ sql: `SELECT generation, ${OWNER_ID} AS ownerId, ${LEASE_EXPIRES_AT} AS leaseExpiresAt, ${DB_NOW_MS} AS nowMs
12456
+ FROM ${RUN_OWNERS_TABLE} WHERE ${RUN_ID} = @runId`,
12457
+ params: { runId },
12458
+ json: true
12459
+ });
12460
+ const row = rows[0];
12461
+ if (!row) return null;
12462
+ const leaseExpiresAt = row.leaseExpiresAt === null ? null : Number(row.leaseExpiresAt);
12463
+ return {
12464
+ runId,
12465
+ generation: Number(row.generation),
12466
+ ownerId: String(row.ownerId),
12467
+ leaseExpiresAt: leaseExpiresAt === null ? null : new Date(leaseExpiresAt),
12468
+ live: leaseExpiresAt !== null && leaseExpiresAt > Number(row.nowMs)
12469
+ };
12470
+ }
12293
12471
  /**
12294
12472
  * Spanner-backed storage for workflow run snapshots, including persistence,
12295
12473
  * incremental updates, and listing of historical runs.
@@ -12370,12 +12548,19 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12370
12548
  const indexes = this.getDefaultIndexDefinitions().filter((idx) => hasStatus || !idx.columns.some((c) => c.startsWith("snapshotStatus")));
12371
12549
  await this.db.createIndexes(indexes);
12372
12550
  }
12373
- /** Creates the workflow snapshot table, the snapshotStatus generated column, and indexes. */
12551
+ /**
12552
+ * Creates the workflow snapshot and run ownership tables, the snapshotStatus
12553
+ * generated column, and indexes.
12554
+ */
12374
12555
  async init() {
12375
12556
  await this.db.createTable({
12376
12557
  tableName: TABLE_WORKFLOW_SNAPSHOT,
12377
12558
  schema: TABLE_SCHEMAS[TABLE_WORKFLOW_SNAPSHOT]
12378
12559
  });
12560
+ await this.db.createTable({
12561
+ tableName: TABLE_WORKFLOW_RUN_OWNERS,
12562
+ schema: RUN_FENCING_TABLE_SCHEMAS[TABLE_WORKFLOW_RUN_OWNERS]
12563
+ });
12379
12564
  await this.ensureStatusColumn();
12380
12565
  await this.createDefaultIndexes();
12381
12566
  await this.createCustomIndexes();
@@ -12424,9 +12609,121 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12424
12609
  if (!this.indexes || this.indexes.length === 0) return;
12425
12610
  await this.db.createIndexes(this.indexes);
12426
12611
  }
12427
- /** Removes every workflow snapshot row. Intended for tests. */
12612
+ /** Removes every workflow snapshot and run ownership row. Intended for tests. */
12428
12613
  async dangerouslyClearAll() {
12429
12614
  await this.db.clearTable({ tableName: TABLE_WORKFLOW_SNAPSHOT });
12615
+ await this.db.clearTable({ tableName: TABLE_WORKFLOW_RUN_OWNERS });
12616
+ }
12617
+ supportsRunFencing() {
12618
+ return true;
12619
+ }
12620
+ #ownershipError(operation, runId, error) {
12621
+ return new MastraError({
12622
+ id: createStorageErrorId("SPANNER", operation, "FAILED"),
12623
+ domain: ErrorDomain.STORAGE,
12624
+ category: ErrorCategory.THIRD_PARTY,
12625
+ details: { runId }
12626
+ }, error);
12627
+ }
12628
+ async claimRunOwnership({ runId, ownerId, leaseMs, force, expectedGeneration }) {
12629
+ try {
12630
+ return await inTransaction(this.db, async (tx) => {
12631
+ const existing = await readRunOwner(tx, runId);
12632
+ if (!existing) {
12633
+ if (expectedGeneration !== void 0 && expectedGeneration !== 0) return {
12634
+ acquired: false,
12635
+ record: null
12636
+ };
12637
+ await tx.runUpdate({
12638
+ sql: `INSERT INTO ${RUN_OWNERS_TABLE} (${RUN_ID}, generation, ${OWNER_ID}, ${LEASE_EXPIRES_AT}, ${UPDATED_AT})
12639
+ VALUES (@runId, ${DB_NOW_MS}, @ownerId, ${DB_NOW_MS} + @leaseMs, ${DB_NOW_MS})`,
12640
+ params: {
12641
+ runId,
12642
+ ownerId,
12643
+ leaseMs
12644
+ }
12645
+ });
12646
+ return {
12647
+ acquired: true,
12648
+ record: await readRunOwner(tx, runId)
12649
+ };
12650
+ }
12651
+ if (!(expectedGeneration === void 0 || existing.generation === expectedGeneration) || !force && existing.live) return {
12652
+ acquired: false,
12653
+ record: existing
12654
+ };
12655
+ await tx.runUpdate({
12656
+ sql: `UPDATE ${RUN_OWNERS_TABLE}
12657
+ SET generation = generation + 1, ${OWNER_ID} = @ownerId, ${LEASE_EXPIRES_AT} = ${DB_NOW_MS} + @leaseMs,
12658
+ ${UPDATED_AT} = ${DB_NOW_MS}
12659
+ WHERE ${RUN_ID} = @runId`,
12660
+ params: {
12661
+ runId,
12662
+ ownerId,
12663
+ leaseMs
12664
+ }
12665
+ });
12666
+ return {
12667
+ acquired: true,
12668
+ record: await readRunOwner(tx, runId)
12669
+ };
12670
+ });
12671
+ } catch (error) {
12672
+ throw this.#ownershipError("CLAIM_RUN_OWNERSHIP", runId, error);
12673
+ }
12674
+ }
12675
+ async renewRunOwnership({ leaseMs, ...fence }) {
12676
+ try {
12677
+ return await inTransaction(this.db, async (tx) => {
12678
+ const [count] = await tx.runUpdate({
12679
+ sql: `UPDATE ${RUN_OWNERS_TABLE}
12680
+ SET ${LEASE_EXPIRES_AT} = ${DB_NOW_MS} + @leaseMs, ${UPDATED_AT} = ${DB_NOW_MS}
12681
+ WHERE ${RUN_ID} = @runId AND generation = @generation AND ${OWNER_ID} = @ownerId
12682
+ AND ${LEASE_EXPIRES_AT} IS NOT NULL`,
12683
+ params: {
12684
+ leaseMs,
12685
+ runId: fence.runId,
12686
+ generation: fence.generation,
12687
+ ownerId: fence.ownerId
12688
+ }
12689
+ });
12690
+ const record = await readRunOwner(tx, fence.runId);
12691
+ return count === 1 && record ? {
12692
+ renewed: true,
12693
+ record
12694
+ } : {
12695
+ renewed: false,
12696
+ record
12697
+ };
12698
+ });
12699
+ } catch (error) {
12700
+ throw this.#ownershipError("RENEW_RUN_OWNERSHIP", fence.runId, error);
12701
+ }
12702
+ }
12703
+ async releaseRunOwnership(fence) {
12704
+ try {
12705
+ return await inTransaction(this.db, async (tx) => {
12706
+ const [count] = await tx.runUpdate({
12707
+ sql: `UPDATE ${RUN_OWNERS_TABLE} SET ${LEASE_EXPIRES_AT} = NULL, ${UPDATED_AT} = ${DB_NOW_MS}
12708
+ WHERE ${RUN_ID} = @runId AND generation = @generation AND ${OWNER_ID} = @ownerId`,
12709
+ params: {
12710
+ runId: fence.runId,
12711
+ generation: fence.generation,
12712
+ ownerId: fence.ownerId
12713
+ }
12714
+ });
12715
+ return count === 1;
12716
+ });
12717
+ } catch (error) {
12718
+ throw this.#ownershipError("RELEASE_RUN_OWNERSHIP", fence.runId, error);
12719
+ }
12720
+ }
12721
+ async getRunOwnership({ runId }) {
12722
+ try {
12723
+ return await readRunOwner(this.database, runId);
12724
+ } catch (error) {
12725
+ throw this.#ownershipError("GET_RUN_OWNERSHIP", runId, error);
12726
+ }
12430
12727
  }
12431
12728
  /** Decodes a raw Spanner snapshot row into the public `WorkflowRun` shape. */
12432
12729
  parseWorkflowRun(row) {
@@ -12450,12 +12747,14 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12450
12747
  };
12451
12748
  }
12452
12749
  /** Upserts a workflow run snapshot, preserving the original `createdAt` on update. */
12453
- async persistWorkflowSnapshot({ workflowName, runId, resourceId, snapshot, createdAt, updatedAt }) {
12750
+ async persistWorkflowSnapshot({ workflowName, runId, resourceId, snapshot, createdAt, updatedAt, fence: explicitFence }) {
12751
+ const fence = resolveRunFence(this, explicitFence, runId);
12454
12752
  const now = /* @__PURE__ */ new Date();
12455
12753
  const table = quoteIdent(TABLE_WORKFLOW_SNAPSHOT, "table name");
12456
12754
  try {
12457
12755
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
12458
12756
  try {
12757
+ if (fence) await assertRunFence(tx, TABLE_WORKFLOW_RUN_OWNERS, fence, "persistWorkflowSnapshot");
12459
12758
  const [rows] = await tx.run({
12460
12759
  sql: `SELECT ${quoteIdent("createdAt", "column name")}, ${quoteIdent("resourceId", "column name")} FROM ${table}
12461
12760
  WHERE workflow_name = @workflow_name AND run_id = @run_id`,
@@ -12489,6 +12788,7 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12489
12788
  }
12490
12789
  }));
12491
12790
  } catch (error) {
12791
+ if (isRunFenceConflictError(error)) throw error;
12492
12792
  throw new MastraError({
12493
12793
  id: createStorageErrorId("SPANNER", "PERSIST_WORKFLOW_SNAPSHOT", "FAILED"),
12494
12794
  domain: ErrorDomain.STORAGE,
@@ -12525,12 +12825,14 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12525
12825
  }
12526
12826
  }
12527
12827
  /** Merges a step result and request-context delta into a snapshot inside an RW transaction. */
12528
- async updateWorkflowResults({ workflowName, runId, stepId, result, requestContext, state }) {
12828
+ async updateWorkflowResults({ workflowName, runId, stepId, result, requestContext, state, fence: explicitFence }) {
12829
+ const fence = resolveRunFence(this, explicitFence, runId);
12529
12830
  const table = quoteIdent(TABLE_WORKFLOW_SNAPSHOT, "table name");
12530
12831
  try {
12531
12832
  let mergedContext = {};
12532
12833
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
12533
12834
  try {
12835
+ if (fence) await assertRunFence(tx, TABLE_WORKFLOW_RUN_OWNERS, fence, "updateWorkflowResults");
12534
12836
  const [rows] = await tx.run({
12535
12837
  sql: `SELECT snapshot, ${quoteIdent("createdAt", "column name")} FROM ${table}
12536
12838
  WHERE workflow_name = @workflow_name AND run_id = @run_id`,
@@ -12590,6 +12892,7 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12590
12892
  }));
12591
12893
  return mergedContext;
12592
12894
  } catch (error) {
12895
+ if (isRunFenceConflictError(error)) throw error;
12593
12896
  throw new MastraError({
12594
12897
  id: createStorageErrorId("SPANNER", "UPDATE_WORKFLOW_RESULTS", "FAILED"),
12595
12898
  domain: ErrorDomain.STORAGE,
@@ -12603,13 +12906,15 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12603
12906
  }
12604
12907
  }
12605
12908
  /** Applies a top-level snapshot patch (e.g. status, value) inside an RW transaction. */
12606
- async updateWorkflowState({ workflowName, runId, opts }) {
12909
+ async updateWorkflowState({ workflowName, runId, opts, fence: explicitFence }) {
12910
+ const fence = resolveRunFence(this, explicitFence, runId);
12607
12911
  const table = quoteIdent(TABLE_WORKFLOW_SNAPSHOT, "table name");
12608
12912
  try {
12609
12913
  let updated;
12610
12914
  await this.db.runWithAbortRetry(() => this.database.runTransactionAsync(async (tx) => {
12611
12915
  updated = void 0;
12612
12916
  try {
12917
+ if (fence) await assertRunFence(tx, TABLE_WORKFLOW_RUN_OWNERS, fence, "updateWorkflowState");
12613
12918
  const [rows] = await tx.run({
12614
12919
  sql: `SELECT snapshot FROM ${table} WHERE workflow_name = @workflow_name AND run_id = @run_id`,
12615
12920
  params: {
@@ -12720,17 +13025,18 @@ var WorkflowsSpanner = class WorkflowsSpanner extends WorkflowsStorage {
12720
13025
  }
12721
13026
  }
12722
13027
  /** Deletes the snapshot row for a `(workflowName, runId)` pair. */
12723
- async deleteWorkflowRunById({ runId, workflowName }) {
13028
+ async deleteWorkflowRunById({ runId, workflowName, fence }) {
12724
13029
  const table = quoteIdent(TABLE_WORKFLOW_SNAPSHOT, "table name");
12725
13030
  try {
12726
- await this.db.runDml({
13031
+ await withRunFence(this.db, TABLE_WORKFLOW_RUN_OWNERS, resolveRunFence(this, fence, runId), "deleteWorkflowRunById", (tx) => this.db.runDml({
12727
13032
  sql: `DELETE FROM ${table} WHERE workflow_name = @workflow_name AND run_id = @run_id`,
12728
13033
  params: {
12729
13034
  workflow_name: workflowName,
12730
13035
  run_id: runId
12731
13036
  }
12732
- });
13037
+ }, tx));
12733
13038
  } catch (error) {
13039
+ if (isRunFenceConflictError(error)) throw error;
12734
13040
  throw new MastraError({
12735
13041
  id: createStorageErrorId("SPANNER", "DELETE_WORKFLOW_RUN_BY_ID", "FAILED"),
12736
13042
  domain: ErrorDomain.STORAGE,