@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/CHANGELOG.md +58 -0
- package/dist/docs/README.md +1 -1
- package/dist/docs/SKILL.md +1 -1
- package/dist/docs/SOURCE_MAP.json +1 -1
- package/dist/docs/storage/01-reference.md +15 -15
- package/dist/index.cjs +314 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +315 -11
- package/dist/index.js.map +1 -1
- package/dist/storage/db/index.d.ts +50 -0
- package/dist/storage/db/index.d.ts.map +1 -1
- package/dist/storage/db/utils.d.ts.map +1 -1
- package/dist/storage/domains/observability/index.d.ts +23 -0
- package/dist/storage/domains/observability/index.d.ts.map +1 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# @mastra/clickhouse
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.12
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fixed duplicate spans migration issue across all storage backends. When upgrading from older versions, existing duplicate (traceId, spanId) combinations in the spans table could prevent the unique constraint from being created. The migration deduplicates spans before adding the constraint. ([#12073](https://github.com/mastra-ai/mastra/pull/12073))
|
|
8
|
+
|
|
9
|
+
**Deduplication rules (in priority order):**
|
|
10
|
+
1. Keep completed spans (those with `endedAt` set) over incomplete spans
|
|
11
|
+
2. Among spans with the same completion status, keep the one with the newest `updatedAt`
|
|
12
|
+
3. Use `createdAt` as the final tiebreaker
|
|
13
|
+
|
|
14
|
+
**What changed:**
|
|
15
|
+
- Added `migrateSpans()` method to observability stores for manual migration
|
|
16
|
+
- Added `checkSpansMigrationStatus()` method to check if migration is needed
|
|
17
|
+
- All stores use optimized single-query deduplication to avoid memory issues on large tables
|
|
18
|
+
|
|
19
|
+
**Usage example:**
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
const observability = await storage.getStore('observability');
|
|
23
|
+
const status = await observability.checkSpansMigrationStatus();
|
|
24
|
+
if (status.needsMigration) {
|
|
25
|
+
const result = await observability.migrateSpans();
|
|
26
|
+
console.log(`Migration complete: ${result.duplicatesRemoved} duplicates removed`);
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Fixes #11840
|
|
31
|
+
|
|
32
|
+
- Renamed MastraStorage to MastraCompositeStore for better clarity. The old MastraStorage name remains available as a deprecated alias for backward compatibility, but will be removed in a future version. ([#12093](https://github.com/mastra-ai/mastra/pull/12093))
|
|
33
|
+
|
|
34
|
+
**Migration:**
|
|
35
|
+
|
|
36
|
+
Update your imports and usage:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// Before
|
|
40
|
+
import { MastraStorage } from '@mastra/core/storage';
|
|
41
|
+
|
|
42
|
+
const storage = new MastraStorage({
|
|
43
|
+
id: 'composite',
|
|
44
|
+
domains: { ... }
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// After
|
|
48
|
+
import { MastraCompositeStore } from '@mastra/core/storage';
|
|
49
|
+
|
|
50
|
+
const storage = new MastraCompositeStore({
|
|
51
|
+
id: 'composite',
|
|
52
|
+
domains: { ... }
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The new name better reflects that this is a composite storage implementation that routes different domains (workflows, traces, messages) to different underlying stores, avoiding confusion with the general "Mastra Storage" concept.
|
|
57
|
+
|
|
58
|
+
- Updated dependencies [[`026b848`](https://github.com/mastra-ai/mastra/commit/026b8483fbf5b6d977be8f7e6aac8d15c75558ac), [`ffa553a`](https://github.com/mastra-ai/mastra/commit/ffa553a3edc1bd17d73669fba66d6b6f4ac10897)]:
|
|
59
|
+
- @mastra/core@1.0.0-beta.26
|
|
60
|
+
|
|
3
61
|
## 1.0.0-beta.11
|
|
4
62
|
|
|
5
63
|
### Patch Changes
|
package/dist/docs/README.md
CHANGED
package/dist/docs/SKILL.md
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
> Documentation for combining multiple storage backends in Mastra.
|
|
11
11
|
|
|
12
|
-
`
|
|
12
|
+
`MastraCompositeStore` can compose storage domains from different providers. Use it when you need different databases for different purposes. For example, use LibSQL for memory and PostgreSQL for workflows.
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
|
-
`
|
|
16
|
+
`MastraCompositeStore` is included in `@mastra/core`:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
npm install @mastra/core@beta
|
|
@@ -44,13 +44,13 @@ Mastra organizes storage into five specialized domains, each handling a specific
|
|
|
44
44
|
Import domain classes directly from each store package and compose them:
|
|
45
45
|
|
|
46
46
|
```typescript title="src/mastra/index.ts"
|
|
47
|
-
import {
|
|
47
|
+
import { MastraCompositeStore } from "@mastra/core/storage";
|
|
48
48
|
import { WorkflowsPG, ScoresPG } from "@mastra/pg";
|
|
49
49
|
import { MemoryLibSQL } from "@mastra/libsql";
|
|
50
50
|
import { Mastra } from "@mastra/core";
|
|
51
51
|
|
|
52
52
|
export const mastra = new Mastra({
|
|
53
|
-
storage: new
|
|
53
|
+
storage: new MastraCompositeStore({
|
|
54
54
|
id: "composite",
|
|
55
55
|
domains: {
|
|
56
56
|
memory: new MemoryLibSQL({ url: "file:./local.db" }),
|
|
@@ -66,7 +66,7 @@ export const mastra = new Mastra({
|
|
|
66
66
|
Use `default` to specify a fallback storage, then override specific domains:
|
|
67
67
|
|
|
68
68
|
```typescript title="src/mastra/index.ts"
|
|
69
|
-
import {
|
|
69
|
+
import { MastraCompositeStore } from "@mastra/core/storage";
|
|
70
70
|
import { PostgresStore } from "@mastra/pg";
|
|
71
71
|
import { MemoryLibSQL } from "@mastra/libsql";
|
|
72
72
|
import { Mastra } from "@mastra/core";
|
|
@@ -77,7 +77,7 @@ const pgStore = new PostgresStore({
|
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
export const mastra = new Mastra({
|
|
80
|
-
storage: new
|
|
80
|
+
storage: new MastraCompositeStore({
|
|
81
81
|
id: "composite",
|
|
82
82
|
default: pgStore,
|
|
83
83
|
domains: {
|
|
@@ -91,14 +91,14 @@ export const mastra = new Mastra({
|
|
|
91
91
|
|
|
92
92
|
## Initialization
|
|
93
93
|
|
|
94
|
-
`
|
|
94
|
+
`MastraCompositeStore` initializes each configured domain independently. When passed to the Mastra class, `init()` is called automatically:
|
|
95
95
|
|
|
96
96
|
```typescript title="src/mastra/index.ts"
|
|
97
|
-
import {
|
|
97
|
+
import { MastraCompositeStore } from "@mastra/core/storage";
|
|
98
98
|
import { MemoryPG, WorkflowsPG, ScoresPG } from "@mastra/pg";
|
|
99
99
|
import { Mastra } from "@mastra/core";
|
|
100
100
|
|
|
101
|
-
const storage = new
|
|
101
|
+
const storage = new MastraCompositeStore({
|
|
102
102
|
id: "composite",
|
|
103
103
|
domains: {
|
|
104
104
|
memory: new MemoryPG({ connectionString: process.env.DATABASE_URL }),
|
|
@@ -115,10 +115,10 @@ export const mastra = new Mastra({
|
|
|
115
115
|
If using storage directly, call `init()` explicitly:
|
|
116
116
|
|
|
117
117
|
```typescript
|
|
118
|
-
import {
|
|
118
|
+
import { MastraCompositeStore } from "@mastra/core/storage";
|
|
119
119
|
import { MemoryPG } from "@mastra/pg";
|
|
120
120
|
|
|
121
|
-
const storage = new
|
|
121
|
+
const storage = new MastraCompositeStore({
|
|
122
122
|
id: "composite",
|
|
123
123
|
domains: {
|
|
124
124
|
memory: new MemoryPG({ connectionString: process.env.DATABASE_URL }),
|
|
@@ -139,11 +139,11 @@ const thread = await memoryStore?.getThreadById({ threadId: "..." });
|
|
|
139
139
|
Use a local database for development while keeping production data in a managed service:
|
|
140
140
|
|
|
141
141
|
```typescript
|
|
142
|
-
import {
|
|
142
|
+
import { MastraCompositeStore } from "@mastra/core/storage";
|
|
143
143
|
import { MemoryPG, WorkflowsPG, ScoresPG } from "@mastra/pg";
|
|
144
144
|
import { MemoryLibSQL } from "@mastra/libsql";
|
|
145
145
|
|
|
146
|
-
const storage = new
|
|
146
|
+
const storage = new MastraCompositeStore({
|
|
147
147
|
id: "composite",
|
|
148
148
|
domains: {
|
|
149
149
|
// Use local SQLite for development, PostgreSQL for production
|
|
@@ -162,11 +162,11 @@ const storage = new MastraStorage({
|
|
|
162
162
|
Use a time-series database for traces while keeping other data in PostgreSQL:
|
|
163
163
|
|
|
164
164
|
```typescript
|
|
165
|
-
import {
|
|
165
|
+
import { MastraCompositeStore } from "@mastra/core/storage";
|
|
166
166
|
import { MemoryPG, WorkflowsPG, ScoresPG } from "@mastra/pg";
|
|
167
167
|
import { ObservabilityStorageClickhouse } from "@mastra/clickhouse";
|
|
168
168
|
|
|
169
|
-
const storage = new
|
|
169
|
+
const storage = new MastraCompositeStore({
|
|
170
170
|
id: "composite",
|
|
171
171
|
domains: {
|
|
172
172
|
memory: new MemoryPG({ connectionString: process.env.DATABASE_URL }),
|
package/dist/index.cjs
CHANGED
|
@@ -15,8 +15,10 @@ var TABLE_ENGINES = {
|
|
|
15
15
|
[storage.TABLE_THREADS]: `ReplacingMergeTree()`,
|
|
16
16
|
[storage.TABLE_SCORERS]: `MergeTree()`,
|
|
17
17
|
[storage.TABLE_RESOURCES]: `ReplacingMergeTree()`,
|
|
18
|
-
//
|
|
19
|
-
|
|
18
|
+
// ReplacingMergeTree(updatedAt) deduplicates rows with the same (traceId, spanId) sorting key,
|
|
19
|
+
// keeping the row with the highest updatedAt value. Combined with ORDER BY (traceId, spanId),
|
|
20
|
+
// this provides eventual uniqueness for the (traceId, spanId) composite key.
|
|
21
|
+
[storage.TABLE_SPANS]: `ReplacingMergeTree(updatedAt)`,
|
|
20
22
|
mastra_agents: `ReplacingMergeTree()`
|
|
21
23
|
};
|
|
22
24
|
var COLUMN_TYPES = {
|
|
@@ -99,6 +101,213 @@ var ClickhouseDB = class extends base.MastraBase {
|
|
|
99
101
|
const columns = await result.json();
|
|
100
102
|
return columns.some((c) => c.name === column);
|
|
101
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Checks if a table exists in the database.
|
|
106
|
+
*/
|
|
107
|
+
async tableExists(tableName) {
|
|
108
|
+
try {
|
|
109
|
+
const result = await this.client.query({
|
|
110
|
+
query: `EXISTS TABLE ${tableName}`,
|
|
111
|
+
format: "JSONEachRow"
|
|
112
|
+
});
|
|
113
|
+
const rows = await result.json();
|
|
114
|
+
return rows[0]?.result === 1;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Gets the sorting key (ORDER BY columns) for a table.
|
|
121
|
+
* Returns null if the table doesn't exist.
|
|
122
|
+
*/
|
|
123
|
+
async getTableSortingKey(tableName) {
|
|
124
|
+
try {
|
|
125
|
+
const result = await this.client.query({
|
|
126
|
+
query: `SELECT sorting_key FROM system.tables WHERE database = currentDatabase() AND name = {tableName:String}`,
|
|
127
|
+
query_params: { tableName },
|
|
128
|
+
format: "JSONEachRow"
|
|
129
|
+
});
|
|
130
|
+
const rows = await result.json();
|
|
131
|
+
return rows[0]?.sorting_key ?? null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Checks if migration is needed for the spans table.
|
|
138
|
+
* Returns information about the current state.
|
|
139
|
+
*/
|
|
140
|
+
async checkSpansMigrationStatus(tableName) {
|
|
141
|
+
const exists = await this.tableExists(tableName);
|
|
142
|
+
if (!exists) {
|
|
143
|
+
return { needsMigration: false, currentSortingKey: null };
|
|
144
|
+
}
|
|
145
|
+
const currentSortingKey = await this.getTableSortingKey(tableName);
|
|
146
|
+
if (!currentSortingKey) {
|
|
147
|
+
return { needsMigration: false, currentSortingKey: null };
|
|
148
|
+
}
|
|
149
|
+
const needsMigration = currentSortingKey.toLowerCase().startsWith("createdat");
|
|
150
|
+
return { needsMigration, currentSortingKey };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Checks for duplicate (traceId, spanId) combinations in the spans table.
|
|
154
|
+
* Returns information about duplicates for logging/CLI purposes.
|
|
155
|
+
*/
|
|
156
|
+
async checkForDuplicateSpans(tableName) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await this.client.query({
|
|
159
|
+
query: `
|
|
160
|
+
SELECT count() as duplicate_count
|
|
161
|
+
FROM (
|
|
162
|
+
SELECT traceId, spanId
|
|
163
|
+
FROM ${tableName}
|
|
164
|
+
GROUP BY traceId, spanId
|
|
165
|
+
HAVING count() > 1
|
|
166
|
+
)
|
|
167
|
+
`,
|
|
168
|
+
format: "JSONEachRow"
|
|
169
|
+
});
|
|
170
|
+
const rows = await result.json();
|
|
171
|
+
const duplicateCount = parseInt(rows[0]?.duplicate_count ?? "0", 10);
|
|
172
|
+
return {
|
|
173
|
+
hasDuplicates: duplicateCount > 0,
|
|
174
|
+
duplicateCount
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
this.logger?.debug?.(`Could not check for duplicates: ${error}`);
|
|
178
|
+
return { hasDuplicates: false, duplicateCount: 0 };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Migrates the spans table from the old sorting key (createdAt, traceId, spanId)
|
|
183
|
+
* to the new sorting key (traceId, spanId) for proper uniqueness enforcement.
|
|
184
|
+
*
|
|
185
|
+
* This migration:
|
|
186
|
+
* 1. Renames the old table to a backup
|
|
187
|
+
* 2. Creates a new table with the correct sorting key
|
|
188
|
+
* 3. Copies all data from the backup to the new table, deduplicating by (traceId, spanId)
|
|
189
|
+
* using priority-based selection:
|
|
190
|
+
* - First, prefer completed spans (those with endedAt set)
|
|
191
|
+
* - Then prefer the most recently updated span (highest updatedAt)
|
|
192
|
+
* - Finally use creation time as tiebreaker (highest createdAt)
|
|
193
|
+
* 4. Drops the backup table
|
|
194
|
+
*
|
|
195
|
+
* The deduplication strategy matches the PostgreSQL migration (PR #12073) to ensure
|
|
196
|
+
* consistent behavior across storage backends.
|
|
197
|
+
*
|
|
198
|
+
* The migration is idempotent - it only runs if the old sorting key is detected.
|
|
199
|
+
*
|
|
200
|
+
* @returns true if migration was performed, false if not needed
|
|
201
|
+
*/
|
|
202
|
+
async migrateSpansTableSortingKey({
|
|
203
|
+
tableName,
|
|
204
|
+
schema
|
|
205
|
+
}) {
|
|
206
|
+
if (tableName !== storage.TABLE_SPANS) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const exists = await this.tableExists(tableName);
|
|
210
|
+
if (!exists) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const currentSortingKey = await this.getTableSortingKey(tableName);
|
|
214
|
+
if (!currentSortingKey) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const needsMigration = currentSortingKey.toLowerCase().startsWith("createdat");
|
|
218
|
+
if (!needsMigration) {
|
|
219
|
+
this.logger?.debug?.(`Spans table already has correct sorting key: ${currentSortingKey}`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
this.logger?.info?.(`Migrating spans table from sorting key "${currentSortingKey}" to "(traceId, spanId)"`);
|
|
223
|
+
const backupTableName = `${tableName}_backup_${Date.now()}`;
|
|
224
|
+
const rowTtl = this.ttl?.[tableName]?.row;
|
|
225
|
+
try {
|
|
226
|
+
await this.client.command({
|
|
227
|
+
query: `RENAME TABLE ${tableName} TO ${backupTableName}`
|
|
228
|
+
});
|
|
229
|
+
const columns = Object.entries(schema).map(([name, def]) => {
|
|
230
|
+
let sqlType = this.getSqlType(def.type);
|
|
231
|
+
let isNullable = def.nullable === true;
|
|
232
|
+
if (tableName === storage.TABLE_SPANS && name === "updatedAt") {
|
|
233
|
+
isNullable = false;
|
|
234
|
+
}
|
|
235
|
+
if (isNullable) {
|
|
236
|
+
sqlType = `Nullable(${sqlType})`;
|
|
237
|
+
}
|
|
238
|
+
const constraints = [];
|
|
239
|
+
if (name === "metadata" && (def.type === "text" || def.type === "jsonb") && isNullable) {
|
|
240
|
+
constraints.push("DEFAULT '{}'");
|
|
241
|
+
}
|
|
242
|
+
const columnTtl = this.ttl?.[tableName]?.columns?.[name];
|
|
243
|
+
return `"${name}" ${sqlType} ${constraints.join(" ")} ${columnTtl ? `TTL toDateTime(${columnTtl.ttlKey ?? "createdAt"}) + INTERVAL ${columnTtl.interval} ${columnTtl.unit}` : ""}`;
|
|
244
|
+
}).join(",\n");
|
|
245
|
+
const createSql = `
|
|
246
|
+
CREATE TABLE ${tableName} (
|
|
247
|
+
${columns}
|
|
248
|
+
)
|
|
249
|
+
ENGINE = ${TABLE_ENGINES[tableName] ?? "MergeTree()"}
|
|
250
|
+
PRIMARY KEY (traceId, spanId)
|
|
251
|
+
ORDER BY (traceId, spanId)
|
|
252
|
+
${rowTtl ? `TTL toDateTime(${rowTtl.ttlKey ?? "createdAt"}) + INTERVAL ${rowTtl.interval} ${rowTtl.unit}` : ""}
|
|
253
|
+
SETTINGS index_granularity = 8192
|
|
254
|
+
`;
|
|
255
|
+
await this.client.command({
|
|
256
|
+
query: createSql
|
|
257
|
+
});
|
|
258
|
+
const describeResult = await this.client.query({
|
|
259
|
+
query: `DESCRIBE TABLE ${backupTableName}`,
|
|
260
|
+
format: "JSONEachRow"
|
|
261
|
+
});
|
|
262
|
+
const backupColumns = await describeResult.json();
|
|
263
|
+
const backupColumnNames = new Set(backupColumns.map((c) => c.name));
|
|
264
|
+
const columnsToInsert = Object.keys(schema).filter((col) => backupColumnNames.has(col));
|
|
265
|
+
const columnList = columnsToInsert.map((c) => `"${c}"`).join(", ");
|
|
266
|
+
const selectExpressions = columnsToInsert.map((c) => c === "updatedAt" ? `COALESCE("updatedAt", "createdAt") as "updatedAt"` : `"${c}"`).join(", ");
|
|
267
|
+
await this.client.command({
|
|
268
|
+
query: `INSERT INTO ${tableName} (${columnList})
|
|
269
|
+
SELECT ${selectExpressions}
|
|
270
|
+
FROM ${backupTableName}
|
|
271
|
+
ORDER BY traceId, spanId,
|
|
272
|
+
(endedAt IS NOT NULL AND endedAt != '') DESC,
|
|
273
|
+
COALESCE(updatedAt, createdAt) DESC,
|
|
274
|
+
createdAt DESC
|
|
275
|
+
LIMIT 1 BY traceId, spanId`
|
|
276
|
+
});
|
|
277
|
+
await this.client.command({
|
|
278
|
+
query: `DROP TABLE ${backupTableName}`
|
|
279
|
+
});
|
|
280
|
+
this.logger?.info?.(`Successfully migrated spans table to new sorting key`);
|
|
281
|
+
return true;
|
|
282
|
+
} catch (error$1) {
|
|
283
|
+
this.logger?.error?.(`Migration failed: ${error$1.message}`);
|
|
284
|
+
try {
|
|
285
|
+
const originalExists = await this.tableExists(tableName);
|
|
286
|
+
const backupExists = await this.tableExists(backupTableName);
|
|
287
|
+
if (!originalExists && backupExists) {
|
|
288
|
+
this.logger?.info?.(`Restoring spans table from backup`);
|
|
289
|
+
await this.client.command({
|
|
290
|
+
query: `RENAME TABLE ${backupTableName} TO ${tableName}`
|
|
291
|
+
});
|
|
292
|
+
} else if (originalExists && backupExists) {
|
|
293
|
+
await this.client.command({
|
|
294
|
+
query: `DROP TABLE IF EXISTS ${backupTableName}`
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
} catch (restoreError) {
|
|
298
|
+
this.logger?.error?.(`Failed to restore from backup: ${restoreError}`);
|
|
299
|
+
}
|
|
300
|
+
throw new error.MastraError(
|
|
301
|
+
{
|
|
302
|
+
id: storage.createStorageErrorId("CLICKHOUSE", "MIGRATE_SPANS_SORTING_KEY", "FAILED"),
|
|
303
|
+
domain: error.ErrorDomain.STORAGE,
|
|
304
|
+
category: error.ErrorCategory.THIRD_PARTY,
|
|
305
|
+
details: { tableName, currentSortingKey }
|
|
306
|
+
},
|
|
307
|
+
error$1
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
102
311
|
getSqlType(type) {
|
|
103
312
|
switch (type) {
|
|
104
313
|
case "text":
|
|
@@ -125,7 +334,10 @@ var ClickhouseDB = class extends base.MastraBase {
|
|
|
125
334
|
try {
|
|
126
335
|
const columns = Object.entries(schema).map(([name, def]) => {
|
|
127
336
|
let sqlType = this.getSqlType(def.type);
|
|
128
|
-
|
|
337
|
+
let isNullable = def.nullable === true;
|
|
338
|
+
if (tableName === storage.TABLE_SPANS && name === "updatedAt") {
|
|
339
|
+
isNullable = false;
|
|
340
|
+
}
|
|
129
341
|
if (isNullable) {
|
|
130
342
|
sqlType = `Nullable(${sqlType})`;
|
|
131
343
|
}
|
|
@@ -155,8 +367,8 @@ var ClickhouseDB = class extends base.MastraBase {
|
|
|
155
367
|
${columns}
|
|
156
368
|
)
|
|
157
369
|
ENGINE = ${TABLE_ENGINES[tableName] ?? "MergeTree()"}
|
|
158
|
-
PRIMARY KEY (
|
|
159
|
-
ORDER BY (
|
|
370
|
+
PRIMARY KEY (traceId, spanId)
|
|
371
|
+
ORDER BY (traceId, spanId)
|
|
160
372
|
${rowTtl ? `TTL toDateTime(${rowTtl.ttlKey ?? "createdAt"}) + INTERVAL ${rowTtl.interval} ${rowTtl.unit}` : ""}
|
|
161
373
|
SETTINGS index_granularity = 8192
|
|
162
374
|
`;
|
|
@@ -302,7 +514,9 @@ var ClickhouseDB = class extends base.MastraBase {
|
|
|
302
514
|
...Object.fromEntries(
|
|
303
515
|
Object.entries(record).map(([key, value]) => [
|
|
304
516
|
key,
|
|
305
|
-
|
|
517
|
+
// Only convert to Date if it's a timestamp column AND value is not null/undefined
|
|
518
|
+
// new Date(null) returns epoch date, not null, so we must check first
|
|
519
|
+
storage.TABLE_SCHEMAS[tableName]?.[key]?.type === "timestamp" && value != null ? new Date(value).toISOString() : value
|
|
306
520
|
])
|
|
307
521
|
)
|
|
308
522
|
}));
|
|
@@ -1644,11 +1858,101 @@ var ObservabilityStorageClickhouse = class extends storage.ObservabilityStorage
|
|
|
1644
1858
|
this.#db = new ClickhouseDB({ client, ttl });
|
|
1645
1859
|
}
|
|
1646
1860
|
async init() {
|
|
1861
|
+
const migrationStatus = await this.#db.checkSpansMigrationStatus(storage.TABLE_SPANS);
|
|
1862
|
+
if (migrationStatus.needsMigration) {
|
|
1863
|
+
const duplicateInfo = await this.#db.checkForDuplicateSpans(storage.TABLE_SPANS);
|
|
1864
|
+
const duplicateMessage = duplicateInfo.hasDuplicates ? `
|
|
1865
|
+
Found ${duplicateInfo.duplicateCount} duplicate (traceId, spanId) combinations that will be removed.
|
|
1866
|
+
` : "";
|
|
1867
|
+
const errorMessage = `
|
|
1868
|
+
===========================================================================
|
|
1869
|
+
MIGRATION REQUIRED: ClickHouse spans table needs sorting key update
|
|
1870
|
+
===========================================================================
|
|
1871
|
+
|
|
1872
|
+
The spans table structure has changed. ClickHouse requires a table recreation
|
|
1873
|
+
to update the sorting key from (traceId) to (traceId, spanId).
|
|
1874
|
+
` + duplicateMessage + `
|
|
1875
|
+
To fix this, run the manual migration command:
|
|
1876
|
+
|
|
1877
|
+
npx mastra migrate
|
|
1878
|
+
|
|
1879
|
+
This command will:
|
|
1880
|
+
1. Create a new table with the correct sorting key
|
|
1881
|
+
2. Copy data from the old table (deduplicating if needed)
|
|
1882
|
+
3. Replace the old table with the new one
|
|
1883
|
+
|
|
1884
|
+
WARNING: This migration involves table recreation and may take significant
|
|
1885
|
+
time for large tables. Please ensure you have a backup before proceeding.
|
|
1886
|
+
===========================================================================
|
|
1887
|
+
`;
|
|
1888
|
+
throw new error.MastraError({
|
|
1889
|
+
id: storage.createStorageErrorId("CLICKHOUSE", "MIGRATION_REQUIRED", "SORTING_KEY_CHANGE"),
|
|
1890
|
+
domain: error.ErrorDomain.STORAGE,
|
|
1891
|
+
category: error.ErrorCategory.USER,
|
|
1892
|
+
text: errorMessage
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1647
1895
|
await this.#db.createTable({ tableName: storage.TABLE_SPANS, schema: storage.SPAN_SCHEMA });
|
|
1648
1896
|
}
|
|
1649
1897
|
async dangerouslyClearAll() {
|
|
1650
1898
|
await this.#db.clearTable({ tableName: storage.TABLE_SPANS });
|
|
1651
1899
|
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Manually run the spans migration to deduplicate and update the sorting key.
|
|
1902
|
+
* This is intended to be called from the CLI when duplicates are detected.
|
|
1903
|
+
*
|
|
1904
|
+
* @returns Migration result with status and details
|
|
1905
|
+
*/
|
|
1906
|
+
async migrateSpans() {
|
|
1907
|
+
const migrationStatus = await this.#db.checkSpansMigrationStatus(storage.TABLE_SPANS);
|
|
1908
|
+
if (!migrationStatus.needsMigration) {
|
|
1909
|
+
return {
|
|
1910
|
+
success: true,
|
|
1911
|
+
alreadyMigrated: true,
|
|
1912
|
+
duplicatesRemoved: 0,
|
|
1913
|
+
message: `Migration already complete. Spans table has correct sorting key.`
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
const duplicateInfo = await this.#db.checkForDuplicateSpans(storage.TABLE_SPANS);
|
|
1917
|
+
if (duplicateInfo.hasDuplicates) {
|
|
1918
|
+
this.logger?.info?.(
|
|
1919
|
+
`Found ${duplicateInfo.duplicateCount} duplicate (traceId, spanId) combinations. Starting migration with deduplication...`
|
|
1920
|
+
);
|
|
1921
|
+
} else {
|
|
1922
|
+
this.logger?.info?.(`No duplicate spans found. Starting sorting key migration...`);
|
|
1923
|
+
}
|
|
1924
|
+
await this.#db.migrateSpansTableSortingKey({ tableName: storage.TABLE_SPANS, schema: storage.SPAN_SCHEMA });
|
|
1925
|
+
return {
|
|
1926
|
+
success: true,
|
|
1927
|
+
alreadyMigrated: false,
|
|
1928
|
+
duplicatesRemoved: duplicateInfo.duplicateCount,
|
|
1929
|
+
message: duplicateInfo.hasDuplicates ? `Migration complete. Removed duplicates and updated sorting key for ${storage.TABLE_SPANS}.` : `Migration complete. Updated sorting key for ${storage.TABLE_SPANS}.`
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Check migration status for the spans table.
|
|
1934
|
+
* Returns information about whether migration is needed.
|
|
1935
|
+
*/
|
|
1936
|
+
async checkSpansMigrationStatus() {
|
|
1937
|
+
const migrationStatus = await this.#db.checkSpansMigrationStatus(storage.TABLE_SPANS);
|
|
1938
|
+
if (!migrationStatus.needsMigration) {
|
|
1939
|
+
return {
|
|
1940
|
+
needsMigration: false,
|
|
1941
|
+
hasDuplicates: false,
|
|
1942
|
+
duplicateCount: 0,
|
|
1943
|
+
constraintExists: true,
|
|
1944
|
+
tableName: storage.TABLE_SPANS
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
const duplicateInfo = await this.#db.checkForDuplicateSpans(storage.TABLE_SPANS);
|
|
1948
|
+
return {
|
|
1949
|
+
needsMigration: true,
|
|
1950
|
+
hasDuplicates: duplicateInfo.hasDuplicates,
|
|
1951
|
+
duplicateCount: duplicateInfo.duplicateCount,
|
|
1952
|
+
constraintExists: false,
|
|
1953
|
+
tableName: storage.TABLE_SPANS
|
|
1954
|
+
};
|
|
1955
|
+
}
|
|
1652
1956
|
get tracingStrategy() {
|
|
1653
1957
|
return {
|
|
1654
1958
|
preferred: "insert-only",
|
|
@@ -1982,10 +2286,10 @@ var ObservabilityStorageClickhouse = class extends storage.ObservabilityStorage
|
|
|
1982
2286
|
conditions.push(`(error IS NOT NULL AND error != '')`);
|
|
1983
2287
|
break;
|
|
1984
2288
|
case storage.TraceStatus.RUNNING:
|
|
1985
|
-
conditions.push(`
|
|
2289
|
+
conditions.push(`endedAt IS NULL AND (error IS NULL OR error = '')`);
|
|
1986
2290
|
break;
|
|
1987
2291
|
case storage.TraceStatus.SUCCESS:
|
|
1988
|
-
conditions.push(`
|
|
2292
|
+
conditions.push(`endedAt IS NOT NULL AND (error IS NULL OR error = '')`);
|
|
1989
2293
|
break;
|
|
1990
2294
|
}
|
|
1991
2295
|
}
|
|
@@ -2014,7 +2318,7 @@ var ObservabilityStorageClickhouse = class extends storage.ObservabilityStorage
|
|
|
2014
2318
|
if (sortField === "endedAt") {
|
|
2015
2319
|
const nullSortValue = sortDirection === "DESC" ? 0 : 1;
|
|
2016
2320
|
const nonNullSortValue = sortDirection === "DESC" ? 1 : 0;
|
|
2017
|
-
orderClause = `ORDER BY CASE WHEN ${sortField} IS NULL
|
|
2321
|
+
orderClause = `ORDER BY CASE WHEN ${sortField} IS NULL THEN ${nullSortValue} ELSE ${nonNullSortValue} END, ${sortField} ${sortDirection}`;
|
|
2018
2322
|
} else {
|
|
2019
2323
|
orderClause = `ORDER BY ${sortField} ${sortDirection}`;
|
|
2020
2324
|
}
|
|
@@ -2920,7 +3224,7 @@ var WorkflowsStorageClickhouse = class extends storage.WorkflowsStorage {
|
|
|
2920
3224
|
var isClientConfig = (config) => {
|
|
2921
3225
|
return "client" in config;
|
|
2922
3226
|
};
|
|
2923
|
-
var ClickhouseStore = class extends storage.
|
|
3227
|
+
var ClickhouseStore = class extends storage.MastraCompositeStore {
|
|
2924
3228
|
db;
|
|
2925
3229
|
ttl = {};
|
|
2926
3230
|
stores;
|