@mastra/clickhouse 1.0.0-beta.11 → 1.0.0-beta.12

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 { createClient } from '@clickhouse/client';
2
2
  import { MastraError, ErrorCategory, ErrorDomain } from '@mastra/core/error';
3
- import { TABLE_SPANS, TABLE_RESOURCES, TABLE_SCORERS, TABLE_THREADS, TABLE_TRACES, TABLE_WORKFLOW_SNAPSHOT, TABLE_MESSAGES, MemoryStorage, TABLE_SCHEMAS, createStorageErrorId, normalizePerPage, calculatePagination, ObservabilityStorage, SPAN_SCHEMA, listTracesArgsSchema, ScoresStorage, transformScoreRow, SCORERS_SCHEMA, WorkflowsStorage, MastraStorage, TraceStatus, getSqlType, getDefaultValue, safelyParseJSON } from '@mastra/core/storage';
3
+ import { TABLE_SPANS, TABLE_RESOURCES, TABLE_SCORERS, TABLE_THREADS, TABLE_TRACES, TABLE_WORKFLOW_SNAPSHOT, TABLE_MESSAGES, MemoryStorage, TABLE_SCHEMAS, createStorageErrorId, normalizePerPage, calculatePagination, ObservabilityStorage, SPAN_SCHEMA, listTracesArgsSchema, ScoresStorage, transformScoreRow, SCORERS_SCHEMA, WorkflowsStorage, MastraCompositeStore, TraceStatus, getSqlType, getDefaultValue, safelyParseJSON } from '@mastra/core/storage';
4
4
  import { MessageList } from '@mastra/core/agent';
5
5
  import { MastraBase } from '@mastra/core/base';
6
6
  import { saveScorePayloadSchema } from '@mastra/core/evals';
@@ -13,8 +13,10 @@ var TABLE_ENGINES = {
13
13
  [TABLE_THREADS]: `ReplacingMergeTree()`,
14
14
  [TABLE_SCORERS]: `MergeTree()`,
15
15
  [TABLE_RESOURCES]: `ReplacingMergeTree()`,
16
- // TODO: verify this is the correct engine for Spans when implementing clickhouse storage
17
- [TABLE_SPANS]: `ReplacingMergeTree()`,
16
+ // ReplacingMergeTree(updatedAt) deduplicates rows with the same (traceId, spanId) sorting key,
17
+ // keeping the row with the highest updatedAt value. Combined with ORDER BY (traceId, spanId),
18
+ // this provides eventual uniqueness for the (traceId, spanId) composite key.
19
+ [TABLE_SPANS]: `ReplacingMergeTree(updatedAt)`,
18
20
  mastra_agents: `ReplacingMergeTree()`
19
21
  };
20
22
  var COLUMN_TYPES = {
@@ -97,6 +99,213 @@ var ClickhouseDB = class extends MastraBase {
97
99
  const columns = await result.json();
98
100
  return columns.some((c) => c.name === column);
99
101
  }
102
+ /**
103
+ * Checks if a table exists in the database.
104
+ */
105
+ async tableExists(tableName) {
106
+ try {
107
+ const result = await this.client.query({
108
+ query: `EXISTS TABLE ${tableName}`,
109
+ format: "JSONEachRow"
110
+ });
111
+ const rows = await result.json();
112
+ return rows[0]?.result === 1;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+ /**
118
+ * Gets the sorting key (ORDER BY columns) for a table.
119
+ * Returns null if the table doesn't exist.
120
+ */
121
+ async getTableSortingKey(tableName) {
122
+ try {
123
+ const result = await this.client.query({
124
+ query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {tableName:String}`,
125
+ query_params: { tableName },
126
+ format: "JSONEachRow"
127
+ });
128
+ const rows = await result.json();
129
+ return rows[0]?.sorting_key ?? null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ /**
135
+ * Checks if migration is needed for the spans table.
136
+ * Returns information about the current state.
137
+ */
138
+ async checkSpansMigrationStatus(tableName) {
139
+ const exists = await this.tableExists(tableName);
140
+ if (!exists) {
141
+ return { needsMigration: false, currentSortingKey: null };
142
+ }
143
+ const currentSortingKey = await this.getTableSortingKey(tableName);
144
+ if (!currentSortingKey) {
145
+ return { needsMigration: false, currentSortingKey: null };
146
+ }
147
+ const needsMigration = currentSortingKey.toLowerCase().startsWith("createdat");
148
+ return { needsMigration, currentSortingKey };
149
+ }
150
+ /**
151
+ * Checks for duplicate (traceId, spanId) combinations in the spans table.
152
+ * Returns information about duplicates for logging/CLI purposes.
153
+ */
154
+ async checkForDuplicateSpans(tableName) {
155
+ try {
156
+ const result = await this.client.query({
157
+ query: `
158
+ SELECT count() as duplicate_count
159
+ FROM (
160
+ SELECT traceId, spanId
161
+ FROM ${tableName}
162
+ GROUP BY traceId, spanId
163
+ HAVING count() > 1
164
+ )
165
+ `,
166
+ format: "JSONEachRow"
167
+ });
168
+ const rows = await result.json();
169
+ const duplicateCount = parseInt(rows[0]?.duplicate_count ?? "0", 10);
170
+ return {
171
+ hasDuplicates: duplicateCount > 0,
172
+ duplicateCount
173
+ };
174
+ } catch (error) {
175
+ this.logger?.debug?.(`Could not check for duplicates: ${error}`);
176
+ return { hasDuplicates: false, duplicateCount: 0 };
177
+ }
178
+ }
179
+ /**
180
+ * Migrates the spans table from the old sorting key (createdAt, traceId, spanId)
181
+ * to the new sorting key (traceId, spanId) for proper uniqueness enforcement.
182
+ *
183
+ * This migration:
184
+ * 1. Renames the old table to a backup
185
+ * 2. Creates a new table with the correct sorting key
186
+ * 3. Copies all data from the backup to the new table, deduplicating by (traceId, spanId)
187
+ * using priority-based selection:
188
+ * - First, prefer completed spans (those with endedAt set)
189
+ * - Then prefer the most recently updated span (highest updatedAt)
190
+ * - Finally use creation time as tiebreaker (highest createdAt)
191
+ * 4. Drops the backup table
192
+ *
193
+ * The deduplication strategy matches the PostgreSQL migration (PR #12073) to ensure
194
+ * consistent behavior across storage backends.
195
+ *
196
+ * The migration is idempotent - it only runs if the old sorting key is detected.
197
+ *
198
+ * @returns true if migration was performed, false if not needed
199
+ */
200
+ async migrateSpansTableSortingKey({
201
+ tableName,
202
+ schema
203
+ }) {
204
+ if (tableName !== TABLE_SPANS) {
205
+ return false;
206
+ }
207
+ const exists = await this.tableExists(tableName);
208
+ if (!exists) {
209
+ return false;
210
+ }
211
+ const currentSortingKey = await this.getTableSortingKey(tableName);
212
+ if (!currentSortingKey) {
213
+ return false;
214
+ }
215
+ const needsMigration = currentSortingKey.toLowerCase().startsWith("createdat");
216
+ if (!needsMigration) {
217
+ this.logger?.debug?.(`Spans table already has correct sorting key: ${currentSortingKey}`);
218
+ return false;
219
+ }
220
+ this.logger?.info?.(`Migrating spans table from sorting key "${currentSortingKey}" to "(traceId, spanId)"`);
221
+ const backupTableName = `${tableName}_backup_${Date.now()}`;
222
+ const rowTtl = this.ttl?.[tableName]?.row;
223
+ try {
224
+ await this.client.command({
225
+ query: `RENAME TABLE ${tableName} TO ${backupTableName}`
226
+ });
227
+ const columns = Object.entries(schema).map(([name, def]) => {
228
+ let sqlType = this.getSqlType(def.type);
229
+ let isNullable = def.nullable === true;
230
+ if (tableName === TABLE_SPANS && name === "updatedAt") {
231
+ isNullable = false;
232
+ }
233
+ if (isNullable) {
234
+ sqlType = `Nullable(${sqlType})`;
235
+ }
236
+ const constraints = [];
237
+ if (name === "metadata" && (def.type === "text" || def.type === "jsonb") && isNullable) {
238
+ constraints.push("DEFAULT '{}'");
239
+ }
240
+ const columnTtl = this.ttl?.[tableName]?.columns?.[name];
241
+ return `"${name}" ${sqlType} ${constraints.join(" ")} ${columnTtl ? `TTL toDateTime(${columnTtl.ttlKey ?? "createdAt"}) + INTERVAL ${columnTtl.interval} ${columnTtl.unit}` : ""}`;
242
+ }).join(",\n");
243
+ const createSql = `
244
+ CREATE TABLE ${tableName} (
245
+ ${columns}
246
+ )
247
+ ENGINE = ${TABLE_ENGINES[tableName] ?? "MergeTree()"}
248
+ PRIMARY KEY (traceId, spanId)
249
+ ORDER BY (traceId, spanId)
250
+ ${rowTtl ? `TTL toDateTime(${rowTtl.ttlKey ?? "createdAt"}) + INTERVAL ${rowTtl.interval} ${rowTtl.unit}` : ""}
251
+ SETTINGS index_granularity = 8192
252
+ `;
253
+ await this.client.command({
254
+ query: createSql
255
+ });
256
+ const describeResult = await this.client.query({
257
+ query: `DESCRIBE TABLE ${backupTableName}`,
258
+ format: "JSONEachRow"
259
+ });
260
+ const backupColumns = await describeResult.json();
261
+ const backupColumnNames = new Set(backupColumns.map((c) => c.name));
262
+ const columnsToInsert = Object.keys(schema).filter((col) => backupColumnNames.has(col));
263
+ const columnList = columnsToInsert.map((c) => `"${c}"`).join(", ");
264
+ const selectExpressions = columnsToInsert.map((c) => c === "updatedAt" ? `COALESCE("updatedAt", "createdAt") as "updatedAt"` : `"${c}"`).join(", ");
265
+ await this.client.command({
266
+ query: `INSERT INTO ${tableName} (${columnList})
267
+ SELECT ${selectExpressions}
268
+ FROM ${backupTableName}
269
+ ORDER BY traceId, spanId,
270
+ (endedAt IS NOT NULL AND endedAt != '') DESC,
271
+ COALESCE(updatedAt, createdAt) DESC,
272
+ createdAt DESC
273
+ LIMIT 1 BY traceId, spanId`
274
+ });
275
+ await this.client.command({
276
+ query: `DROP TABLE ${backupTableName}`
277
+ });
278
+ this.logger?.info?.(`Successfully migrated spans table to new sorting key`);
279
+ return true;
280
+ } catch (error) {
281
+ this.logger?.error?.(`Migration failed: ${error.message}`);
282
+ try {
283
+ const originalExists = await this.tableExists(tableName);
284
+ const backupExists = await this.tableExists(backupTableName);
285
+ if (!originalExists && backupExists) {
286
+ this.logger?.info?.(`Restoring spans table from backup`);
287
+ await this.client.command({
288
+ query: `RENAME TABLE ${backupTableName} TO ${tableName}`
289
+ });
290
+ } else if (originalExists && backupExists) {
291
+ await this.client.command({
292
+ query: `DROP TABLE IF EXISTS ${backupTableName}`
293
+ });
294
+ }
295
+ } catch (restoreError) {
296
+ this.logger?.error?.(`Failed to restore from backup: ${restoreError}`);
297
+ }
298
+ throw new MastraError(
299
+ {
300
+ id: createStorageErrorId("CLICKHOUSE", "MIGRATE_SPANS_SORTING_KEY", "FAILED"),
301
+ domain: ErrorDomain.STORAGE,
302
+ category: ErrorCategory.THIRD_PARTY,
303
+ details: { tableName, currentSortingKey }
304
+ },
305
+ error
306
+ );
307
+ }
308
+ }
100
309
  getSqlType(type) {
101
310
  switch (type) {
102
311
  case "text":
@@ -123,7 +332,10 @@ var ClickhouseDB = class extends MastraBase {
123
332
  try {
124
333
  const columns = Object.entries(schema).map(([name, def]) => {
125
334
  let sqlType = this.getSqlType(def.type);
126
- const isNullable = def.nullable === true;
335
+ let isNullable = def.nullable === true;
336
+ if (tableName === TABLE_SPANS && name === "updatedAt") {
337
+ isNullable = false;
338
+ }
127
339
  if (isNullable) {
128
340
  sqlType = `Nullable(${sqlType})`;
129
341
  }
@@ -153,8 +365,8 @@ var ClickhouseDB = class extends MastraBase {
153
365
  ${columns}
154
366
  )
155
367
  ENGINE = ${TABLE_ENGINES[tableName] ?? "MergeTree()"}
156
- PRIMARY KEY (createdAt, traceId, spanId)
157
- ORDER BY (createdAt, traceId, spanId)
368
+ PRIMARY KEY (traceId, spanId)
369
+ ORDER BY (traceId, spanId)
158
370
  ${rowTtl ? `TTL toDateTime(${rowTtl.ttlKey ?? "createdAt"}) + INTERVAL ${rowTtl.interval} ${rowTtl.unit}` : ""}
159
371
  SETTINGS index_granularity = 8192
160
372
  `;
@@ -300,7 +512,9 @@ var ClickhouseDB = class extends MastraBase {
300
512
  ...Object.fromEntries(
301
513
  Object.entries(record).map(([key, value]) => [
302
514
  key,
303
- TABLE_SCHEMAS[tableName]?.[key]?.type === "timestamp" ? new Date(value).toISOString() : value
515
+ // Only convert to Date if it's a timestamp column AND value is not null/undefined
516
+ // new Date(null) returns epoch date, not null, so we must check first
517
+ TABLE_SCHEMAS[tableName]?.[key]?.type === "timestamp" && value != null ? new Date(value).toISOString() : value
304
518
  ])
305
519
  )
306
520
  }));
@@ -1642,11 +1856,101 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1642
1856
  this.#db = new ClickhouseDB({ client, ttl });
1643
1857
  }
1644
1858
  async init() {
1859
+ const migrationStatus = await this.#db.checkSpansMigrationStatus(TABLE_SPANS);
1860
+ if (migrationStatus.needsMigration) {
1861
+ const duplicateInfo = await this.#db.checkForDuplicateSpans(TABLE_SPANS);
1862
+ const duplicateMessage = duplicateInfo.hasDuplicates ? `
1863
+ Found ${duplicateInfo.duplicateCount} duplicate (traceId, spanId) combinations that will be removed.
1864
+ ` : "";
1865
+ const errorMessage = `
1866
+ ===========================================================================
1867
+ MIGRATION REQUIRED: ClickHouse spans table needs sorting key update
1868
+ ===========================================================================
1869
+
1870
+ The spans table structure has changed. ClickHouse requires a table recreation
1871
+ to update the sorting key from (traceId) to (traceId, spanId).
1872
+ ` + duplicateMessage + `
1873
+ To fix this, run the manual migration command:
1874
+
1875
+ npx mastra migrate
1876
+
1877
+ This command will:
1878
+ 1. Create a new table with the correct sorting key
1879
+ 2. Copy data from the old table (deduplicating if needed)
1880
+ 3. Replace the old table with the new one
1881
+
1882
+ WARNING: This migration involves table recreation and may take significant
1883
+ time for large tables. Please ensure you have a backup before proceeding.
1884
+ ===========================================================================
1885
+ `;
1886
+ throw new MastraError({
1887
+ id: createStorageErrorId("CLICKHOUSE", "MIGRATION_REQUIRED", "SORTING_KEY_CHANGE"),
1888
+ domain: ErrorDomain.STORAGE,
1889
+ category: ErrorCategory.USER,
1890
+ text: errorMessage
1891
+ });
1892
+ }
1645
1893
  await this.#db.createTable({ tableName: TABLE_SPANS, schema: SPAN_SCHEMA });
1646
1894
  }
1647
1895
  async dangerouslyClearAll() {
1648
1896
  await this.#db.clearTable({ tableName: TABLE_SPANS });
1649
1897
  }
1898
+ /**
1899
+ * Manually run the spans migration to deduplicate and update the sorting key.
1900
+ * This is intended to be called from the CLI when duplicates are detected.
1901
+ *
1902
+ * @returns Migration result with status and details
1903
+ */
1904
+ async migrateSpans() {
1905
+ const migrationStatus = await this.#db.checkSpansMigrationStatus(TABLE_SPANS);
1906
+ if (!migrationStatus.needsMigration) {
1907
+ return {
1908
+ success: true,
1909
+ alreadyMigrated: true,
1910
+ duplicatesRemoved: 0,
1911
+ message: `Migration already complete. Spans table has correct sorting key.`
1912
+ };
1913
+ }
1914
+ const duplicateInfo = await this.#db.checkForDuplicateSpans(TABLE_SPANS);
1915
+ if (duplicateInfo.hasDuplicates) {
1916
+ this.logger?.info?.(
1917
+ `Found ${duplicateInfo.duplicateCount} duplicate (traceId, spanId) combinations. Starting migration with deduplication...`
1918
+ );
1919
+ } else {
1920
+ this.logger?.info?.(`No duplicate spans found. Starting sorting key migration...`);
1921
+ }
1922
+ await this.#db.migrateSpansTableSortingKey({ tableName: TABLE_SPANS, schema: SPAN_SCHEMA });
1923
+ return {
1924
+ success: true,
1925
+ alreadyMigrated: false,
1926
+ duplicatesRemoved: duplicateInfo.duplicateCount,
1927
+ message: duplicateInfo.hasDuplicates ? `Migration complete. Removed duplicates and updated sorting key for ${TABLE_SPANS}.` : `Migration complete. Updated sorting key for ${TABLE_SPANS}.`
1928
+ };
1929
+ }
1930
+ /**
1931
+ * Check migration status for the spans table.
1932
+ * Returns information about whether migration is needed.
1933
+ */
1934
+ async checkSpansMigrationStatus() {
1935
+ const migrationStatus = await this.#db.checkSpansMigrationStatus(TABLE_SPANS);
1936
+ if (!migrationStatus.needsMigration) {
1937
+ return {
1938
+ needsMigration: false,
1939
+ hasDuplicates: false,
1940
+ duplicateCount: 0,
1941
+ constraintExists: true,
1942
+ tableName: TABLE_SPANS
1943
+ };
1944
+ }
1945
+ const duplicateInfo = await this.#db.checkForDuplicateSpans(TABLE_SPANS);
1946
+ return {
1947
+ needsMigration: true,
1948
+ hasDuplicates: duplicateInfo.hasDuplicates,
1949
+ duplicateCount: duplicateInfo.duplicateCount,
1950
+ constraintExists: false,
1951
+ tableName: TABLE_SPANS
1952
+ };
1953
+ }
1650
1954
  get tracingStrategy() {
1651
1955
  return {
1652
1956
  preferred: "insert-only",
@@ -1980,10 +2284,10 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
1980
2284
  conditions.push(`(error IS NOT NULL AND error != '')`);
1981
2285
  break;
1982
2286
  case TraceStatus.RUNNING:
1983
- conditions.push(`(endedAt IS NULL OR endedAt = '') AND (error IS NULL OR error = '')`);
2287
+ conditions.push(`endedAt IS NULL AND (error IS NULL OR error = '')`);
1984
2288
  break;
1985
2289
  case TraceStatus.SUCCESS:
1986
- conditions.push(`(endedAt IS NOT NULL AND endedAt != '') AND (error IS NULL OR error = '')`);
2290
+ conditions.push(`endedAt IS NOT NULL AND (error IS NULL OR error = '')`);
1987
2291
  break;
1988
2292
  }
1989
2293
  }
@@ -2012,7 +2316,7 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
2012
2316
  if (sortField === "endedAt") {
2013
2317
  const nullSortValue = sortDirection === "DESC" ? 0 : 1;
2014
2318
  const nonNullSortValue = sortDirection === "DESC" ? 1 : 0;
2015
- orderClause = `ORDER BY CASE WHEN ${sortField} IS NULL OR ${sortField} = '' THEN ${nullSortValue} ELSE ${nonNullSortValue} END, ${sortField} ${sortDirection}`;
2319
+ orderClause = `ORDER BY CASE WHEN ${sortField} IS NULL THEN ${nullSortValue} ELSE ${nonNullSortValue} END, ${sortField} ${sortDirection}`;
2016
2320
  } else {
2017
2321
  orderClause = `ORDER BY ${sortField} ${sortDirection}`;
2018
2322
  }
@@ -2918,7 +3222,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
2918
3222
  var isClientConfig = (config) => {
2919
3223
  return "client" in config;
2920
3224
  };
2921
- var ClickhouseStore = class extends MastraStorage {
3225
+ var ClickhouseStore = class extends MastraCompositeStore {
2922
3226
  db;
2923
3227
  ttl = {};
2924
3228
  stores;