@mastra/clickhouse 1.23.0-alpha.0 → 1.23.0-alpha.2

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 (31) hide show
  1. package/dist/docs/SKILL.md +1 -1
  2. package/dist/docs/assets/SOURCE_MAP.json +1 -1
  3. package/dist/index.cjs +815 -386
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +816 -387
  6. package/dist/index.js.map +1 -1
  7. package/dist/storage/db/index.d.ts +8 -0
  8. package/dist/storage/db/index.d.ts.map +1 -1
  9. package/dist/storage/db/utils.d.ts +11 -0
  10. package/dist/storage/db/utils.d.ts.map +1 -1
  11. package/dist/storage/domains/memory/index.d.ts +2 -0
  12. package/dist/storage/domains/memory/index.d.ts.map +1 -1
  13. package/dist/storage/domains/observability/index.d.ts.map +1 -1
  14. package/dist/storage/domains/observability/v-next/ddl.d.ts +5 -5
  15. package/dist/storage/domains/observability/v-next/ddl.d.ts.map +1 -1
  16. package/dist/storage/domains/observability/v-next/feedback.d.ts.map +1 -1
  17. package/dist/storage/domains/observability/v-next/index.d.ts +3 -2
  18. package/dist/storage/domains/observability/v-next/index.d.ts.map +1 -1
  19. package/dist/storage/domains/observability/v-next/metrics.d.ts.map +1 -1
  20. package/dist/storage/domains/observability/v-next/polling.d.ts +2 -0
  21. package/dist/storage/domains/observability/v-next/polling.d.ts.map +1 -1
  22. package/dist/storage/domains/observability/v-next/scores.d.ts.map +1 -1
  23. package/dist/storage/domains/observability/v-next/span-query.d.ts +45 -0
  24. package/dist/storage/domains/observability/v-next/span-query.d.ts.map +1 -0
  25. package/dist/storage/domains/observability/v-next/trace-query.d.ts +21 -2
  26. package/dist/storage/domains/observability/v-next/trace-query.d.ts.map +1 -1
  27. package/dist/storage/domains/observability/v-next/trace-roots.d.ts.map +1 -1
  28. package/dist/storage/domains/observability/v-next/tracing.d.ts.map +1 -1
  29. package/dist/storage/domains/scores/index.d.ts.map +1 -1
  30. package/dist/storage/domains/workflows/index.d.ts.map +1 -1
  31. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createClient } from "@clickhouse/client";
1
+ import { TupleParam, createClient } from "@clickhouse/client";
2
2
  import { ErrorCategory, ErrorDomain, MastraError } from "@mastra/core/error";
3
3
  import * as coreStorage from "@mastra/core/storage";
4
4
  import { BRANCH_SPAN_TYPES, EntityType, METRIC_DISTINCT_COLUMNS, MastraCompositeStore, MemoryStorage, ObservabilityStorage, SCORERS_SCHEMA, SPAN_SCHEMA, ScoresStorage, TABLE_AGENT_VERSIONS, TABLE_DATASETS, TABLE_DATASET_ITEMS, TABLE_DATASET_VERSIONS, TABLE_EXPERIMENTS, TABLE_EXPERIMENT_RESULTS, TABLE_FAVORITES, TABLE_HARNESS_SESSIONS, TABLE_KNOWLEDGE_ACTIVITY, TABLE_KNOWLEDGE_CURSORS, TABLE_KNOWLEDGE_MENTIONS, TABLE_KNOWLEDGE_NODES, TABLE_KNOWLEDGE_RECORDS, TABLE_KNOWLEDGE_SEMANTIC_OUTBOX, TABLE_MCP_CLIENTS, TABLE_MCP_CLIENT_VERSIONS, TABLE_MCP_SERVERS, TABLE_MCP_SERVER_VERSIONS, TABLE_MESSAGES, TABLE_NOTIFICATIONS, 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_THREAD_STATE, TABLE_TOOL_PROVIDER_CONNECTIONS, TABLE_TRACES, TABLE_WORKFLOW_DEFINITIONS, TABLE_WORKFLOW_SNAPSHOT, TABLE_WORKSPACES, TABLE_WORKSPACE_VERSIONS, TraceStatus, WorkflowsStorage, buildInputPreview, calculatePagination, computeTraceStatus, createStorageErrorId, getDefaultValue, getSqlType, listBranchesArgsSchema, listFeedbackArgsSchema, listLogsArgsSchema, listMetricsArgsSchema, listScoresArgsSchema, listTracesArgsSchema, normalizePerPage, safelyParseJSON, toTraceSpans, transformScoreRow, validateStorageMetadataFilter } from "@mastra/core/storage";
@@ -256,6 +256,35 @@ const TABLE_ENGINES = {
256
256
  [TABLE_KNOWLEDGE_ACTIVITY]: `ReplacingMergeTree()`,
257
257
  [TABLE_KNOWLEDGE_SEMANTIC_OUTBOX]: `ReplacingMergeTree()`
258
258
  };
259
+ /**
260
+ * Bloom-filter skip indexes for lookups on columns outside the sort key.
261
+ *
262
+ * These tables are keyed `(createdAt, id)` (or `(createdAt, run_id, ...)` for
263
+ * workflow snapshots), so every lookup by id, thread, resource or run would
264
+ * otherwise scan every granule. Added idempotently at init; existing parts are
265
+ * covered as merges and retention rewrite them (or via `MATERIALIZE INDEX`).
266
+ */
267
+ const TABLE_SKIP_INDEXES = {
268
+ [TABLE_MESSAGES]: [
269
+ "id",
270
+ "thread_id",
271
+ "resourceId"
272
+ ],
273
+ [TABLE_THREADS]: ["id", "resourceId"],
274
+ [TABLE_RESOURCES]: ["id"],
275
+ [TABLE_WORKFLOW_SNAPSHOT]: ["run_id"],
276
+ [TABLE_SCORERS]: [
277
+ "id",
278
+ "runId",
279
+ "scorerId",
280
+ "entityId",
281
+ "traceId"
282
+ ]
283
+ };
284
+ /** Name of the skip index {@link TABLE_SKIP_INDEXES} declares for `column`. */
285
+ function skipIndexName(column) {
286
+ return `idx_${column}`;
287
+ }
259
288
  const COLUMN_TYPES = {
260
289
  text: "String",
261
290
  timestamp: "DateTime64(3)",
@@ -368,10 +397,39 @@ var ClickhouseDB = class extends MastraBase {
368
397
  return filtered;
369
398
  }
370
399
  async hasColumn(table, column) {
371
- return (await (await this.client.query({
372
- query: `DESCRIBE TABLE ${table}`,
373
- format: "JSONEachRow"
374
- })).json()).some((c) => c.name === column);
400
+ return (await this.getTableColumns(table)).has(column);
401
+ }
402
+ /**
403
+ * Adds the bloom-filter skip indexes declared in {@link TABLE_SKIP_INDEXES}
404
+ * for `tableName`. Call after createTable/alterTable so optional columns
405
+ * exist. Indexes (or columns) already present are skipped: on Replicated
406
+ * tables every issued ALTER bumps the metadata version even with
407
+ * `IF NOT EXISTS`.
408
+ */
409
+ async ensureSkipIndexes(tableName) {
410
+ const columns = TABLE_SKIP_INDEXES[tableName];
411
+ if (!columns?.length) return;
412
+ try {
413
+ const existingColumns = await this.getTableColumns(tableName);
414
+ const result = await this.client.query({
415
+ query: `SELECT name FROM system.data_skipping_indices WHERE database = currentDatabase() AND table = {table:String}`,
416
+ query_params: { table: tableName },
417
+ format: "JSONEachRow"
418
+ });
419
+ const existingIndexes = new Set((await result.json()).map((row) => row.name));
420
+ for (const column of columns) {
421
+ const name = skipIndexName(column);
422
+ if (existingIndexes.has(name) || !existingColumns.has(column)) continue;
423
+ await this.client.command({ query: addOnClusterToDDL(`ALTER TABLE ${tableName} ADD INDEX IF NOT EXISTS ${name} "${column}" TYPE bloom_filter(0.01) GRANULARITY 1`, this.replication) });
424
+ }
425
+ } catch (error) {
426
+ throw new MastraError({
427
+ id: createStorageErrorId("CLICKHOUSE", "ENSURE_SKIP_INDEXES", "FAILED"),
428
+ domain: ErrorDomain.STORAGE,
429
+ category: ErrorCategory.THIRD_PARTY,
430
+ details: { tableName }
431
+ }, error);
432
+ }
375
433
  }
376
434
  /**
377
435
  * Checks if a table exists in the database.
@@ -764,7 +822,8 @@ var ClickhouseDB = class extends MastraBase {
764
822
  date_time_input_format: "best_effort",
765
823
  date_time_output_format: "iso",
766
824
  use_client_time_zone: 1,
767
- output_format_json_quote_64bit_integers: 0
825
+ output_format_json_quote_64bit_integers: 0,
826
+ ...tableName === TABLE_WORKFLOW_SNAPSHOT ? { use_skip_indexes_if_final: 1 } : {}
768
827
  }
769
828
  });
770
829
  if (!result) return null;
@@ -861,6 +920,9 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
861
920
  schema: TABLE_SCHEMAS[TABLE_MESSAGES],
862
921
  ifNotExists: ["resourceId"]
863
922
  });
923
+ await this.#db.ensureSkipIndexes(TABLE_THREADS);
924
+ await this.#db.ensureSkipIndexes(TABLE_MESSAGES);
925
+ await this.#db.ensureSkipIndexes(TABLE_RESOURCES);
864
926
  }
865
927
  async dangerouslyClearAll() {
866
928
  await this.#db.clearTable({ tableName: TABLE_MESSAGES });
@@ -1203,16 +1265,9 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1203
1265
  for (const message of messages) {
1204
1266
  if (!message.resourceId) throw new Error("Resource ID is required");
1205
1267
  if (!message.threadId) throw new Error("Thread ID is required");
1206
- if (!await this.getThreadById({ threadId: message.threadId })) throw new Error(`Thread ${message.threadId} not found`);
1207
- }
1208
- const threadIdSet = /* @__PURE__ */ new Map();
1209
- await Promise.all(messages.map(async (m) => {
1210
- if (!m.resourceId) throw new Error("Resource ID is required");
1211
- if (!m.threadId) throw new Error("Thread ID is required");
1212
- const thread = await this.getThreadById({ threadId: m.threadId });
1213
- if (!thread) throw new Error(`Thread ${m.threadId} not found`);
1214
- threadIdSet.set(m.threadId, thread);
1215
- }));
1268
+ }
1269
+ const threadIdSet = await this.getLatestThreads([...new Set(messages.map((m) => m.threadId))]);
1270
+ for (const message of messages) if (!threadIdSet.has(message.threadId)) throw new Error(`Thread ${message.threadId} not found`);
1216
1271
  try {
1217
1272
  const existingRows = await (await this.client.query({
1218
1273
  query: `SELECT id, thread_id FROM ${TABLE_MESSAGES} WHERE id IN ({ids:Array(String)})`,
@@ -1315,6 +1370,38 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1315
1370
  }, error);
1316
1371
  }
1317
1372
  }
1373
+ /** Newest version of each requested thread, keyed by id. Missing threads are absent. */
1374
+ async getLatestThreads(threadIds) {
1375
+ const rows = await (await this.client.query({
1376
+ query: `SELECT
1377
+ id,
1378
+ "resourceId",
1379
+ title,
1380
+ metadata,
1381
+ toDateTime64(createdAt, 3) as createdAt,
1382
+ toDateTime64(updatedAt, 3) as updatedAt
1383
+ FROM "${TABLE_THREADS}"
1384
+ WHERE id IN {threadIds:Array(String)}
1385
+ ORDER BY updatedAt DESC
1386
+ LIMIT 1 BY id`,
1387
+ query_params: { threadIds },
1388
+ clickhouse_settings: {
1389
+ date_time_input_format: "best_effort",
1390
+ date_time_output_format: "iso",
1391
+ use_client_time_zone: 1,
1392
+ output_format_json_quote_64bit_integers: 0
1393
+ }
1394
+ })).json();
1395
+ const threads = /* @__PURE__ */ new Map();
1396
+ for (const row of rows.data) {
1397
+ const thread = transformRow(row);
1398
+ threads.set(thread.id, {
1399
+ ...thread,
1400
+ metadata: parseMetadata(thread.metadata)
1401
+ });
1402
+ }
1403
+ return threads;
1404
+ }
1318
1405
  async getThreadById({ threadId, resourceId }) {
1319
1406
  try {
1320
1407
  const thread = transformRow((await (await this.client.query({
@@ -1489,19 +1576,27 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1489
1576
  metadataIndex++;
1490
1577
  }
1491
1578
  }
1579
+ const latestThreads = `
1580
+ latest_threads AS (
1581
+ SELECT
1582
+ id,
1583
+ resourceId,
1584
+ title,
1585
+ metadata,
1586
+ toDateTime64(createdAt, 3) as createdAt,
1587
+ toDateTime64(updatedAt, 3) as updatedAt
1588
+ FROM ${TABLE_THREADS}
1589
+ ${filter?.resourceId ? `WHERE id IN (SELECT id FROM ${TABLE_THREADS} WHERE resourceId = {resourceId:String})` : ""}
1590
+ ORDER BY updatedAt DESC
1591
+ LIMIT 1 BY id
1592
+ )`;
1593
+ const latestWhere = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
1492
1594
  const total = (await (await this.client.query({
1493
1595
  query: `
1494
- WITH ranked_threads AS (
1495
- SELECT
1496
- id,
1497
- resourceId,
1498
- metadata,
1499
- ROW_NUMBER() OVER (PARTITION BY id ORDER BY updatedAt DESC) as row_num
1500
- FROM ${TABLE_THREADS}
1501
- )
1502
- SELECT count(*) as total
1503
- FROM ranked_threads
1504
- WHERE row_num = 1 ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
1596
+ WITH ${latestThreads}
1597
+ SELECT count(*) as total
1598
+ FROM latest_threads
1599
+ ${latestWhere}
1505
1600
  `,
1506
1601
  query_params: queryParams,
1507
1602
  clickhouse_settings: {
@@ -1521,17 +1616,7 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1521
1616
  return {
1522
1617
  threads: transformRows((await (await this.client.query({
1523
1618
  query: `
1524
- WITH ranked_threads AS (
1525
- SELECT
1526
- id,
1527
- resourceId,
1528
- title,
1529
- metadata,
1530
- toDateTime64(createdAt, 3) as createdAt,
1531
- toDateTime64(updatedAt, 3) as updatedAt,
1532
- ROW_NUMBER() OVER (PARTITION BY id ORDER BY updatedAt DESC) as row_num
1533
- FROM ${TABLE_THREADS}
1534
- )
1619
+ WITH ${latestThreads}
1535
1620
  SELECT
1536
1621
  id,
1537
1622
  resourceId,
@@ -1539,8 +1624,8 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1539
1624
  metadata,
1540
1625
  createdAt,
1541
1626
  updatedAt
1542
- FROM ranked_threads
1543
- WHERE row_num = 1 ${whereClauses.length > 0 ? `AND ${whereClauses.join(" AND ")}` : ""}
1627
+ FROM latest_threads
1628
+ ${latestWhere}
1544
1629
  ORDER BY "${field}" ${direction === "DESC" ? "DESC" : "ASC"}
1545
1630
  LIMIT {perPage:Int64} OFFSET {offset:Int64}
1546
1631
  `,
@@ -1653,37 +1738,32 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1653
1738
  clickhouse_settings: {
1654
1739
  date_time_input_format: "best_effort",
1655
1740
  use_client_time_zone: 1,
1656
- output_format_json_quote_64bit_integers: 0
1741
+ output_format_json_quote_64bit_integers: 0,
1742
+ mutations_sync: "1"
1657
1743
  }
1658
1744
  }));
1659
1745
  }
1660
1746
  }
1661
1747
  if (updatePromises.length > 0) await Promise.all(updatePromises);
1662
- await this.client.command({
1663
- query: `OPTIMIZE TABLE ${TABLE_MESSAGES} FINAL`,
1748
+ const verifyResult = await this.client.query({
1749
+ query: `SELECT id, content, role, type, "createdAt", thread_id AS "threadId", "resourceId" FROM ${TABLE_MESSAGES} WHERE id IN {messageIds:Array(String)}`,
1750
+ query_params: { messageIds },
1664
1751
  clickhouse_settings: {
1665
1752
  date_time_input_format: "best_effort",
1753
+ date_time_output_format: "iso",
1666
1754
  use_client_time_zone: 1,
1667
1755
  output_format_json_quote_64bit_integers: 0
1668
1756
  }
1669
1757
  });
1758
+ const verifiedById = /* @__PURE__ */ new Map();
1759
+ for (const row of transformRows((await verifyResult.json()).data)) if (!verifiedById.has(row.id)) verifiedById.set(row.id, row);
1670
1760
  for (const existingMessage of parsedExistingMessages) {
1671
1761
  const updatePayload = messages.find((m) => m.id === existingMessage.id);
1672
1762
  if (!updatePayload) continue;
1673
1763
  const { id, ...fieldsToUpdate } = updatePayload;
1674
1764
  if (Object.keys(fieldsToUpdate).length === 0) continue;
1675
- const verifyRows = await (await this.client.query({
1676
- query: `SELECT id, content, role, type, "createdAt", thread_id AS "threadId", "resourceId" FROM ${TABLE_MESSAGES} WHERE id = {messageId:String}`,
1677
- query_params: { messageId: id },
1678
- clickhouse_settings: {
1679
- date_time_input_format: "best_effort",
1680
- date_time_output_format: "iso",
1681
- use_client_time_zone: 1,
1682
- output_format_json_quote_64bit_integers: 0
1683
- }
1684
- })).json();
1685
- if (verifyRows.data.length > 0) {
1686
- const updatedMessage = transformRows(verifyRows.data)[0];
1765
+ if (verifiedById.has(id)) {
1766
+ const updatedMessage = verifiedById.get(id);
1687
1767
  if (updatedMessage) {
1688
1768
  let needsRetry = false;
1689
1769
  for (const [key, value] of Object.entries(fieldsToUpdate)) if (key === "content") {
@@ -1750,68 +1830,42 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1750
1830
  }
1751
1831
  if (threadIdsToUpdate.size > 0) {
1752
1832
  await new Promise((resolve) => setTimeout(resolve, 10));
1753
- const now = (/* @__PURE__ */ new Date()).toISOString().replace("Z", "");
1754
- const threadUpdatePromises = Array.from(threadIdsToUpdate).map(async (threadId) => {
1755
- const threadRows = await (await this.client.query({
1756
- query: `SELECT id, resourceId, title, metadata, createdAt FROM ${TABLE_THREADS} WHERE id = {threadId:String} ORDER BY updatedAt DESC LIMIT 1`,
1757
- query_params: { threadId },
1758
- clickhouse_settings: {
1759
- date_time_input_format: "best_effort",
1760
- date_time_output_format: "iso",
1761
- use_client_time_zone: 1,
1762
- output_format_json_quote_64bit_integers: 0
1763
- }
1764
- })).json();
1765
- if (threadRows.data.length > 0) {
1766
- const existingThread = threadRows.data[0];
1767
- await this.client.command({
1768
- query: `DELETE FROM ${TABLE_THREADS} WHERE id = {threadId:String}`,
1769
- query_params: { threadId },
1770
- clickhouse_settings: {
1771
- date_time_input_format: "best_effort",
1772
- use_client_time_zone: 1,
1773
- output_format_json_quote_64bit_integers: 0
1774
- }
1775
- });
1776
- await this.client.insert({
1777
- table: TABLE_THREADS,
1778
- format: "JSONEachRow",
1779
- values: [{
1780
- id: existingThread.id,
1781
- resourceId: existingThread.resourceId,
1782
- title: existingThread.title,
1783
- metadata: typeof existingThread.metadata === "string" ? existingThread.metadata : serializeMetadata(existingThread.metadata),
1784
- createdAt: existingThread.createdAt,
1785
- updatedAt: now
1786
- }],
1787
- clickhouse_settings: {
1788
- date_time_input_format: "best_effort",
1789
- use_client_time_zone: 1,
1790
- output_format_json_quote_64bit_integers: 0
1791
- }
1792
- });
1793
- }
1794
- });
1795
- await Promise.all(threadUpdatePromises);
1796
- }
1797
- const updatedMessages = [];
1798
- for (const messageId of messageIds) {
1799
- const updatedRows = await (await this.client.query({
1800
- query: `SELECT id, content, role, type, "createdAt", thread_id AS "threadId", "resourceId" FROM ${TABLE_MESSAGES} WHERE id = {messageId:String}`,
1801
- query_params: { messageId },
1833
+ const now = Date.now();
1834
+ const existingThreads = await this.getLatestThreads(Array.from(threadIdsToUpdate));
1835
+ if (existingThreads.size > 0) await this.client.insert({
1836
+ table: TABLE_THREADS,
1837
+ format: "JSONEachRow",
1838
+ values: Array.from(existingThreads.values()).map((existingThread) => ({
1839
+ id: existingThread.id,
1840
+ resourceId: existingThread.resourceId,
1841
+ title: existingThread.title,
1842
+ metadata: serializeMetadata(existingThread.metadata),
1843
+ createdAt: existingThread.createdAt,
1844
+ updatedAt: new Date(Math.max(now, new Date(existingThread.updatedAt).getTime() + 1)).toISOString().replace("Z", "")
1845
+ })),
1802
1846
  clickhouse_settings: {
1803
1847
  date_time_input_format: "best_effort",
1804
- date_time_output_format: "iso",
1805
1848
  use_client_time_zone: 1,
1806
1849
  output_format_json_quote_64bit_integers: 0
1807
1850
  }
1808
- })).json();
1809
- if (updatedRows.data.length > 0) {
1810
- const message = transformRows(updatedRows.data)[0];
1811
- if (message) updatedMessages.push(message);
1812
- }
1851
+ });
1813
1852
  }
1814
- return updatedMessages.map((message) => {
1853
+ const updatedResult = await this.client.query({
1854
+ query: `SELECT id, content, role, type, "createdAt", thread_id AS "threadId", "resourceId" FROM ${TABLE_MESSAGES} WHERE id IN {messageIds:Array(String)}`,
1855
+ query_params: { messageIds },
1856
+ clickhouse_settings: {
1857
+ date_time_input_format: "best_effort",
1858
+ date_time_output_format: "iso",
1859
+ use_client_time_zone: 1,
1860
+ output_format_json_quote_64bit_integers: 0
1861
+ }
1862
+ });
1863
+ const updatedById = /* @__PURE__ */ new Map();
1864
+ for (const row of transformRows((await updatedResult.json()).data)) if (!updatedById.has(row.id)) updatedById.set(row.id, row);
1865
+ return messageIds.flatMap((messageId) => {
1866
+ const message = updatedById.get(messageId);
1867
+ return message ? [message] : [];
1868
+ }).map((message) => {
1815
1869
  if (typeof message.content === "string") try {
1816
1870
  message.content = JSON.parse(message.content);
1817
1871
  } catch {}
@@ -1907,35 +1961,9 @@ var MemoryStorageClickhouse = class extends MemoryStorage {
1907
1961
  ...existingResource.metadata,
1908
1962
  ...metadata
1909
1963
  },
1910
- updatedAt: /* @__PURE__ */ new Date()
1964
+ updatedAt: new Date(Math.max(Date.now(), existingResource.updatedAt.getTime() + 1))
1911
1965
  };
1912
- const updateQuery = `
1913
- ALTER TABLE ${TABLE_RESOURCES}
1914
- UPDATE workingMemory = {workingMemory:String}, metadata = {metadata:String}, updatedAt = {updatedAt:String}
1915
- WHERE id = {resourceId:String}
1916
- `;
1917
- await this.client.command({
1918
- query: updateQuery,
1919
- query_params: {
1920
- workingMemory: updatedResource.workingMemory,
1921
- metadata: JSON.stringify(updatedResource.metadata),
1922
- updatedAt: updatedResource.updatedAt.toISOString().replace("Z", ""),
1923
- resourceId
1924
- },
1925
- clickhouse_settings: {
1926
- date_time_input_format: "best_effort",
1927
- use_client_time_zone: 1,
1928
- output_format_json_quote_64bit_integers: 0
1929
- }
1930
- });
1931
- await this.client.command({
1932
- query: `OPTIMIZE TABLE ${TABLE_RESOURCES} FINAL`,
1933
- clickhouse_settings: {
1934
- date_time_input_format: "best_effort",
1935
- use_client_time_zone: 1,
1936
- output_format_json_quote_64bit_integers: 0
1937
- }
1938
- });
1966
+ await this.saveResource({ resource: updatedResource });
1939
1967
  return updatedResource;
1940
1968
  } catch (error) {
1941
1969
  throw new MastraError({
@@ -2383,14 +2411,10 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
2383
2411
  }
2384
2412
  if (filters.hasChildError !== void 0) {
2385
2413
  const finalClause = (TABLE_ENGINES[TABLE_SPANS] ?? "MergeTree()").startsWith("ReplacingMergeTree") ? "FINAL" : "";
2386
- if (filters.hasChildError) conditions.push(`EXISTS (
2387
- SELECT 1 FROM ${TABLE_SPANS} ${finalClause} c
2388
- WHERE c.traceId = ${TABLE_SPANS}.traceId AND c.error IS NOT NULL AND c.error != ''
2389
- )`);
2390
- else conditions.push(`NOT EXISTS (
2391
- SELECT 1 FROM ${TABLE_SPANS} ${finalClause} c
2392
- WHERE c.traceId = ${TABLE_SPANS}.traceId AND c.error IS NOT NULL AND c.error != ''
2393
- )`);
2414
+ conditions.push(`traceId ${filters.hasChildError ? "IN" : "NOT IN"} (
2415
+ SELECT c.traceId FROM ${TABLE_SPANS} ${finalClause} c
2416
+ WHERE c.error IS NOT NULL AND c.error != ''
2417
+ )`);
2394
2418
  }
2395
2419
  }
2396
2420
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -2482,35 +2506,58 @@ var ObservabilityStorageClickhouse = class extends ObservabilityStorage {
2482
2506
  async batchUpdateSpans(args) {
2483
2507
  try {
2484
2508
  const now = Date.now();
2509
+ if (args.records.length === 0) return;
2510
+ const keyParams = {};
2511
+ const keyTuples = args.records.map((record, i) => {
2512
+ keyParams[`t_${i}`] = record.traceId;
2513
+ keyParams[`s_${i}`] = record.spanId;
2514
+ return `({t_${i}:String}, {s_${i}:String})`;
2515
+ });
2516
+ const existingResult = await this.client.query({
2517
+ query: `SELECT *, toDateTime64(createdAt, 3) as createdAt, toDateTime64(updatedAt, 3) as updatedAt
2518
+ FROM ${TABLE_SPANS}
2519
+ WHERE (traceId, spanId) IN (${keyTuples.join(", ")})
2520
+ -- Newest version per span, which is what ReplacingMergeTree(updatedAt)
2521
+ -- keeps. FINAL would merge every part the batch's keys touch, which
2522
+ -- costs far more than reading the unmerged versions and sorting them.
2523
+ ORDER BY updatedAt DESC
2524
+ LIMIT 1 BY traceId, spanId`,
2525
+ query_params: keyParams,
2526
+ clickhouse_settings: {
2527
+ date_time_input_format: "best_effort",
2528
+ date_time_output_format: "iso",
2529
+ use_client_time_zone: 1,
2530
+ output_format_json_quote_64bit_integers: 0
2531
+ }
2532
+ });
2533
+ const spanKey = (traceId, spanId) => `${traceId}\u0000${spanId}`;
2534
+ const current = /* @__PURE__ */ new Map();
2535
+ for (const row of transformRows((await existingResult.json()).data)) current.set(spanKey(row.traceId, row.spanId), row);
2485
2536
  for (const record of args.records) {
2486
- const existing = await this.#db.load({
2487
- tableName: TABLE_SPANS,
2488
- keys: {
2489
- spanId: record.spanId,
2490
- traceId: record.traceId
2491
- }
2537
+ const key = spanKey(record.traceId, record.spanId);
2538
+ const existing = current.get(key);
2539
+ if (!existing) continue;
2540
+ const updates = { ...record.updates };
2541
+ if (updates.startedAt instanceof Date) updates.startedAt = updates.startedAt.getTime();
2542
+ if (updates.endedAt instanceof Date) updates.endedAt = updates.endedAt.getTime();
2543
+ current.set(key, {
2544
+ ...existing,
2545
+ ...updates,
2546
+ updatedAt: now,
2547
+ __updated: true
2492
2548
  });
2493
- if (existing) {
2494
- const updates = { ...record.updates };
2495
- if (updates.startedAt instanceof Date) updates.startedAt = updates.startedAt.getTime();
2496
- if (updates.endedAt instanceof Date) updates.endedAt = updates.endedAt.getTime();
2497
- const updated = {
2498
- ...existing,
2499
- ...updates,
2500
- updatedAt: now
2501
- };
2502
- await this.client.insert({
2503
- table: TABLE_SPANS,
2504
- values: [updated],
2505
- format: "JSONEachRow",
2506
- clickhouse_settings: {
2507
- date_time_input_format: "best_effort",
2508
- use_client_time_zone: 1,
2509
- output_format_json_quote_64bit_integers: 0
2510
- }
2511
- });
2512
- }
2513
2549
  }
2550
+ const updatedRows = Array.from(current.values()).filter((row) => row.__updated).map(({ __updated: _updated, ...row }) => row);
2551
+ if (updatedRows.length > 0) await this.client.insert({
2552
+ table: TABLE_SPANS,
2553
+ values: updatedRows,
2554
+ format: "JSONEachRow",
2555
+ clickhouse_settings: {
2556
+ date_time_input_format: "best_effort",
2557
+ use_client_time_zone: 1,
2558
+ output_format_json_quote_64bit_integers: 0
2559
+ }
2560
+ });
2514
2561
  } catch (error) {
2515
2562
  throw new MastraError({
2516
2563
  id: createStorageErrorId("CLICKHOUSE", "BATCH_UPDATE_SPANS", "FAILED"),
@@ -2739,7 +2786,12 @@ CREATE TABLE IF NOT EXISTS ${TABLE_TRACE_ROOTS} (
2739
2786
  output Nullable(String),
2740
2787
  error Nullable(String),
2741
2788
  metadataRaw Nullable(String),
2742
- requestContext Nullable(String)
2789
+ requestContext Nullable(String),
2790
+
2791
+ -- traceId is second in the sort key, behind startedAt, so point lookups by
2792
+ -- traceId (getRootSpan, trace deletes, trace-query root dedupe) need a skip
2793
+ -- index to avoid scanning every granule.
2794
+ INDEX idx_traceId traceId TYPE bloom_filter(0.01) GRANULARITY 1
2743
2795
  )
2744
2796
  ENGINE = ReplacingMergeTree
2745
2797
  PARTITION BY toDate(endedAt)
@@ -2813,7 +2865,9 @@ CREATE TABLE IF NOT EXISTS ${TABLE_TRACE_BRANCHES} (
2813
2865
  output Nullable(String),
2814
2866
  error Nullable(String),
2815
2867
  metadataRaw Nullable(String),
2816
- requestContext Nullable(String)
2868
+ requestContext Nullable(String),
2869
+
2870
+ INDEX idx_traceId traceId TYPE bloom_filter(0.01) GRANULARITY 1
2817
2871
  )
2818
2872
  ENGINE = ReplacingMergeTree
2819
2873
  PARTITION BY toDate(endedAt)
@@ -3042,7 +3096,19 @@ CREATE TABLE IF NOT EXISTS ${TABLE_LOG_EVENTS} (
3042
3096
  -- Information-only JSON payloads
3043
3097
  data Nullable(String),
3044
3098
  metadata Nullable(String),
3045
- scope Nullable(String)
3099
+ scope Nullable(String),
3100
+
3101
+ -- Same ID drilldown indexes as metric_events: these columns are outside
3102
+ -- the sort key, so without them every drilldown scans the time range.
3103
+ INDEX idx_traceId traceId TYPE bloom_filter(0.01) GRANULARITY 2,
3104
+ INDEX idx_threadId threadId TYPE bloom_filter(0.01) GRANULARITY 2,
3105
+ INDEX idx_resourceId resourceId TYPE bloom_filter(0.01) GRANULARITY 2,
3106
+ INDEX idx_userId userId TYPE bloom_filter(0.01) GRANULARITY 2,
3107
+ INDEX idx_organizationId organizationId TYPE bloom_filter(0.01) GRANULARITY 2,
3108
+ INDEX idx_experimentId experimentId TYPE bloom_filter(0.01) GRANULARITY 2,
3109
+ INDEX idx_runId runId TYPE bloom_filter(0.01) GRANULARITY 2,
3110
+ INDEX idx_sessionId sessionId TYPE bloom_filter(0.01) GRANULARITY 2,
3111
+ INDEX idx_requestId requestId TYPE bloom_filter(0.01) GRANULARITY 2
3046
3112
  )
3047
3113
  ENGINE = ReplacingMergeTree
3048
3114
  PARTITION BY toDate(timestamp)
@@ -3171,7 +3237,8 @@ SETTINGS allow_nullable_key = 1
3171
3237
  `;
3172
3238
  const SCORE_EVENTS_CURRENT_DDL = `
3173
3239
  CREATE TABLE IF NOT EXISTS ${TABLE_SCORE_EVENTS_CURRENT} (
3174
- ${SCORE_EVENT_COLUMNS_DDL}
3240
+ ${SCORE_EVENT_COLUMNS_DDL},
3241
+ INDEX idx_traceId traceId TYPE bloom_filter(0.01) GRANULARITY 1
3175
3242
  )
3176
3243
  ENGINE = ReplacingMergeTree(writeVersion)
3177
3244
  PARTITION BY cityHash64(scoreId) % 64
@@ -3299,7 +3366,11 @@ CREATE TABLE IF NOT EXISTS ${TABLE_FEEDBACK_EVENTS} (
3299
3366
 
3300
3367
  -- Information-only JSON payloads
3301
3368
  metadata Nullable(String),
3302
- scope Nullable(String)
3369
+ scope Nullable(String),
3370
+
3371
+ -- feedbackId is last in the sort key; id lookups (write versions, review
3372
+ -- updates, deletes, trace-query dedupe) need a skip index.
3373
+ INDEX idx_feedbackId feedbackId TYPE bloom_filter(0.01) GRANULARITY 1
3303
3374
  )
3304
3375
  ENGINE = ReplacingMergeTree
3305
3376
  PARTITION BY toDate(timestamp)
@@ -3545,11 +3616,15 @@ const ALL_MIGRATIONS = [
3545
3616
  addColumn(TABLE_SCORE_EVENTS, "rootEntityVersionId", "Nullable(String)"),
3546
3617
  addBloomIndex(TABLE_SCORE_EVENTS, "idx_scoreId", "scoreId", 1),
3547
3618
  addBloomIndex(TABLE_SCORE_EVENTS_DELTA, "idx_scoreId", "scoreId", 1),
3619
+ addBloomIndex(TABLE_SCORE_EVENTS_CURRENT, "idx_traceId", "traceId", 1),
3548
3620
  addColumn(TABLE_FEEDBACK_EVENTS, "writeVersion", "UInt64 DEFAULT 0"),
3549
3621
  addColumn(TABLE_FEEDBACK_EVENTS, "entityVersionId", "Nullable(String)"),
3550
3622
  addColumn(TABLE_FEEDBACK_EVENTS, "reviewStatus", "LowCardinality(String) DEFAULT 'needs-review'"),
3551
3623
  addColumn(TABLE_FEEDBACK_EVENTS, "parentEntityVersionId", "Nullable(String)"),
3552
3624
  addColumn(TABLE_FEEDBACK_EVENTS, "rootEntityVersionId", "Nullable(String)"),
3625
+ addBloomIndex(TABLE_FEEDBACK_EVENTS, "idx_feedbackId", "feedbackId", 1),
3626
+ addBloomIndex(TABLE_TRACE_ROOTS, "idx_traceId", "traceId", 1),
3627
+ addBloomIndex(TABLE_TRACE_BRANCHES, "idx_traceId", "traceId", 1),
3553
3628
  addBloomIndex(TABLE_METRIC_EVENTS, "idx_traceId", "traceId"),
3554
3629
  addBloomIndex(TABLE_METRIC_EVENTS, "idx_threadId", "threadId"),
3555
3630
  addBloomIndex(TABLE_METRIC_EVENTS, "idx_resourceId", "resourceId"),
@@ -3559,6 +3634,17 @@ const ALL_MIGRATIONS = [
3559
3634
  addBloomIndex(TABLE_METRIC_EVENTS, "idx_runId", "runId"),
3560
3635
  addBloomIndex(TABLE_METRIC_EVENTS, "idx_sessionId", "sessionId"),
3561
3636
  addBloomIndex(TABLE_METRIC_EVENTS, "idx_requestId", "requestId"),
3637
+ ...[
3638
+ "traceId",
3639
+ "threadId",
3640
+ "resourceId",
3641
+ "userId",
3642
+ "organizationId",
3643
+ "experimentId",
3644
+ "runId",
3645
+ "sessionId",
3646
+ "requestId"
3647
+ ].map((column) => addBloomIndex(TABLE_LOG_EVENTS, `idx_${column}`, column)),
3562
3648
  addBloomIndex(TABLE_DELETION_REQUESTS, "idx_predicateValues", "predicateValues")
3563
3649
  ];
3564
3650
  const ALL_TABLE_NAMES = [
@@ -4654,6 +4740,11 @@ function validateCursorId(cursor) {
4654
4740
  text: "Invalid observability delta cursor"
4655
4741
  });
4656
4742
  }
4743
+ /** ANDs `conditions` onto a `WHERE ...` clause, or starts one when `whereClause` is empty. */
4744
+ function appendWhere(whereClause, ...conditions) {
4745
+ const parts = [whereClause.replace(/^\s*WHERE\s+/i, ""), ...conditions].filter(Boolean);
4746
+ return parts.length > 0 ? `WHERE ${parts.join(" AND ")}` : "";
4747
+ }
4657
4748
  //#endregion
4658
4749
  //#region src/storage/domains/observability/v-next/feedback.ts
4659
4750
  const FEEDBACK_TYPED_COLUMNS = /* @__PURE__ */ new Set([
@@ -4760,12 +4851,21 @@ function resolveFeedbackGroupBy(groupBy) {
4760
4851
  function toSeriesName$1(values) {
4761
4852
  return values.map((v) => v == null ? "" : String(v)).join("|");
4762
4853
  }
4763
- async function queryJson$2(client, query, params) {
4854
+ /**
4855
+ * Lets `FINAL` reads use skip indexes on older servers, where it defaults off.
4856
+ * Only for lookups on a column shared by every version of a sort key (so a
4857
+ * skipped granule can never hold a newer version of a matching row).
4858
+ */
4859
+ const FINAL_SKIP_INDEX_SETTINGS = { use_skip_indexes_if_final: 1 };
4860
+ async function queryJson$2(client, query, params, settings = {}) {
4764
4861
  return await (await client.query({
4765
4862
  query,
4766
4863
  query_params: params,
4767
4864
  format: "JSONEachRow",
4768
- clickhouse_settings: CH_SETTINGS
4865
+ clickhouse_settings: {
4866
+ ...CH_SETTINGS,
4867
+ ...settings
4868
+ }
4769
4869
  })).json();
4770
4870
  }
4771
4871
  async function feedbackRowsWithWriteVersions(client, feedbacks) {
@@ -4874,7 +4974,7 @@ async function hasFeedbackDeletionRequest(client, feedbackId, organizationId, re
4874
4974
  feedbackId,
4875
4975
  organizationId: organizationId ?? "",
4876
4976
  resourceId: resourceId ?? ""
4877
- })).length > 0;
4977
+ }, FINAL_SKIP_INDEX_SETTINGS)).length > 0;
4878
4978
  }
4879
4979
  /**
4880
4980
  * Review updates insert a replacement row, so they need only `INSERT` and the
@@ -4895,7 +4995,7 @@ async function updateFeedbackReviewStatus(client, args, replication) {
4895
4995
  const existingRow = (await queryJson$2(client, `SELECT * FROM ${TABLE_FEEDBACK_EVENTS} FINAL
4896
4996
  WHERE feedbackId = {feedbackId:String}
4897
4997
  ORDER BY writeVersion DESC, timestamp DESC
4898
- LIMIT 1`, { feedbackId }))[0];
4998
+ LIMIT 1`, { feedbackId }, FINAL_SKIP_INDEX_SETTINGS))[0];
4899
4999
  if (!existingRow) throw feedbackNotFoundError(feedbackId);
4900
5000
  const { organizationId, resourceId } = existingRow;
4901
5001
  const redelete = async () => {
@@ -4972,12 +5072,16 @@ async function queryFeedbackAfterCursor(client, whereClause, params, limit, curs
4972
5072
  f.timestamp AS timestamp,
4973
5073
  f.feedbackId AS feedbackId,
4974
5074
  toString(d.cursorId) AS cursorId
4975
- FROM ${TABLE_FEEDBACK_EVENTS_DELTA} d
4976
- INNER JOIN ${TABLE_FEEDBACK_EVENTS} f FINAL
4977
- ON ((f.traceId = d.traceId) OR (f.traceId IS NULL AND d.traceId IS NULL))
5075
+ FROM ${TABLE_FEEDBACK_EVENTS} f FINAL
5076
+ INNER JOIN (
5077
+ SELECT cursorId, traceId, timestamp, feedbackId
5078
+ FROM ${TABLE_FEEDBACK_EVENTS_DELTA}
5079
+ WHERE cursorId > {afterCursor:UInt64}
5080
+ ) d
5081
+ ON isNotDistinctFrom(f.traceId, d.traceId)
4978
5082
  AND f.timestamp = d.timestamp
4979
5083
  AND f.feedbackId = d.feedbackId
4980
- ${whereClause ? `${whereClause} AND d.cursorId > {afterCursor:UInt64}` : "WHERE d.cursorId > {afterCursor:UInt64}"}
5084
+ ${appendWhere(whereClause, `f.timestamp >= (SELECT min(timestamp) FROM ${TABLE_FEEDBACK_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64})`, `(f.timestamp, f.feedbackId) IN (${`SELECT timestamp, feedbackId FROM ${TABLE_FEEDBACK_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64}`})`)}
4981
5085
  ORDER BY d.cursorId ASC
4982
5086
  LIMIT {fetchLimit:UInt32}
4983
5087
  `, {
@@ -4986,18 +5090,24 @@ async function queryFeedbackAfterCursor(client, whereClause, params, limit, curs
4986
5090
  fetchLimit: limit + 1
4987
5091
  });
4988
5092
  }
5093
+ /**
5094
+ * Newest delta cursor whose feedback matches the filters. Without filters
5095
+ * this is the stream head; with filters, the feedback scan is bounded below by
5096
+ * the oldest `timestamp` still in the delta table.
5097
+ */
4989
5098
  async function getDeltaCursor$5(client, whereClause, params) {
5099
+ if (!whereClause) return getStreamHeadCursor$5(client);
4990
5100
  const cursorId = (await queryJson$2(client, `
4991
5101
  SELECT toString(max(d.cursorId)) AS cursorId
4992
5102
  FROM mastra_feedback_events_delta d
4993
- INNER JOIN mastra_feedback_events f FINAL
4994
- ON ((f.traceId = d.traceId) OR (f.traceId IS NULL AND d.traceId IS NULL))
4995
- AND f.timestamp = d.timestamp
4996
- AND f.feedbackId = d.feedbackId
4997
- ${whereClause}
5103
+ WHERE (d.timestamp, d.feedbackId) IN (
5104
+ SELECT f.timestamp, f.feedbackId
5105
+ FROM mastra_feedback_events f FINAL
5106
+ ${appendWhere(whereClause, `f.timestamp >= (SELECT min(timestamp) FROM mastra_feedback_events_delta)`)}
5107
+ )
4998
5108
  `, params))[0]?.cursorId ?? null;
4999
5109
  if (cursorId) return cursorId;
5000
- return (await queryJson$2(client, `SELECT toString(max(cursorId)) AS cursorId FROM mastra_feedback_events_delta`, {}))[0]?.cursorId ?? "0";
5110
+ return getStreamHeadCursor$5(client);
5001
5111
  }
5002
5112
  async function getStreamHeadCursor$5(client) {
5003
5113
  return (await queryJson$2(client, `SELECT toString(max(cursorId)) AS cursorId FROM mastra_feedback_events_delta`, {}))[0]?.cursorId ?? "0";
@@ -5119,26 +5229,22 @@ async function getFeedbackPercentiles(client, args) {
5119
5229
  const combined = mergeFilters$2(buildFeedbackIdentityFilter(args), buildFeedbackFilterConditions(args.filters));
5120
5230
  const whereClause = toWhereClause$2(combined);
5121
5231
  if (!Array.isArray(args.percentiles) || args.percentiles.length === 0) throw new Error("Percentiles must include at least one value between 0 and 1.");
5122
- const series = [];
5123
- for (const p of args.percentiles) {
5124
- if (!Number.isFinite(p) || p < 0 || p > 1) throw new Error(`Percentile value must be a finite number between 0 and 1, got ${p}`);
5125
- const rows = await queryJson$2(client, `
5126
- SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
5127
- quantile(${p})(valueNumber) AS pvalue
5128
- FROM ${TABLE_FEEDBACK_EVENTS} FINAL
5129
- ${whereClause}
5130
- GROUP BY bucket
5131
- ORDER BY bucket
5132
- `, combined.params);
5133
- series.push({
5134
- percentile: p,
5135
- points: rows.map((row) => ({
5136
- timestamp: row.bucket instanceof Date ? row.bucket : new Date(String(row.bucket)),
5137
- value: Number(row.pvalue ?? 0)
5138
- }))
5139
- });
5140
- }
5141
- return { series };
5232
+ for (const p of args.percentiles) if (!Number.isFinite(p) || p < 0 || p > 1) throw new Error(`Percentile value must be a finite number between 0 and 1, got ${p}`);
5233
+ const rows = await queryJson$2(client, `
5234
+ SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
5235
+ quantiles(${args.percentiles.join(", ")})(valueNumber) AS pvalues
5236
+ FROM ${TABLE_FEEDBACK_EVENTS} FINAL
5237
+ ${whereClause}
5238
+ GROUP BY bucket
5239
+ ORDER BY bucket
5240
+ `, combined.params);
5241
+ return { series: args.percentiles.map((percentile, index) => ({
5242
+ percentile,
5243
+ points: rows.map((row) => ({
5244
+ timestamp: row.bucket instanceof Date ? row.bucket : new Date(String(row.bucket)),
5245
+ value: Number(row.pvalues?.[index] ?? 0)
5246
+ }))
5247
+ })) };
5142
5248
  }
5143
5249
  //#endregion
5144
5250
  //#region src/storage/domains/observability/v-next/logs.ts
@@ -5217,6 +5323,7 @@ async function listLogs(client, args, strategy) {
5217
5323
  };
5218
5324
  }
5219
5325
  async function queryLogsAfterCursor(client, whereClause, params, limit, cursorId) {
5326
+ const deltaKeys = `SELECT timestamp, logId FROM ${TABLE_LOG_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64}`;
5220
5327
  return await (await client.query({
5221
5328
  query: `
5222
5329
  SELECT
@@ -5224,11 +5331,15 @@ async function queryLogsAfterCursor(client, whereClause, params, limit, cursorId
5224
5331
  l.timestamp AS timestamp,
5225
5332
  l.logId AS logId,
5226
5333
  toString(d.cursorId) AS cursorId
5227
- FROM ${TABLE_LOG_EVENTS_DELTA} d
5228
- INNER JOIN ${TABLE_LOG_EVENTS} l
5334
+ FROM ${TABLE_LOG_EVENTS} l
5335
+ INNER JOIN (
5336
+ SELECT cursorId, timestamp, logId
5337
+ FROM ${TABLE_LOG_EVENTS_DELTA}
5338
+ WHERE cursorId > {afterCursor:UInt64}
5339
+ ) d
5229
5340
  ON l.timestamp = d.timestamp
5230
5341
  AND l.logId = d.logId
5231
- ${whereClause ? `${whereClause} AND d.cursorId > {afterCursor:UInt64}` : "WHERE d.cursorId > {afterCursor:UInt64}"}
5342
+ ${appendWhere(whereClause, `(l.timestamp, l.logId) IN (${deltaKeys})`)}
5232
5343
  ORDER BY d.cursorId ASC
5233
5344
  LIMIT {fetchLimit:UInt32}
5234
5345
  `,
@@ -5241,26 +5352,29 @@ async function queryLogsAfterCursor(client, whereClause, params, limit, cursorId
5241
5352
  clickhouse_settings: CH_SETTINGS
5242
5353
  })).json();
5243
5354
  }
5355
+ /**
5356
+ * Newest delta cursor whose log matches the filters. Without filters this is
5357
+ * the stream head; with filters, the log scan is bounded below by the oldest
5358
+ * `timestamp` still in the delta table.
5359
+ */
5244
5360
  async function getDeltaCursor$4(client, whereClause, params) {
5361
+ if (!whereClause) return getStreamHeadCursor$4(client);
5245
5362
  const cursorId = (await (await client.query({
5246
5363
  query: `
5247
5364
  SELECT toString(max(d.cursorId)) AS cursorId
5248
5365
  FROM mastra_log_events_delta d
5249
- INNER JOIN mastra_log_events l
5250
- ON l.timestamp = d.timestamp
5251
- AND l.logId = d.logId
5252
- ${whereClause}
5366
+ WHERE (d.timestamp, d.logId) IN (
5367
+ SELECT l.timestamp, l.logId
5368
+ FROM mastra_log_events l
5369
+ ${appendWhere(whereClause, `l.timestamp >= (SELECT min(timestamp) FROM mastra_log_events_delta)`)}
5370
+ )
5253
5371
  `,
5254
5372
  query_params: params,
5255
5373
  format: "JSONEachRow",
5256
5374
  clickhouse_settings: CH_SETTINGS
5257
5375
  })).json())[0]?.cursorId ?? null;
5258
5376
  if (cursorId) return cursorId;
5259
- return (await (await client.query({
5260
- query: `SELECT toString(max(cursorId)) AS cursorId FROM mastra_log_events_delta`,
5261
- format: "JSONEachRow",
5262
- clickhouse_settings: CH_SETTINGS
5263
- })).json())[0]?.cursorId ?? "0";
5377
+ return getStreamHeadCursor$4(client);
5264
5378
  }
5265
5379
  async function getStreamHeadCursor$4(client) {
5266
5380
  return (await (await client.query({
@@ -5486,12 +5600,16 @@ async function queryMetricsAfterCursor(client, whereClause, params, limit, curso
5486
5600
  m.timestamp AS timestamp,
5487
5601
  m.metricId AS metricId,
5488
5602
  toString(d.cursorId) AS cursorId
5489
- FROM ${TABLE_METRIC_EVENTS_DELTA} d
5490
- INNER JOIN ${TABLE_METRIC_EVENTS} m
5603
+ FROM ${TABLE_METRIC_EVENTS} m
5604
+ INNER JOIN (
5605
+ SELECT cursorId, name, timestamp, metricId
5606
+ FROM ${TABLE_METRIC_EVENTS_DELTA}
5607
+ WHERE cursorId > {afterCursor:UInt64}
5608
+ ) d
5491
5609
  ON m.name = d.name
5492
5610
  AND m.timestamp = d.timestamp
5493
5611
  AND m.metricId = d.metricId
5494
- ${whereClause ? `${whereClause} AND d.cursorId > {afterCursor:UInt64}` : "WHERE d.cursorId > {afterCursor:UInt64}"}
5612
+ ${appendWhere(whereClause, `(m.name, m.timestamp, m.metricId) IN (${`SELECT name, timestamp, metricId FROM ${TABLE_METRIC_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64}`})`)}
5495
5613
  ORDER BY d.cursorId ASC
5496
5614
  LIMIT {fetchLimit:UInt32}
5497
5615
  `, {
@@ -5500,18 +5618,24 @@ async function queryMetricsAfterCursor(client, whereClause, params, limit, curso
5500
5618
  fetchLimit: limit + 1
5501
5619
  });
5502
5620
  }
5621
+ /**
5622
+ * Newest delta cursor whose metric matches the filters. Without filters this
5623
+ * is the stream head; with filters, the metric scan is bounded below by the
5624
+ * oldest `timestamp` still in the delta table.
5625
+ */
5503
5626
  async function getDeltaCursor$3(client, whereClause, params) {
5627
+ if (!whereClause) return getStreamHeadCursor$3(client);
5504
5628
  const cursorId = (await queryJson$1(client, `
5505
5629
  SELECT toString(max(d.cursorId)) AS cursorId
5506
5630
  FROM mastra_metric_events_delta d
5507
- INNER JOIN mastra_metric_events m
5508
- ON m.name = d.name
5509
- AND m.timestamp = d.timestamp
5510
- AND m.metricId = d.metricId
5511
- ${whereClause}
5631
+ WHERE (d.name, d.timestamp, d.metricId) IN (
5632
+ SELECT m.name, m.timestamp, m.metricId
5633
+ FROM mastra_metric_events m
5634
+ ${appendWhere(whereClause, `m.timestamp >= (SELECT min(timestamp) FROM mastra_metric_events_delta)`)}
5635
+ )
5512
5636
  `, params))[0]?.cursorId ?? null;
5513
5637
  if (cursorId) return cursorId;
5514
- return (await queryJson$1(client, `SELECT toString(max(cursorId)) AS cursorId FROM mastra_metric_events_delta`, {}))[0]?.cursorId ?? "0";
5638
+ return getStreamHeadCursor$3(client);
5515
5639
  }
5516
5640
  async function getStreamHeadCursor$3(client) {
5517
5641
  return (await queryJson$1(client, `SELECT toString(max(cursorId)) AS cursorId FROM mastra_metric_events_delta`, {}))[0]?.cursorId ?? "0";
@@ -5710,29 +5834,26 @@ async function getMetricPercentiles(client, args) {
5710
5834
  params: { percentileName: args.name }
5711
5835
  }, signalFilter);
5712
5836
  const whereClause = toWhereClause$1(combined);
5713
- const series = [];
5714
- for (const p of args.percentiles) {
5715
- if (p < 0 || p > 1) throw new Error(`Percentile value must be between 0 and 1, got ${p}`);
5716
- const rows = await queryJson$1(client, `
5717
- SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
5718
- quantile(${p})(value) AS pvalue
5719
- FROM ${TABLE_METRIC_EVENTS}
5720
- ${whereClause}
5721
- GROUP BY bucket
5722
- ORDER BY bucket
5723
- `, combined.params);
5724
- series.push({
5725
- percentile: p,
5726
- points: rows.map((row) => ({
5727
- timestamp: row.bucket instanceof Date ? row.bucket : new Date(String(row.bucket)),
5728
- value: Number(row.pvalue ?? 0)
5729
- }))
5730
- });
5731
- }
5732
- return { series };
5837
+ for (const p of args.percentiles) if (!Number.isFinite(p) || p < 0 || p > 1) throw new Error(`Percentile value must be a finite number between 0 and 1, got ${p}`);
5838
+ if (args.percentiles.length === 0) return { series: [] };
5839
+ const rows = await queryJson$1(client, `
5840
+ SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
5841
+ quantiles(${args.percentiles.join(", ")})(value) AS pvalues
5842
+ FROM ${TABLE_METRIC_EVENTS}
5843
+ ${whereClause}
5844
+ GROUP BY bucket
5845
+ ORDER BY bucket
5846
+ `, combined.params);
5847
+ return { series: args.percentiles.map((percentile, index) => ({
5848
+ percentile,
5849
+ points: rows.map((row) => ({
5850
+ timestamp: row.bucket instanceof Date ? row.bucket : new Date(String(row.bucket)),
5851
+ value: Number(row.pvalues?.[index] ?? 0)
5852
+ }))
5853
+ })) };
5733
5854
  }
5734
5855
  async function getMetricNames(client, args) {
5735
- const conditions = [`kind = 'metricName'`];
5856
+ const conditions = [`kind = 'metricName'`, `key1 = ''`];
5736
5857
  const params = {};
5737
5858
  if (args.prefix) {
5738
5859
  conditions.push(`value LIKE {namePrefix:String}`);
@@ -6193,6 +6314,22 @@ function currentScoresRelation(sourcePredicate) {
6193
6314
  ${sourcePredicate ? `WHERE ${sourcePredicate}` : ""}
6194
6315
  )`;
6195
6316
  }
6317
+ /**
6318
+ * Current-state scores narrowed before FINAL merges. `score_events_current` is
6319
+ * keyed by scoreId, so a traceId filter only applies after FINAL has merged
6320
+ * the whole table. When the (unaliased) filter pins traceId, the matching
6321
+ * scoreIds are first read from the history table, whose sort key starts with
6322
+ * traceId. Every current row was written to history, so the candidate set is a
6323
+ * superset; callers still apply the same filter to the current version.
6324
+ *
6325
+ * Time ranges alone are not narrowed this way: scoreIds are hash-distributed,
6326
+ * so the ids from any real time window touch nearly every granule of the
6327
+ * current table and the extra history scan costs more than it saves.
6328
+ */
6329
+ function prunedCurrentScoresRelation(historyFilter) {
6330
+ if (!historyFilter.conditions.some((condition) => condition.startsWith("traceId "))) return currentScoresRelation();
6331
+ return currentScoresRelation(`scoreId IN (SELECT scoreId FROM ${TABLE_SCORE_EVENTS} WHERE ${historyFilter.conditions.join(" AND ")})`);
6332
+ }
6196
6333
  function buildScoreIdentityFilter(args) {
6197
6334
  const conditions = ["scorerId = {olapScorerId:String}"];
6198
6335
  const params = { olapScorerId: args.scorerId };
@@ -6337,7 +6474,7 @@ async function listScores(client, args, strategy) {
6337
6474
  };
6338
6475
  }
6339
6476
  const currentDeltaCursor = deltaCursorEnabled ? await getDeltaCursor$2(client, whereClause, filter.params) : void 0;
6340
- const scoresRelation = currentScoresRelation();
6477
+ const scoresRelation = prunedCurrentScoresRelation(buildScoresFilterConditions(parsed.filters));
6341
6478
  const countResult = await queryJson(client, `SELECT count() AS total FROM ${scoresRelation} AS s ${whereClause}`, filter.params);
6342
6479
  const rows = await queryJson(client, `SELECT * FROM ${scoresRelation} AS s ${whereClause} ORDER BY ${orderBy} LIMIT {limit:UInt32} OFFSET {offset:UInt32}`, {
6343
6480
  ...filter.params,
@@ -6375,15 +6512,19 @@ async function queryScoresAfterCursor(client, whereClause, params, limit, cursor
6375
6512
  s.timestamp AS timestamp,
6376
6513
  s.scoreId AS scoreId,
6377
6514
  toString(d.cursorId) AS cursorId
6378
- FROM ${TABLE_SCORE_EVENTS_DELTA} d
6379
- INNER JOIN ${TABLE_SCORE_EVENTS_CURRENT} s FINAL
6515
+ FROM ${TABLE_SCORE_EVENTS_CURRENT} s FINAL
6516
+ INNER JOIN (
6517
+ SELECT cursorId, scoreId
6518
+ FROM ${TABLE_SCORE_EVENTS_DELTA}
6519
+ WHERE cursorId > {afterCursor:UInt64}
6520
+ AND scoreId NOT IN (
6521
+ SELECT scoreId FROM ${TABLE_SCORE_EVENTS_DELTA}
6522
+ WHERE cursorId <= {afterCursor:UInt64}
6523
+ AND scoreId IN (SELECT scoreId FROM ${TABLE_SCORE_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64})
6524
+ )
6525
+ ) d
6380
6526
  ON s.scoreId = d.scoreId
6381
- ${whereClause ? `${whereClause} AND` : "WHERE"} d.cursorId > {afterCursor:UInt64}
6382
- AND d.scoreId NOT IN (
6383
- SELECT scoreId FROM ${TABLE_SCORE_EVENTS_DELTA}
6384
- WHERE cursorId <= {afterCursor:UInt64}
6385
- AND scoreId IN (SELECT scoreId FROM ${TABLE_SCORE_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64})
6386
- )
6527
+ ${appendWhere(whereClause, `s.scoreId IN (SELECT scoreId FROM ${TABLE_SCORE_EVENTS_DELTA} WHERE cursorId > {afterCursor:UInt64})`)}
6387
6528
  ORDER BY d.cursorId ASC
6388
6529
  LIMIT 1 BY s.scoreId
6389
6530
  LIMIT {fetchLimit:UInt32}
@@ -6393,16 +6534,24 @@ async function queryScoresAfterCursor(client, whereClause, params, limit, cursor
6393
6534
  fetchLimit: limit + 1
6394
6535
  });
6395
6536
  }
6537
+ /**
6538
+ * Newest delta cursor whose current score matches the filters. Without
6539
+ * filters this is the stream head; with filters, FINAL only merges the
6540
+ * scoreIds still in the delta table.
6541
+ */
6396
6542
  async function getDeltaCursor$2(client, whereClause, params) {
6543
+ if (!whereClause) return getStreamHeadCursor$2(client);
6397
6544
  const cursorId = (await queryJson(client, `
6398
6545
  SELECT toString(max(d.cursorId)) AS cursorId
6399
6546
  FROM mastra_score_events_delta d
6400
- INNER JOIN mastra_score_events_current s FINAL
6401
- ON s.scoreId = d.scoreId
6402
- ${whereClause}
6547
+ WHERE d.scoreId IN (
6548
+ SELECT s.scoreId
6549
+ FROM mastra_score_events_current s FINAL
6550
+ ${appendWhere(whereClause, `s.scoreId IN (SELECT scoreId FROM mastra_score_events_delta)`)}
6551
+ )
6403
6552
  `, params))[0]?.cursorId ?? null;
6404
6553
  if (cursorId) return cursorId;
6405
- return (await queryJson(client, `SELECT toString(max(cursorId)) AS cursorId FROM mastra_score_events_delta`, {}))[0]?.cursorId ?? "0";
6554
+ return getStreamHeadCursor$2(client);
6406
6555
  }
6407
6556
  async function getStreamHeadCursor$2(client) {
6408
6557
  return (await queryJson(client, `SELECT toString(max(cursorId)) AS cursorId FROM mastra_score_events_delta`, {}))[0]?.cursorId ?? "0";
@@ -6419,7 +6568,7 @@ async function getScoreAggregate(client, args) {
6419
6568
  const identity = buildScoreIdentityFilter(args);
6420
6569
  const combined = mergeFilters(identity, buildScoresFilterConditions(args.filters));
6421
6570
  const whereClause = toWhereClause(combined);
6422
- const result = await queryJson(client, `SELECT ${aggSql} AS value FROM ${currentScoresRelation()} ${whereClause}`, combined.params);
6571
+ const result = await queryJson(client, `SELECT ${aggSql} AS value FROM ${prunedCurrentScoresRelation(combined)} ${whereClause}`, combined.params);
6423
6572
  const value = result[0]?.value == null ? null : Number(result[0]?.value);
6424
6573
  if (args.comparePeriod && args.filters?.timestamp) {
6425
6574
  const ts = args.filters.timestamp;
@@ -6454,7 +6603,7 @@ async function getScoreAggregate(client, args) {
6454
6603
  }
6455
6604
  }));
6456
6605
  const prevWhereClause = toWhereClause(prevCombined);
6457
- const prevResult = await queryJson(client, `SELECT ${aggSql} AS value FROM ${currentScoresRelation()} ${prevWhereClause}`, prevCombined.params);
6606
+ const prevResult = await queryJson(client, `SELECT ${aggSql} AS value FROM ${prunedCurrentScoresRelation(prevCombined)} ${prevWhereClause}`, prevCombined.params);
6458
6607
  const previousValue = prevResult[0]?.value == null ? null : Number(prevResult[0]?.value);
6459
6608
  let changePercent = null;
6460
6609
  if (previousValue !== null && previousValue !== 0 && value !== null) changePercent = (value - previousValue) / Math.abs(previousValue) * 100;
@@ -6472,7 +6621,7 @@ async function getScoreBreakdown(client, args) {
6472
6621
  const combined = mergeFilters(buildScoreIdentityFilter(args), buildScoresFilterConditions(args.filters));
6473
6622
  const whereClause = toWhereClause(combined);
6474
6623
  const resolved = resolveScoreGroupBy(args.groupBy);
6475
- return { groups: (await queryJson(client, `SELECT ${resolved.map((e) => e.selectSql).join(", ")}, ${aggSql} AS value FROM ${currentScoresRelation()} ${whereClause} GROUP BY ${resolved.map((e) => e.groupSql).join(", ")} ORDER BY value DESC`, combined.params)).map((row) => ({
6624
+ return { groups: (await queryJson(client, `SELECT ${resolved.map((e) => e.selectSql).join(", ")}, ${aggSql} AS value FROM ${prunedCurrentScoresRelation(combined)} ${whereClause} GROUP BY ${resolved.map((e) => e.groupSql).join(", ")} ORDER BY value DESC`, combined.params)).map((row) => ({
6476
6625
  dimensions: Object.fromEntries(resolved.map((entry, index) => {
6477
6626
  const v = row[`group_by_${index}`];
6478
6627
  return [entry.key, v == null ? null : String(v)];
@@ -6491,7 +6640,7 @@ async function getScoreTimeSeries(client, args) {
6491
6640
  SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
6492
6641
  ${resolved.map((e) => e.selectSql).join(", ")},
6493
6642
  ${aggSql} AS value
6494
- FROM ${currentScoresRelation()} ${whereClause}
6643
+ FROM ${prunedCurrentScoresRelation(combined)} ${whereClause}
6495
6644
  GROUP BY bucket, ${resolved.map((e) => e.groupSql).join(", ")}
6496
6645
  ORDER BY bucket
6497
6646
  `, combined.params);
@@ -6513,7 +6662,7 @@ async function getScoreTimeSeries(client, args) {
6513
6662
  const rows = await queryJson(client, `
6514
6663
  SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
6515
6664
  ${aggSql} AS value
6516
- FROM ${currentScoresRelation()} ${whereClause}
6665
+ FROM ${prunedCurrentScoresRelation(combined)} ${whereClause}
6517
6666
  GROUP BY bucket
6518
6667
  ORDER BY bucket
6519
6668
  `, combined.params);
@@ -6530,26 +6679,22 @@ async function getScorePercentiles(client, args) {
6530
6679
  const combined = mergeFilters(buildScoreIdentityFilter(args), buildScoresFilterConditions(args.filters));
6531
6680
  const whereClause = toWhereClause(combined);
6532
6681
  if (!Array.isArray(args.percentiles) || args.percentiles.length === 0) throw new Error("Percentiles must include at least one value between 0 and 1.");
6533
- const series = [];
6534
- for (const p of args.percentiles) {
6535
- if (!Number.isFinite(p) || p < 0 || p > 1) throw new Error(`Percentile value must be a finite number between 0 and 1, got ${p}`);
6536
- const rows = await queryJson(client, `
6537
- SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
6538
- quantile(${p})(score) AS pvalue
6539
- FROM ${currentScoresRelation()}
6540
- ${whereClause}
6541
- GROUP BY bucket
6542
- ORDER BY bucket
6543
- `, combined.params);
6544
- series.push({
6545
- percentile: p,
6546
- points: rows.map((row) => ({
6547
- timestamp: row.bucket instanceof Date ? row.bucket : new Date(String(row.bucket)),
6548
- value: Number(row.pvalue ?? 0)
6549
- }))
6550
- });
6551
- }
6552
- return { series };
6682
+ for (const p of args.percentiles) if (!Number.isFinite(p) || p < 0 || p > 1) throw new Error(`Percentile value must be a finite number between 0 and 1, got ${p}`);
6683
+ const rows = await queryJson(client, `
6684
+ SELECT toStartOfInterval(timestamp, ${intervalSql}) AS bucket,
6685
+ quantiles(${args.percentiles.join(", ")})(score) AS pvalues
6686
+ FROM ${prunedCurrentScoresRelation(combined)}
6687
+ ${whereClause}
6688
+ GROUP BY bucket
6689
+ ORDER BY bucket
6690
+ `, combined.params);
6691
+ return { series: args.percentiles.map((percentile, index) => ({
6692
+ percentile,
6693
+ points: rows.map((row) => ({
6694
+ timestamp: row.bucket instanceof Date ? row.bucket : new Date(String(row.bucket)),
6695
+ value: Number(row.pvalues?.[index] ?? 0)
6696
+ }))
6697
+ })) };
6553
6698
  }
6554
6699
  //#endregion
6555
6700
  //#region src/storage/domains/observability/v-next/trace-query.ts
@@ -6793,6 +6938,22 @@ const TRACE_SELECT = `
6793
6938
  r.entityType AS entityType,
6794
6939
  r.environment AS environment,
6795
6940
  ${TRACE_STATUS_SQL} AS status`;
6941
+ /** Candidate columns carried through the page-mode window sort (no payload blobs). */
6942
+ const TRACE_PAGE_COLUMNS = [
6943
+ "traceId",
6944
+ "rootSpanId",
6945
+ "name",
6946
+ "entityId",
6947
+ "parentSpanId",
6948
+ "threadId",
6949
+ "resourceId",
6950
+ "startedAt",
6951
+ "endedAt",
6952
+ "entityName",
6953
+ "entityType",
6954
+ "environment",
6955
+ "status"
6956
+ ];
6796
6957
  var ParameterBuilder = class {
6797
6958
  params = {};
6798
6959
  #next = 1;
@@ -6879,13 +7040,12 @@ function collectThreadRelationCollections(predicate, collections) {
6879
7040
  }
6880
7041
  function compilePredicate(predicate, parameters) {
6881
7042
  if (predicate.type === "relation") {
6882
- const existence = `EXISTS (
6883
- SELECT 1 FROM ${predicate.collection === "spans" ? "current_spans" : predicate.collection === "scores" ? "current_scores" : "current_feedback"} s
7043
+ const matching = `(
7044
+ SELECT s.traceId FROM ${predicate.collection === "spans" ? "current_spans" : predicate.collection === "scores" ? "current_scores" : "current_feedback"} s
6884
7045
  WHERE isNotNull(s.traceId)
6885
- AND s.traceId = r.traceId
6886
7046
  AND (${predicate.collection === "feedback" ? compileFeedbackScalarPredicate(predicate.predicate, parameters) : compileScalarPredicate(predicate.predicate, predicate.collection === "spans" ? SPAN_FIELDS : SCORE_FIELDS, parameters)})
6887
7047
  )`;
6888
- return predicate.quantifier === "some" ? existence : `NOT ${existence}`;
7048
+ return `r.traceId ${predicate.quantifier === "some" ? "IN" : "NOT IN"} ${matching}`;
6889
7049
  }
6890
7050
  if (predicate.type === "boolean") return predicate.args.map((arg) => `(${compilePredicate(arg, parameters)})`).join(predicate.operator === "and" ? " AND " : " OR ");
6891
7051
  if (predicate.type === "not") return `NOT (${compilePredicate(predicate.arg, parameters)})`;
@@ -6893,12 +7053,12 @@ function compilePredicate(predicate, parameters) {
6893
7053
  }
6894
7054
  function compileThreadPredicate(predicate, parameters) {
6895
7055
  if (predicate.type === "relation") {
6896
- const existence = `EXISTS (
6897
- SELECT 1 FROM eligible_roots r
6898
- WHERE r.threadId = t.threadId
7056
+ const matching = `(
7057
+ SELECT r.threadId FROM eligible_roots r
7058
+ WHERE isNotNull(r.threadId)
6899
7059
  AND (${compilePredicate(predicate.predicate, parameters)})
6900
7060
  )`;
6901
- return predicate.quantifier === "some" ? existence : `NOT ${existence}`;
7061
+ return `t.threadId ${predicate.quantifier === "some" ? "IN" : "NOT IN"} ${matching}`;
6902
7062
  }
6903
7063
  if (predicate.type === "boolean") return predicate.args.map((arg) => `(${compileThreadPredicate(arg, parameters)})`).join(predicate.operator === "and" ? " AND " : " OR ");
6904
7064
  return `NOT (${compileThreadPredicate(predicate.arg, parameters)})`;
@@ -6999,6 +7159,15 @@ function compileClickHouseTraceScope(selection, relationCollections, parameters,
6999
7159
  FROM (
7000
7160
  SELECT *
7001
7161
  FROM ${TABLE_FEEDBACK_EVENTS} FINAL
7162
+ -- ClickHouse cannot push the scope filter through LIMIT 1 BY, so keep
7163
+ -- every version of each feedbackId that ever pointed into the scope
7164
+ -- (traceId leads the sort key). A rewrite may move a feedback to another
7165
+ -- trace, so the current version is still filtered below.
7166
+ WHERE feedbackId IN (
7167
+ SELECT feedbackId
7168
+ FROM ${TABLE_FEEDBACK_EVENTS}
7169
+ WHERE traceId IN (SELECT traceId FROM root_scope)
7170
+ )
7002
7171
  ORDER BY feedbackId, writeVersion DESC, timestamp DESC
7003
7172
  LIMIT 1 BY feedbackId
7004
7173
  ) AS current
@@ -7053,11 +7222,12 @@ LIMIT ${parameters.add(plan.limit + 1, "UInt64")}`,
7053
7222
  cursorId: "0",
7054
7223
  traceId: ""
7055
7224
  };
7225
+ const lowerCursor = parameters.add(after.cursorId, "UInt64");
7056
7226
  return {
7057
7227
  query: `${candidates}, delta_candidates AS (
7058
7228
  SELECT traceId, max(cursorId) AS latestCursorId
7059
7229
  FROM ${TABLE_TRACE_ROOTS_DELTA}
7060
- WHERE tuple(cursorId, traceId) > ${`tuple(${parameters.add(after.cursorId, "UInt64")}, ${parameters.add(after.traceId, "String")})`} ${deltaHead ? `AND tuple(cursorId, traceId) <= tuple(${parameters.add(deltaHead.cursorId, "UInt64")}, ${parameters.add(deltaHead.traceId, "String")})` : ""}
7230
+ WHERE cursorId >= ${lowerCursor} AND tuple(cursorId, traceId) > ${`tuple(${lowerCursor}, ${parameters.add(after.traceId, "String")})`} ${deltaHead ? `AND tuple(cursorId, traceId) <= tuple(${parameters.add(deltaHead.cursorId, "UInt64")}, ${parameters.add(deltaHead.traceId, "String")})` : ""}
7061
7231
  GROUP BY traceId
7062
7232
  )
7063
7233
  SELECT c.*, toString(d.latestCursorId) AS __delta_cursor
@@ -7071,46 +7241,25 @@ LIMIT ${parameters.add(plan.limit + 1, "UInt64")}`,
7071
7241
  }
7072
7242
  const orderField = resolveOrderField(plan.orderBy.field);
7073
7243
  const direction = plan.orderBy.direction === "asc" ? "ASC" : "DESC";
7074
- if (plan.paginationMode === "page") return {
7075
- query: `${candidates},
7244
+ if (plan.paginationMode === "page") {
7245
+ const onPage = `__row_position > ${parameters.add(plan.page * plan.perPage, "UInt64")} AND __row_position <= ${parameters.add((plan.page + 1) * plan.perPage, "UInt64")}`;
7246
+ return {
7247
+ query: `${candidates},
7076
7248
  page_rows AS (
7077
- SELECT *, row_number() OVER (ORDER BY ${orderField} ${direction}, traceId ASC) AS __row_position
7078
- FROM candidates
7079
- ORDER BY ${orderField} ${direction}, traceId ASC
7080
- LIMIT ${parameters.add(plan.perPage, "UInt64")} OFFSET ${parameters.add(plan.page * plan.perPage, "UInt64")}
7081
- ),
7082
- page_total AS (
7083
- SELECT count() AS total
7249
+ SELECT
7250
+ ${TRACE_PAGE_COLUMNS.join(",\n ")},
7251
+ row_number() OVER (ORDER BY ${orderField} ${direction}, traceId ASC) AS __row_position,
7252
+ count() OVER () AS total
7084
7253
  FROM candidates
7085
7254
  )
7086
- SELECT page_rows.*, page_total.total, 0 AS __metadata
7255
+ SELECT *, if(${onPage}, 0, 1) AS __metadata
7087
7256
  FROM page_rows
7088
- CROSS JOIN page_total
7089
- UNION ALL
7090
- SELECT
7091
- '' AS traceId,
7092
- '' AS rootSpanId,
7093
- '' AS name,
7094
- CAST(NULL, 'Nullable(String)') AS entityId,
7095
- CAST(NULL, 'Nullable(String)') AS parentSpanId,
7096
- CAST(NULL, 'Nullable(String)') AS metadata,
7097
- CAST(NULL, 'Nullable(String)') AS input,
7098
- CAST(NULL, 'Nullable(String)') AS threadId,
7099
- CAST(NULL, 'Nullable(String)') AS resourceId,
7100
- toDateTime64(0, 3, 'UTC') AS startedAt,
7101
- toDateTime64(0, 3, 'UTC') AS endedAt,
7102
- CAST(NULL, 'Nullable(String)') AS entityName,
7103
- CAST(NULL, 'Nullable(String)') AS entityType,
7104
- CAST(NULL, 'Nullable(String)') AS environment,
7105
- '' AS status,
7106
- 0 AS __row_position,
7107
- page_total.total AS total,
7108
- 1 AS __metadata
7109
- FROM page_total
7257
+ WHERE (${onPage}) OR __row_position = 1
7110
7258
  ORDER BY __metadata ASC, __row_position ASC`,
7111
- query_params: parameters.params,
7112
- sharedSnapshot: true
7113
- };
7259
+ query_params: parameters.params,
7260
+ sharedSnapshot: true
7261
+ };
7262
+ }
7114
7263
  let pageCondition = "";
7115
7264
  if (plan.cursor) {
7116
7265
  const comparison = plan.orderBy.direction === "asc" ? ">" : "<";
@@ -7128,6 +7277,23 @@ LIMIT ${limit}`,
7128
7277
  query_params: parameters.params
7129
7278
  };
7130
7279
  }
7280
+ /**
7281
+ * Fetches the metadata/input payloads for page-mode rows. Looks rows up by the
7282
+ * trace_roots sort-key prefix `(startedAt, traceId)`, so only the page's
7283
+ * granules are read. A root can have unmerged versions in different `endedAt`
7284
+ * partitions, so `endedAt` is part of the key: the payload comes from the same
7285
+ * version as the candidate row.
7286
+ */
7287
+ function compileClickHouseTraceRootPayloads(keys) {
7288
+ const parameters = new ParameterBuilder();
7289
+ return {
7290
+ query: `SELECT traceId, spanId AS rootSpanId, metadataRaw AS metadata, input
7291
+ FROM ${TABLE_TRACE_ROOTS}
7292
+ WHERE (startedAt, traceId, spanId, endedAt) IN (${keys.map((key) => `(${parameters.add(key.startedAt, "DateTime64(3, 'UTC')")}, ${parameters.add(key.traceId, "String")}, ${parameters.add(key.rootSpanId, "String")}, ${parameters.add(key.endedAt, "DateTime64(3, 'UTC')")})`).join(", ")})
7293
+ LIMIT 1 BY traceId, spanId`,
7294
+ query_params: parameters.params
7295
+ };
7296
+ }
7131
7297
  function compileClickHouseThreadQuery(plan) {
7132
7298
  const parameters = new ParameterBuilder();
7133
7299
  const relationCollections = collectRelationCollections(plan.traces.where);
@@ -7321,7 +7487,21 @@ async function queryTraces(client, plan, timeoutMs, strategy = null) {
7321
7487
  if (plan.paginationMode === "page") {
7322
7488
  const rows = await runWithClickHouseTraceQueryTimeout(client, { timeoutMs: deltaHead ? remaining() : timeoutMs }, compileClickHouseTraceQuery(plan, deltaHead));
7323
7489
  const total = Number(rows.at(-1)?.total ?? 0);
7324
- const traces = rows.filter((row) => Number(row.__metadata) === 0).map((row) => ({
7490
+ const pageRows = rows.filter((row) => Number(row.__metadata) === 0);
7491
+ const payloads = /* @__PURE__ */ new Map();
7492
+ if (pageRows.length > 0) {
7493
+ const payloadRows = await runWithClickHouseTraceQueryTimeout(client, { timeoutMs: remaining() }, compileClickHouseTraceRootPayloads(pageRows.map((row) => ({
7494
+ traceId: String(row.traceId),
7495
+ rootSpanId: String(row.rootSpanId),
7496
+ startedAt: asIsoTimestamp(row.startedAt),
7497
+ endedAt: asIsoTimestamp(row.endedAt)
7498
+ }))));
7499
+ for (const payload of payloadRows) payloads.set(`${payload.traceId}\u0000${payload.rootSpanId}`, payload);
7500
+ }
7501
+ const traces = pageRows.map((row) => ({
7502
+ ...row,
7503
+ ...payloads.get(`${row.traceId}\u0000${row.rootSpanId}`)
7504
+ })).map((row) => ({
7325
7505
  traceId: String(row.traceId),
7326
7506
  rootSpanId: String(row.rootSpanId),
7327
7507
  name: row.name,
@@ -7420,6 +7600,201 @@ async function queryThreads(client, plan, timeoutMs) {
7420
7600
  }) : null }
7421
7601
  });
7422
7602
  }
7603
+ /** Compile a span-row filter with exactly the same rules as trace span predicates. */
7604
+ function compileSpanQueryPredicate(predicate) {
7605
+ const parameters = new ParameterBuilder();
7606
+ return {
7607
+ sql: compileScalarPredicate(predicate, SPAN_FIELDS, parameters),
7608
+ params: parameters.params
7609
+ };
7610
+ }
7611
+ //#endregion
7612
+ //#region src/storage/domains/observability/v-next/span-query.ts
7613
+ const identityFields = [
7614
+ "organizationId",
7615
+ "resourceId",
7616
+ "traceId",
7617
+ "spanId"
7618
+ ];
7619
+ const identities = identityFields.join(", ");
7620
+ const identitySetColumns = "isNull(organizationId), ifNull(organizationId, ''), isNull(resourceId), ifNull(resourceId, ''), traceId, spanId";
7621
+ const identitySetKey = `tuple(${identitySetColumns})`;
7622
+ const displayFields = [
7623
+ "parentSpanId",
7624
+ "name",
7625
+ "spanType",
7626
+ "entityType",
7627
+ "entityId",
7628
+ "entityName",
7629
+ "startedAt",
7630
+ "endedAt",
7631
+ "model",
7632
+ "provider",
7633
+ "durationMs",
7634
+ "status"
7635
+ ];
7636
+ var Parameters = class {
7637
+ values = {};
7638
+ add(value, timestamp = false) {
7639
+ const name = `span_${Object.keys(this.values).length}`;
7640
+ this.values[name] = timestamp ? new Date(value).toISOString().replace("T", " ").replace(/Z$/, "") : value;
7641
+ return `{${name}:${timestamp ? "DateTime64(3, 'UTC')" : typeof value === "number" ? "UInt64" : "String"}}`;
7642
+ }
7643
+ };
7644
+ function identityValues(row) {
7645
+ return [
7646
+ row.organizationId === null ? 1 : 0,
7647
+ row.organizationId ?? "",
7648
+ row.resourceId === null ? 1 : 0,
7649
+ row.resourceId ?? "",
7650
+ row.traceId,
7651
+ row.spanId
7652
+ ];
7653
+ }
7654
+ function afterCursor(plan, parameters) {
7655
+ if (!plan.cursor) return "1";
7656
+ const cursor = plan.cursor;
7657
+ const timestamp = parameters.add(cursor.sortValue, true);
7658
+ const field = `s.${plan.orderBy.field}`;
7659
+ const terms = [`${field} ${plan.orderBy.direction === "asc" ? ">" : "<"} ${timestamp}`];
7660
+ const equal = [`${field} = ${timestamp}`];
7661
+ for (const key of identityFields) {
7662
+ const value = cursor[key];
7663
+ if (value === null) equal.push(`isNull(s.${key})`);
7664
+ else {
7665
+ const parameter = parameters.add(value);
7666
+ terms.push(`(${equal.join(" AND ")} AND (s.${key} > ${parameter} OR isNull(s.${key})))`);
7667
+ equal.push(`s.${key} = ${parameter}`);
7668
+ }
7669
+ }
7670
+ return `(${terms.join(" OR ")})`;
7671
+ }
7672
+ function predicateFields(predicate, fields = /* @__PURE__ */ new Set()) {
7673
+ if (!predicate) return fields;
7674
+ if (predicate.type === "boolean") for (const arg of predicate.args) predicateFields(arg, fields);
7675
+ else if (predicate.type === "not") predicateFields(predicate.arg, fields);
7676
+ else fields.add(predicate.field);
7677
+ return fields;
7678
+ }
7679
+ function fieldExpression(field) {
7680
+ if (field === "model" || field === "provider") return `if(JSONType(e.attributes, '${field}') = 'String', JSONExtractString(e.attributes, '${field}'), NULL)`;
7681
+ if (field === "durationMs") return "dateDiff('millisecond', e.startedAt, e.endedAt)";
7682
+ if (field === "status") return "if(isNull(e.error), 'success', 'error')";
7683
+ if (field === "error") return "if(isNull(e.error), NULL, 'error')";
7684
+ return `e.${field}`;
7685
+ }
7686
+ function compileClickHouseSpanQuery(plan) {
7687
+ const parameters = new Parameters();
7688
+ const from = parameters.add(plan.timeRange.from, true);
7689
+ const to = parameters.add(plan.timeRange.to, true);
7690
+ const scope = [];
7691
+ if (plan.scope) {
7692
+ scope.push(`organizationId = ${parameters.add(plan.scope.organizationId)}`);
7693
+ if (plan.scope.resourceId !== void 0) scope.push(`resourceId = ${parameters.add(plan.scope.resourceId)}`);
7694
+ }
7695
+ const predicate = plan.where ? compileSpanQueryPredicate(plan.where) : {
7696
+ sql: "1",
7697
+ params: {}
7698
+ };
7699
+ const selectionFields = [.../* @__PURE__ */ new Set([
7700
+ "startedAt",
7701
+ "endedAt",
7702
+ ...predicateFields(plan.where)
7703
+ ])].filter((field) => !new Set(identityFields).has(field));
7704
+ const expressions = selectionFields.map(fieldExpression);
7705
+ const after = afterCursor(plan, parameters);
7706
+ return {
7707
+ query: `WITH candidate_ids AS (
7708
+ SELECT ${identitySetKey} AS identity FROM ${TABLE_SPAN_EVENTS}
7709
+ PREWHERE endedAt >= ${from}
7710
+ WHERE startedAt >= ${from} AND startedAt < ${to} ${scope.length ? `AND ${scope.join(" AND ")}` : ""}
7711
+ ), candidates AS (
7712
+ SELECT ${identities}, ${selectionFields.map((field, index) => `${expressions[index]} AS ${field}`).join(", ")}
7713
+ FROM ${TABLE_SPAN_EVENTS} e
7714
+ PREWHERE e.endedAt >= ${from}
7715
+ WHERE ${identitySetKey} IN (SELECT identity FROM candidate_ids) ${scope.length ? `AND ${scope.join(" AND ")}` : ""}
7716
+ ORDER BY ${identities}, e.endedAt DESC
7717
+ LIMIT 1 BY ${identities}
7718
+ )
7719
+ SELECT ${[
7720
+ ...identityFields,
7721
+ "startedAt",
7722
+ "endedAt"
7723
+ ].map((field) => `s.${field}`).join(", ")}
7724
+ FROM candidates s WHERE s.startedAt >= ${from} AND s.startedAt < ${to} AND (${predicate.sql}) AND ${after}
7725
+ ORDER BY s.${plan.orderBy.field} ${plan.orderBy.direction === "asc" ? "ASC" : "DESC"}, ${identityFields.map((field) => `s.${field} ASC NULLS LAST`).join(", ")}
7726
+ LIMIT ${parameters.add(plan.limit + 1)}`,
7727
+ query_params: {
7728
+ ...parameters.values,
7729
+ ...predicate.params
7730
+ }
7731
+ };
7732
+ }
7733
+ function iso(value) {
7734
+ return new Date(value.includes("T") ? value : `${value.replace(" ", "T")}Z`).toISOString();
7735
+ }
7736
+ async function querySpans(client, plan, timeoutMs) {
7737
+ const deadline = performance.now() + coreStorage.resolveTraceQueryTimeoutMs(timeoutMs);
7738
+ const run = async (compiled) => {
7739
+ const remaining = Math.floor(deadline - performance.now());
7740
+ if (remaining <= 0) throw new coreStorage.TraceQueryExecutionError();
7741
+ return await runWithClickHouseTraceQueryTimeout(client, {
7742
+ timeoutMs: remaining,
7743
+ memoryLimitBytes: 512 * 1024 * 1024
7744
+ }, compiled);
7745
+ };
7746
+ const selected = (await run(compileClickHouseSpanQuery(plan))).map((row) => ({
7747
+ ...row,
7748
+ startedAt: iso(row.startedAt),
7749
+ endedAt: iso(row.endedAt)
7750
+ }));
7751
+ const page = selected.slice(0, plan.limit);
7752
+ if (!page.length) return {
7753
+ spans: [],
7754
+ page: { next: null }
7755
+ };
7756
+ const payloadWhere = `tuple(${identitySetColumns}, endedAt) IN {page:Array(Tuple(UInt8, String, UInt8, String, String, String, DateTime64(3, 'UTC')))}`;
7757
+ const payloads = await run({
7758
+ query: `SELECT ${identities}, ${displayFields.map((field, index) => `data.${index + 1} AS ${field}`).join(", ")},
7759
+ data.${displayFields.length + 1} AS inputPreview, data.${displayFields.length + 2} AS outputPreview
7760
+ FROM (SELECT ${identities}, any(tuple(${displayFields.map(fieldExpression).join(", ")}, substringUTF8(input, 1, ${coreStorage.SPAN_QUERY_MAX_PREVIEW_CHARACTERS + 1}), substringUTF8(output, 1, ${coreStorage.SPAN_QUERY_MAX_PREVIEW_CHARACTERS + 1}))) AS data
7761
+ FROM ${TABLE_SPAN_EVENTS} e WHERE ${payloadWhere} GROUP BY ${identities})`,
7762
+ query_params: { page: page.map((row) => new TupleParam([...identityValues(row), row.endedAt.replace("T", " ").replace(/Z$/, "")])) }
7763
+ });
7764
+ const metricWhere = `${identitySetKey} IN {page:Array(Tuple(UInt8, String, UInt8, String, String, String))}`;
7765
+ const metrics = await run({
7766
+ query: `SELECT ${identities}, data.1 AS name, data.2 AS estimatedCost,
7767
+ data.3 AS costUnit, data.4 AS allocation, data.5 AS costError
7768
+ FROM (SELECT ${identities}, metricId, argMax(tuple(name, estimatedCost, costUnit,
7769
+ JSONExtractString(costMetadata, 'allocation'), JSONHas(costMetadata, 'error')), timestamp) AS data
7770
+ FROM ${TABLE_METRIC_EVENTS} WHERE (${metricWhere}) AND name LIKE 'mastra_model_%'
7771
+ GROUP BY ${identities}, metricId)
7772
+ LIMIT ${coreStorage.SPAN_QUERY_MAX_COST_METRICS + 1}`,
7773
+ query_params: { page: page.map((row) => new TupleParam(identityValues(row))) }
7774
+ });
7775
+ if (metrics.length > coreStorage.SPAN_QUERY_MAX_COST_METRICS) throw new coreStorage.TraceQueryResourceLimitError();
7776
+ const byIdentity = new Map(payloads.map((row) => [coreStorage.spanQueryIdentityKey(row), row]));
7777
+ const hydrated = page.flatMap((identity) => {
7778
+ const row = byIdentity.get(coreStorage.spanQueryIdentityKey(identity));
7779
+ if (!row) return [];
7780
+ const { inputPreview: _input, outputPreview: _output, ...metadata } = row;
7781
+ return [{
7782
+ ...metadata,
7783
+ ...identity,
7784
+ durationMs: Number(row.durationMs)
7785
+ }];
7786
+ });
7787
+ const result = coreStorage.buildSpanQueryResponse(plan, hydrated, payloads, metrics);
7788
+ const last = page.at(-1);
7789
+ result.page.next = selected.length > plan.limit ? coreStorage.encodeSpanQueryCursor(plan, {
7790
+ organizationId: last.organizationId,
7791
+ resourceId: last.resourceId,
7792
+ traceId: last.traceId,
7793
+ spanId: last.spanId,
7794
+ sortValue: last[plan.orderBy.field]
7795
+ }) : null;
7796
+ return result;
7797
+ }
7423
7798
  //#endregion
7424
7799
  //#region src/storage/domains/observability/v-next/trace-aggregate.ts
7425
7800
  /** Trace-root column expressions (alias `r`) for each groupable dimension; same values `where` sees. */
@@ -7669,18 +8044,16 @@ async function listTraceRows(client, args, strategy, projection, mapRows) {
7669
8044
  const page = pagination?.page ?? 0;
7670
8045
  const perPage = pagination?.perPage ?? 10;
7671
8046
  const { conditions, params } = buildTraceFilterConditions(filters, "r");
7672
- if (filters?.hasChildError != null) if (filters.hasChildError) conditions.push(`EXISTS (
7673
- SELECT 1 FROM ${TABLE_SPAN_EVENTS} c
7674
- WHERE c.traceId = r.traceId
7675
- AND c.parentSpanId IS NOT NULL
7676
- AND c.error IS NOT NULL
7677
- )`);
7678
- else conditions.push(`NOT EXISTS (
7679
- SELECT 1 FROM ${TABLE_SPAN_EVENTS} c
7680
- WHERE c.traceId = r.traceId
7681
- AND c.parentSpanId IS NOT NULL
8047
+ if (filters?.hasChildError != null) {
8048
+ const scope = buildTraceFilterConditions(filters, "scope_r");
8049
+ const traceScope = scope.conditions.length ? `c.traceId IN (SELECT scope_r.traceId FROM ${TABLE_TRACE_ROOTS} scope_r WHERE ${scope.conditions.join(" AND ")})
8050
+ AND ` : "";
8051
+ conditions.push(`r.traceId ${filters.hasChildError ? "IN" : "NOT IN"} (
8052
+ SELECT c.traceId FROM ${TABLE_SPAN_EVENTS} c
8053
+ WHERE ${traceScope}c.parentSpanId IS NOT NULL
7682
8054
  AND c.error IS NOT NULL
7683
8055
  )`);
8056
+ }
7684
8057
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7685
8058
  const deltaCursorEnabled = deltaPollingSupported(strategy);
7686
8059
  if (mode === "delta") {
@@ -7736,15 +8109,27 @@ async function listTraceRows(client, args, strategy, projection, mapRows) {
7736
8109
  const rows = await (await client.query({
7737
8110
  query: `
7738
8111
  SELECT ${projection.outerSelect} FROM (
8112
+ -- Deferred join: pick the page's sort keys from a narrow sort, then
8113
+ -- read full rows only for those keys. LIMIT 1 BY disables ClickHouse's
8114
+ -- own lazy materialization, so sorting SELECT * directly would carry
8115
+ -- every matching row's payload columns through the sort. The filter is
8116
+ -- applied again on the re-read: a dedupeKey can have unmerged versions
8117
+ -- (in different endedAt partitions) and only a matching one may win.
7739
8118
  SELECT ${projection.innerSelect}
7740
8119
  FROM ${TABLE_TRACE_ROOTS} r
7741
- ${whereClause}
7742
- ORDER BY dedupeKey
8120
+ ${appendWhere(whereClause, `(r.startedAt, r.traceId, r.dedupeKey) IN (
8121
+ SELECT startedAt, traceId, dedupeKey
8122
+ FROM ${TABLE_TRACE_ROOTS} r
8123
+ ${whereClause}
8124
+ ORDER BY ${orderClause}, dedupeKey ASC
8125
+ LIMIT 1 BY dedupeKey
8126
+ LIMIT {limit:UInt32}
8127
+ OFFSET {offset:UInt32}
8128
+ )`)}
8129
+ ORDER BY ${orderClause}, dedupeKey ASC
7743
8130
  LIMIT 1 BY dedupeKey
7744
8131
  )
7745
- ORDER BY ${orderClause}
7746
- LIMIT {limit:UInt32}
7747
- OFFSET {offset:UInt32}
8132
+ ORDER BY ${orderClause}, dedupeKey ASC
7748
8133
  `,
7749
8134
  query_params: {
7750
8135
  ...params,
@@ -7778,6 +8163,7 @@ async function listTracesLight(client, args, strategy) {
7778
8163
  return listTraceRows(client, args, strategy, LIGHT_PROJECTION, (rows) => rows.map(rowToLightSpanRecord));
7779
8164
  }
7780
8165
  async function queryTracesAfterCursor(client, deltaSelect, whereClause, params, limit, cursorId) {
8166
+ const deltaKeys = `SELECT startedAt, traceId, dedupeKey FROM ${TABLE_TRACE_ROOTS_DELTA} WHERE cursorId > {afterCursor:UInt64}`;
7781
8167
  return await (await client.query({
7782
8168
  query: `
7783
8169
  SELECT
@@ -7786,12 +8172,16 @@ async function queryTracesAfterCursor(client, deltaSelect, whereClause, params,
7786
8172
  r.traceId AS traceId,
7787
8173
  r.dedupeKey AS dedupeKey,
7788
8174
  toString(d.cursorId) AS cursorId
7789
- FROM ${TABLE_TRACE_ROOTS_DELTA} d
7790
- INNER JOIN ${TABLE_TRACE_ROOTS} r
8175
+ FROM ${TABLE_TRACE_ROOTS} r
8176
+ INNER JOIN (
8177
+ SELECT cursorId, startedAt, traceId, dedupeKey
8178
+ FROM ${TABLE_TRACE_ROOTS_DELTA}
8179
+ WHERE cursorId > {afterCursor:UInt64}
8180
+ ) d
7791
8181
  ON r.startedAt = d.startedAt
7792
8182
  AND r.traceId = d.traceId
7793
8183
  AND r.dedupeKey = d.dedupeKey
7794
- ${whereClause ? `${whereClause} AND d.cursorId > {afterCursor:UInt64}` : "WHERE d.cursorId > {afterCursor:UInt64}"}
8184
+ ${appendWhere(whereClause, `(r.startedAt, r.traceId, r.dedupeKey) IN (${deltaKeys})`)}
7795
8185
  ORDER BY d.cursorId ASC
7796
8186
  LIMIT {fetchLimit:UInt32}
7797
8187
  `,
@@ -7804,27 +8194,30 @@ async function queryTracesAfterCursor(client, deltaSelect, whereClause, params,
7804
8194
  clickhouse_settings: CH_SETTINGS
7805
8195
  })).json();
7806
8196
  }
8197
+ /**
8198
+ * Newest delta cursor whose trace root matches the filters. Without filters
8199
+ * every delta row qualifies, so this is the stream head. With filters, the
8200
+ * root scan is bounded below by the oldest `startedAt` still in the delta
8201
+ * table (a 2-day TTL window) instead of reading all of trace_roots.
8202
+ */
7807
8203
  async function getDeltaCursor$1(client, whereClause, params) {
8204
+ if (!whereClause) return getStreamHeadCursor$1(client);
7808
8205
  const cursorId = (await (await client.query({
7809
8206
  query: `
7810
8207
  SELECT toString(max(d.cursorId)) AS cursorId
7811
8208
  FROM mastra_trace_roots_delta d
7812
- INNER JOIN mastra_trace_roots r
7813
- ON r.startedAt = d.startedAt
7814
- AND r.traceId = d.traceId
7815
- AND r.dedupeKey = d.dedupeKey
7816
- ${whereClause}
8209
+ WHERE (d.startedAt, d.traceId, d.dedupeKey) IN (
8210
+ SELECT r.startedAt, r.traceId, r.dedupeKey
8211
+ FROM mastra_trace_roots r
8212
+ ${appendWhere(whereClause, `r.startedAt >= (SELECT min(startedAt) FROM mastra_trace_roots_delta)`)}
8213
+ )
7817
8214
  `,
7818
8215
  query_params: params,
7819
8216
  format: "JSONEachRow",
7820
8217
  clickhouse_settings: CH_SETTINGS
7821
8218
  })).json())[0]?.cursorId ?? null;
7822
8219
  if (cursorId) return cursorId;
7823
- return (await (await client.query({
7824
- query: `SELECT toString(max(cursorId)) AS cursorId FROM mastra_trace_roots_delta`,
7825
- format: "JSONEachRow",
7826
- clickhouse_settings: CH_SETTINGS
7827
- })).json())[0]?.cursorId ?? "0";
8220
+ return getStreamHeadCursor$1(client);
7828
8221
  }
7829
8222
  async function getStreamHeadCursor$1(client) {
7830
8223
  return (await (await client.query({
@@ -8315,15 +8708,28 @@ async function listBranches(client, args, strategy) {
8315
8708
  const spans = (await (await client.query({
8316
8709
  query: `
8317
8710
  SELECT * FROM (
8711
+ -- Deferred join: pick the page's sort keys from a narrow sort, then
8712
+ -- read full rows only for those keys. LIMIT 1 BY disables ClickHouse's
8713
+ -- own lazy materialization, so sorting SELECT * directly would carry
8714
+ -- every matching branch's payload columns through the sort. The filter
8715
+ -- is applied again on the re-read: a dedupeKey can have unmerged
8716
+ -- versions (in different endedAt partitions) and only a matching one
8717
+ -- may win.
8318
8718
  SELECT *
8319
8719
  FROM ${TABLE_TRACE_BRANCHES} b
8320
- ${whereClause}
8321
- ORDER BY b.dedupeKey
8720
+ ${appendWhere(whereClause, `(b.spanType, b.startedAt, b.traceId, b.dedupeKey) IN (
8721
+ SELECT b.spanType, b.startedAt, b.traceId, b.dedupeKey
8722
+ FROM ${TABLE_TRACE_BRANCHES} b
8723
+ ${whereClause}
8724
+ ORDER BY b.${sortField} ${sortDirection}, b.dedupeKey ASC
8725
+ LIMIT 1 BY b.dedupeKey
8726
+ LIMIT {limit:UInt32}
8727
+ OFFSET {offset:UInt32}
8728
+ )`)}
8729
+ ORDER BY b.${sortField} ${sortDirection}, b.dedupeKey ASC
8322
8730
  LIMIT 1 BY b.dedupeKey
8323
8731
  )
8324
8732
  ORDER BY ${sortField} ${sortDirection}, dedupeKey ASC
8325
- LIMIT {limit:UInt32}
8326
- OFFSET {offset:UInt32}
8327
8733
  `,
8328
8734
  query_params: {
8329
8735
  ...params,
@@ -8345,6 +8751,7 @@ async function listBranches(client, args, strategy) {
8345
8751
  };
8346
8752
  }
8347
8753
  async function queryBranchesAfterCursor(client, whereClause, params, limit, cursorId) {
8754
+ const deltaKeys = `SELECT spanType, startedAt, traceId, dedupeKey FROM ${TABLE_TRACE_BRANCHES_DELTA} WHERE cursorId > {afterCursor:UInt64}`;
8348
8755
  return await (await client.query({
8349
8756
  query: `
8350
8757
  SELECT
@@ -8355,14 +8762,18 @@ async function queryBranchesAfterCursor(client, whereClause, params, limit, curs
8355
8762
  b.spanId AS spanId,
8356
8763
  b.dedupeKey AS dedupeKey,
8357
8764
  toString(d.cursorId) AS cursorId
8358
- FROM ${TABLE_TRACE_BRANCHES_DELTA} d
8359
- INNER JOIN ${TABLE_TRACE_BRANCHES} b
8765
+ FROM ${TABLE_TRACE_BRANCHES} b
8766
+ INNER JOIN (
8767
+ SELECT cursorId, spanType, startedAt, traceId, spanId, dedupeKey
8768
+ FROM ${TABLE_TRACE_BRANCHES_DELTA}
8769
+ WHERE cursorId > {afterCursor:UInt64}
8770
+ ) d
8360
8771
  ON b.spanType = d.spanType
8361
8772
  AND b.startedAt = d.startedAt
8362
8773
  AND b.traceId = d.traceId
8363
8774
  AND b.spanId = d.spanId
8364
8775
  AND b.dedupeKey = d.dedupeKey
8365
- ${whereClause ? `${whereClause} AND d.cursorId > {afterCursor:UInt64}` : "WHERE d.cursorId > {afterCursor:UInt64}"}
8776
+ ${appendWhere(whereClause, `(b.spanType, b.startedAt, b.traceId, b.dedupeKey) IN (${deltaKeys})`)}
8366
8777
  ORDER BY d.cursorId ASC
8367
8778
  LIMIT {fetchLimit:UInt32}
8368
8779
  `,
@@ -8375,29 +8786,29 @@ async function queryBranchesAfterCursor(client, whereClause, params, limit, curs
8375
8786
  clickhouse_settings: CH_SETTINGS
8376
8787
  })).json();
8377
8788
  }
8789
+ /**
8790
+ * Newest delta cursor whose branch matches the filters. Without filters this
8791
+ * is the stream head; with filters, the branch scan is bounded below by the
8792
+ * oldest `startedAt` still in the delta table.
8793
+ */
8378
8794
  async function getDeltaCursor(client, whereClause, params) {
8795
+ if (!whereClause) return getStreamHeadCursor(client);
8379
8796
  const cursorId = (await (await client.query({
8380
8797
  query: `
8381
8798
  SELECT toString(max(d.cursorId)) AS cursorId
8382
8799
  FROM mastra_trace_branches_delta d
8383
- INNER JOIN mastra_trace_branches b
8384
- ON b.spanType = d.spanType
8385
- AND b.startedAt = d.startedAt
8386
- AND b.traceId = d.traceId
8387
- AND b.spanId = d.spanId
8388
- AND b.dedupeKey = d.dedupeKey
8389
- ${whereClause}
8800
+ WHERE (d.spanType, d.startedAt, d.traceId, d.spanId, d.dedupeKey) IN (
8801
+ SELECT b.spanType, b.startedAt, b.traceId, b.spanId, b.dedupeKey
8802
+ FROM mastra_trace_branches b
8803
+ ${appendWhere(whereClause, `b.startedAt >= (SELECT min(startedAt) FROM mastra_trace_branches_delta)`)}
8804
+ )
8390
8805
  `,
8391
8806
  query_params: params,
8392
8807
  format: "JSONEachRow",
8393
8808
  clickhouse_settings: CH_SETTINGS
8394
8809
  })).json())[0]?.cursorId ?? null;
8395
8810
  if (cursorId) return cursorId;
8396
- return (await (await client.query({
8397
- query: `SELECT toString(max(cursorId)) AS cursorId FROM mastra_trace_branches_delta`,
8398
- format: "JSONEachRow",
8399
- clickhouse_settings: CH_SETTINGS
8400
- })).json())[0]?.cursorId ?? "0";
8811
+ return getStreamHeadCursor(client);
8401
8812
  }
8402
8813
  async function getStreamHeadCursor(client) {
8403
8814
  return (await (await client.query({
@@ -8411,6 +8822,7 @@ function buildBranchCursor(row) {
8411
8822
  }
8412
8823
  //#endregion
8413
8824
  //#region src/storage/domains/observability/v-next/index.ts
8825
+ const spanQueryFeatures = typeof coreStorage.planSpanQuery === "function" ? ["span-query"] : [];
8414
8826
  function buildSignalMigrationRequiredMessage(args) {
8415
8827
  const tableList = args.tables.map((table) => ` - ${table.table} (${table.engine})`).join("\n");
8416
8828
  return `
@@ -8848,7 +9260,8 @@ var ObservabilityStorageClickhouseVNext = class extends ObservabilityStorage {
8848
9260
  "trace-query-tenant-scope",
8849
9261
  "feedback",
8850
9262
  "trace-query-context-ids",
8851
- "trace-aggregate"
9263
+ "trace-aggregate",
9264
+ ...spanQueryFeatures
8852
9265
  ];
8853
9266
  return [
8854
9267
  "metrics",
@@ -8867,7 +9280,8 @@ var ObservabilityStorageClickhouseVNext = class extends ObservabilityStorage {
8867
9280
  "trace-query-tenant-scope",
8868
9281
  "feedback",
8869
9282
  "trace-query-context-ids",
8870
- "trace-aggregate"
9283
+ "trace-aggregate",
9284
+ ...spanQueryFeatures
8871
9285
  ];
8872
9286
  }
8873
9287
  async createSpan(args) {
@@ -8982,6 +9396,18 @@ var ObservabilityStorageClickhouseVNext = class extends ObservabilityStorage {
8982
9396
  }, error);
8983
9397
  }
8984
9398
  }
9399
+ async querySpans(plan) {
9400
+ try {
9401
+ return await querySpans(this.#client, plan, this.#traceQueryTimeoutMs);
9402
+ } catch (error) {
9403
+ if (error instanceof MastraError || error instanceof coreStorage.TraceQueryExecutionError || error instanceof coreStorage.TraceQueryCursorError || error instanceof coreStorage.TraceQueryResourceLimitError) throw error;
9404
+ throw new MastraError({
9405
+ id: createStorageErrorId("CLICKHOUSE", "QUERY_SPANS", "FAILED"),
9406
+ domain: ErrorDomain.STORAGE,
9407
+ category: ErrorCategory.THIRD_PARTY
9408
+ }, error);
9409
+ }
9410
+ }
8985
9411
  async queryTraces(plan) {
8986
9412
  try {
8987
9413
  return await queryTraces(this.#client, plan, this.#traceQueryTimeoutMs, this.#deltaCursorStrategy);
@@ -9548,6 +9974,7 @@ var ScoresStorageClickhouse = class extends ScoresStorage {
9548
9974
  tableName: TABLE_SCORERS,
9549
9975
  schema: TABLE_SCHEMAS[TABLE_SCORERS]
9550
9976
  });
9977
+ await this.#db.ensureSkipIndexes(TABLE_SCORERS);
9551
9978
  }
9552
9979
  async dangerouslyClearAll() {
9553
9980
  await this.#db.clearTable({ tableName: TABLE_SCORERS });
@@ -9958,6 +10385,7 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
9958
10385
  schema,
9959
10386
  ifNotExists: ["resourceId"]
9960
10387
  });
10388
+ await this.#db.ensureSkipIndexes(TABLE_WORKFLOW_SNAPSHOT);
9961
10389
  }
9962
10390
  async dangerouslyClearAll() {
9963
10391
  await this.#db.clearTable({ tableName: TABLE_WORKFLOW_SNAPSHOT });
@@ -10154,7 +10582,8 @@ var WorkflowsStorageClickhouse = class extends WorkflowsStorage {
10154
10582
  ORDER BY createdAt DESC LIMIT 1
10155
10583
  `,
10156
10584
  query_params: values,
10157
- format: "JSONEachRow"
10585
+ format: "JSONEachRow",
10586
+ clickhouse_settings: { use_skip_indexes_if_final: 1 }
10158
10587
  })).json();
10159
10588
  if (!Array.isArray(resultJson) || resultJson.length === 0) return null;
10160
10589
  return this.parseWorkflowRun(resultJson[0]);