@transitive-sdk/clickhouse 0.2.0 → 0.3.1
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/index.js +169 -90
- package/package.json +3 -4
- package/test/clickhouse.test.js +49 -52
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const _ = require('lodash');
|
|
2
2
|
const { createClient } = require('@clickhouse/client');
|
|
3
|
-
const { topicToPath } = require('@transitive-sdk/datacache');
|
|
3
|
+
const { topicToPath, topicMatch } = require('@transitive-sdk/datacache');
|
|
4
4
|
|
|
5
5
|
// Default TTL in days for mqtt_history table
|
|
6
6
|
const DEFAULT_TTL_DAYS = 30;
|
|
@@ -19,11 +19,28 @@ const MULTI_TENANT_SCHEMA = {
|
|
|
19
19
|
]
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/** Given a Topic path (array), return an array of conditions to use in a
|
|
23
|
+
* WHERE clause. */
|
|
24
|
+
const path2where = (path) => {
|
|
25
|
+
const where = [];
|
|
26
|
+
_.forEach(path, (value, i) => {
|
|
27
|
+
if (!['+','#'].includes(value[0])) {
|
|
28
|
+
// it's a constant, filter by it
|
|
29
|
+
where.push(`((TopicParts[${i + 1}]) = '${value}')`);
|
|
30
|
+
// Note that ClickHouse/SQL index starting at 1, not 0
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
return where;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
|
|
22
37
|
/** Singleton ClickHouse client wrapper with multi-tenant table support */
|
|
23
38
|
class ClickHouse {
|
|
24
39
|
|
|
25
40
|
_client = null;
|
|
26
|
-
|
|
41
|
+
|
|
42
|
+
mqttHistoryTable = null; // name of the table used for MQTT history, if used
|
|
43
|
+
topics = {}; // list of topics registered for storage, as object for de-duplication
|
|
27
44
|
|
|
28
45
|
/** Create the client, connecting to Clickhouse */
|
|
29
46
|
init({ url, dbName, user, password } = {}) {
|
|
@@ -120,48 +137,26 @@ class ClickHouse {
|
|
|
120
137
|
});
|
|
121
138
|
}
|
|
122
139
|
|
|
123
|
-
|
|
124
|
-
*
|
|
140
|
+
/* Enable history recording. Ensure the mqtt_history table exists with the
|
|
141
|
+
* correct schema, set dataCache, and subscribe to changes.
|
|
142
|
+
* @param {object} options = {dataCache, tableName, ttlDays}
|
|
125
143
|
*/
|
|
126
|
-
async
|
|
127
|
-
|
|
128
|
-
await this.client.command({
|
|
129
|
-
query: `ALTER TABLE ${this.mqttHistoryTable} MODIFY TTL toDateTime(Timestamp) + toIntervalDay(${ttlDays})`,
|
|
130
|
-
clickhouse_settings: {
|
|
131
|
-
wait_end_of_query: 1,
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
}
|
|
144
|
+
async enableHistory(options) {
|
|
145
|
+
const { dataCache, tableName = 'mqtt_history' } = options;
|
|
135
146
|
|
|
136
|
-
/** Ensure the mqtt_history table exists with the correct schema
|
|
137
|
-
* @param {number} ttlDays - TTL in days (default: 30)
|
|
138
|
-
*/
|
|
139
|
-
async ensureMqttHistoryTable(tableName = 'mqtt_history', ttlDays = DEFAULT_TTL_DAYS) {
|
|
140
147
|
if (this.mqttHistoryTable != tableName) {
|
|
141
148
|
console.warn(`creating or altering mqtt history table ${tableName}`);
|
|
142
149
|
}
|
|
143
150
|
|
|
144
|
-
const ttlExpression = `TTL toDateTime(Timestamp) + toIntervalDay(${ttlDays})`;
|
|
145
|
-
|
|
146
151
|
// Check if table already exists before creating
|
|
147
152
|
const tableExists = await this.client.query({
|
|
148
|
-
query: `SELECT name
|
|
153
|
+
query: `SELECT name FROM system.tables WHERE name = '${this.mqttHistoryTable}' AND database = currentDatabase()`,
|
|
149
154
|
format: 'JSONEachRow'
|
|
150
155
|
});
|
|
151
156
|
const tables = await tableExists.json();
|
|
152
157
|
|
|
153
|
-
if (tables.length
|
|
154
|
-
// table already exists, verify TTL
|
|
155
|
-
const originalCreateQuery = tables[0].create_table_query;
|
|
156
|
-
|
|
157
|
-
// Update table if it differs
|
|
158
|
-
if (!originalCreateQuery.includes(ttlExpression)) {
|
|
159
|
-
await this.updateMqttHistoryTTL(ttlDays);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
} else {
|
|
158
|
+
if (tables.length == 0) {
|
|
163
159
|
// Create the table
|
|
164
|
-
|
|
165
160
|
const columns = [
|
|
166
161
|
// High-precision event time; Delta + ZSTD is a common combo for time-series
|
|
167
162
|
'Timestamp DateTime64(6) CODEC(Delta, ZSTD(1))',
|
|
@@ -170,29 +165,32 @@ class ClickHouse {
|
|
|
170
165
|
// Org/device fields materialized from TopicParts (always computed, not overridable)
|
|
171
166
|
'OrgId LowCardinality(String) MATERIALIZED TopicParts[1] CODEC(ZSTD(1))',
|
|
172
167
|
'DeviceId LowCardinality(String) MATERIALIZED TopicParts[2] CODEC(ZSTD(1))',
|
|
173
|
-
'Scope LowCardinality(String) MATERIALIZED TopicParts[3] CODEC(ZSTD(1))',
|
|
174
|
-
'CapabilityName LowCardinality(String) MATERIALIZED TopicParts[4] CODEC(ZSTD(1))',
|
|
175
|
-
'CapabilityVersion LowCardinality(String) MATERIALIZED TopicParts[5] CODEC(ZSTD(1))',
|
|
168
|
+
// 'Scope LowCardinality(String) MATERIALIZED TopicParts[3] CODEC(ZSTD(1))',
|
|
169
|
+
// 'CapabilityName LowCardinality(String) MATERIALIZED TopicParts[4] CODEC(ZSTD(1))',
|
|
170
|
+
// 'CapabilityVersion LowCardinality(String) MATERIALIZED TopicParts[5] CODEC(ZSTD(1))',
|
|
176
171
|
// Remaining topic segments stored as an array for less-structured suffixes
|
|
177
|
-
'SubTopic Array(String) MATERIALIZED arraySlice(TopicParts, 6) CODEC(ZSTD(1))',
|
|
172
|
+
// 'SubTopic Array(String) MATERIALIZED arraySlice(TopicParts, 6) CODEC(ZSTD(1))',
|
|
178
173
|
// Payload stored as a String, compressed with ZSTD(1). This allows us to
|
|
179
174
|
// store atomic values (still stringified) as opposed to only JSON objects,
|
|
180
175
|
// as the JSON type would require.
|
|
181
176
|
'Payload String CODEC(ZSTD(1))',
|
|
182
177
|
// Bloom filter indexes (shared multi-tenant indexes)
|
|
183
178
|
...MULTI_TENANT_SCHEMA.indexes,
|
|
184
|
-
'INDEX idx_scope (Scope) TYPE bloom_filter(0.01) GRANULARITY 1',
|
|
185
|
-
'INDEX idx_capability (CapabilityName) TYPE bloom_filter(0.01) GRANULARITY 1'
|
|
179
|
+
// 'INDEX idx_scope (Scope) TYPE bloom_filter(0.01) GRANULARITY 1',
|
|
180
|
+
// 'INDEX idx_capability (CapabilityName) TYPE bloom_filter(0.01) GRANULARITY 1'
|
|
181
|
+
'INDEX idx_scope (TopicParts[3]) TYPE bloom_filter(0.01) GRANULARITY 1',
|
|
182
|
+
'INDEX idx_capability (TopicParts[4]) TYPE bloom_filter(0.01) GRANULARITY 1'
|
|
186
183
|
];
|
|
187
184
|
|
|
188
|
-
const query =
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
185
|
+
const query = [
|
|
186
|
+
`CREATE TABLE IF NOT EXISTS default.${tableName} (${columns.join(', ')})`,
|
|
187
|
+
'ENGINE = MergeTree()',
|
|
188
|
+
'PARTITION BY toYYYYMMDD(Timestamp)',
|
|
189
|
+
'ORDER BY (OrgId, toUnixTimestamp64Micro(Timestamp), TopicParts)',
|
|
190
|
+
'SETTINGS',
|
|
191
|
+
' index_granularity = 8192,',
|
|
192
|
+
' ttl_only_drop_parts = 1'
|
|
193
|
+
].join('\n');
|
|
196
194
|
// Note: PRIMARY KEY is not needed because we want it to be the same as
|
|
197
195
|
// ORDER BY, which is what ClickHouse does automatically.
|
|
198
196
|
|
|
@@ -202,45 +200,130 @@ class ClickHouse {
|
|
|
202
200
|
wait_end_of_query: 1,
|
|
203
201
|
}
|
|
204
202
|
});
|
|
203
|
+
|
|
204
|
+
// grant capabilities read-access to their namespace
|
|
205
|
+
await this.client.command({ query:
|
|
206
|
+
`CREATE ROW POLICY IF NOT EXISTS default_capabilities
|
|
207
|
+
ON default.${tableName}
|
|
208
|
+
USING TopicParts[3] = splitByString('_', currentUser())[2]
|
|
209
|
+
AND TopicParts[4] = splitByString('_', currentUser())[3] TO ALL`,
|
|
210
|
+
clickhouse_settings: {
|
|
211
|
+
wait_end_of_query: 1,
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Subscribe to changes to the data cache. On each change, check whether
|
|
216
|
+
// it matches any of the registered topics (this avoid duplicate triggers),
|
|
217
|
+
// then store to ClickHouse with current timestamp.
|
|
218
|
+
dataCache.subscribe((changes) => {
|
|
219
|
+
_.forEach(changes, async (value, topic) => {
|
|
220
|
+
|
|
221
|
+
const matched =
|
|
222
|
+
_.some(this.topics, (_true, selector) => topicMatch(selector, topic));
|
|
223
|
+
|
|
224
|
+
if (!matched) return;
|
|
225
|
+
|
|
226
|
+
const row = {
|
|
227
|
+
Timestamp: new Date(),
|
|
228
|
+
TopicParts: topicToPath(topic), // topic as array
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (value !== null && value !== undefined) {
|
|
232
|
+
row.Payload = JSON.stringify(value);
|
|
233
|
+
} // else: omit
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
await this.client.insert({
|
|
237
|
+
table: this.mqttHistoryTable,
|
|
238
|
+
values: [row],
|
|
239
|
+
format: 'JSONEachRow'
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error('Error inserting MQTT message into ClickHouse:', error.message);
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
|
|
205
248
|
}
|
|
206
249
|
|
|
207
250
|
this.mqttHistoryTable = tableName;
|
|
208
251
|
}
|
|
209
252
|
|
|
210
|
-
|
|
253
|
+
/* Register an MQTT topic for storage in ClickHouse subscribes to the topic
|
|
211
254
|
* and stores incoming messages JSON.stringify'd in a ClickHouse table.
|
|
212
255
|
* Retrieve using `queryMQTTHistory`, or, when quering directly, e.g., from
|
|
213
256
|
* Grafana, use the ClickHouse built-in functionality for parsing JSON, e.g.,
|
|
214
257
|
* after inserting `{ x: 1 }` use
|
|
215
258
|
* `select JSON_VALUE(Payload, '$.x') AS x FROM default.mqtt_history`.
|
|
216
259
|
* NOTE: `ensureMqttHistoryTable` must be called before registering topics
|
|
217
|
-
* @param {Object} dataCache - DataCache instance to use for subscribing
|
|
218
260
|
* @param {string} topic - MQTT topic to register
|
|
219
261
|
*/
|
|
220
|
-
registerMqttTopicForStorage(
|
|
221
|
-
|
|
222
|
-
|
|
262
|
+
async registerMqttTopicForStorage(selector, ttlDays = DEFAULT_TTL_DAYS) {
|
|
263
|
+
this.topics[selector] = true;
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------
|
|
266
|
+
// Set/update TTL for this capability and sub-topic
|
|
267
|
+
|
|
268
|
+
const path = topicToPath(selector);
|
|
269
|
+
|
|
270
|
+
if (path.length < 4) {
|
|
271
|
+
// underspecified, don't set TTL
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// list of TopicParts indices and selected value to use in WHERE statement
|
|
276
|
+
// const topicPartSelectors = [
|
|
277
|
+
// [2, path[2]],
|
|
278
|
+
// [3, path[3]]
|
|
279
|
+
// ];
|
|
280
|
+
|
|
281
|
+
// path.slice(5).forEach((value, i) => topicPartSelectors.push([i + 5, value]));
|
|
282
|
+
|
|
283
|
+
// const where = topicPartSelectors
|
|
284
|
+
// // filter out wildcards
|
|
285
|
+
// .filter(([i, value]) => !['+','#'].includes(value[0]))
|
|
286
|
+
// // map to WHERE conditions
|
|
287
|
+
// .map(([i, value]) => `((TopicParts[${i + 1}]) = '${value}')`);
|
|
288
|
+
const where = path2where(path);
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
if (where.length == 0) {
|
|
292
|
+
// underspecified, don't set TTL
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const tableExists = await this.client.query({
|
|
297
|
+
query: `SELECT name, create_table_query FROM system.tables WHERE name = '${
|
|
298
|
+
this.mqttHistoryTable}'`,
|
|
299
|
+
format: 'JSONEachRow'
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const tables = await tableExists.json();
|
|
303
|
+
const originalCreateQuery = tables[0].create_table_query;
|
|
304
|
+
const matched = originalCreateQuery.match(/TTL (.*) SETTINGS/);
|
|
305
|
+
const ttls = matched ? matched[1].split(',').map(x => x.trim()) : [];
|
|
306
|
+
|
|
307
|
+
const whereStatement = `WHERE ${where.join(' AND ')}`;
|
|
308
|
+
let present = false;
|
|
309
|
+
|
|
310
|
+
// check if TTL statement already present on table definiton
|
|
311
|
+
ttls.forEach((ttl, i) => {
|
|
312
|
+
if (ttl.endsWith(whereStatement)) {
|
|
313
|
+
// condition already present, just replace it to update time
|
|
314
|
+
ttls[i] = newTTLStatement;
|
|
315
|
+
present = true;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (!present) {
|
|
320
|
+
ttls.push(`toDateTime(Timestamp) + toIntervalDay(${ttlDays}) ${whereStatement}`);
|
|
223
321
|
}
|
|
224
322
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
TopicParts: topicToPath(topicString), // topic as array
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
if (value !== null && value !== undefined) {
|
|
233
|
-
row.Payload = JSON.stringify(value);
|
|
234
|
-
} // else: omit
|
|
235
|
-
|
|
236
|
-
try {
|
|
237
|
-
await this.client.insert({
|
|
238
|
-
table: this.mqttHistoryTable,
|
|
239
|
-
values: [row],
|
|
240
|
-
format: 'JSONEachRow'
|
|
241
|
-
});
|
|
242
|
-
} catch (error) {
|
|
243
|
-
console.error('Error inserting MQTT message into ClickHouse:', error.message);
|
|
323
|
+
await this.client.command({
|
|
324
|
+
query: `ALTER TABLE ${this.mqttHistoryTable} MODIFY TTL ${ttls.join(',')}`,
|
|
325
|
+
clickhouse_settings: {
|
|
326
|
+
wait_end_of_query: 1,
|
|
244
327
|
}
|
|
245
328
|
});
|
|
246
329
|
}
|
|
@@ -258,28 +341,18 @@ class ClickHouse {
|
|
|
258
341
|
limit = 1000
|
|
259
342
|
} = options;
|
|
260
343
|
|
|
261
|
-
const
|
|
262
|
-
= topicToPath(topicSelector);
|
|
263
|
-
// store as objects so we can refer to them by column name
|
|
264
|
-
const fields = { OrgId, DeviceId, Scope, CapabilityName, CapabilityVersion };
|
|
265
|
-
|
|
266
|
-
const selectors = ['Payload', 'TopicParts', 'Timestamp', 'SubTopic'];
|
|
267
|
-
const where = [];
|
|
344
|
+
const path = topicToPath(topicSelector);
|
|
268
345
|
|
|
269
346
|
// interpret wildcards
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
// special WHERE conditions for SubPath (if given)
|
|
281
|
-
subPath?.forEach((value, i) =>
|
|
282
|
-
!value.startsWith('+') && where.push(`SubTopic[${i}] = '${value}'`));
|
|
347
|
+
// const where = [];
|
|
348
|
+
// _.forEach(path, (value, i) => {
|
|
349
|
+
// if (!['+','#'].includes(value[0])) {
|
|
350
|
+
// // it's a constant, filter by it
|
|
351
|
+
// where.push(`TopicParts[${i + 1}] = '${value}'`);
|
|
352
|
+
// // Note that ClickHouse/SQL index starting at 1, not 0
|
|
353
|
+
// }
|
|
354
|
+
// });
|
|
355
|
+
const where = path2where(path);
|
|
283
356
|
|
|
284
357
|
since && where.push(`Timestamp >= fromUnixTimestamp64Milli(${since.getTime()})`);
|
|
285
358
|
until && where.push(`Timestamp <= fromUnixTimestamp64Milli(${until.getTime()})`);
|
|
@@ -289,7 +362,7 @@ class ClickHouse {
|
|
|
289
362
|
: '';
|
|
290
363
|
|
|
291
364
|
const result = await this.client.query({
|
|
292
|
-
query: `SELECT
|
|
365
|
+
query: `SELECT Payload,TopicParts,Timestamp FROM default.${this.mqttHistoryTable} ${
|
|
293
366
|
whereStatement} ORDER BY ${orderBy} ${limit ? ` LIMIT ${limit}` : ''}`,
|
|
294
367
|
format: 'JSONEachRow'
|
|
295
368
|
});
|
|
@@ -301,6 +374,12 @@ class ClickHouse {
|
|
|
301
374
|
return rows.map(row => {
|
|
302
375
|
row.Payload = row.Payload ? JSON.parse(row.Payload) : null;
|
|
303
376
|
row.Timestamp = new Date(row.Timestamp);
|
|
377
|
+
row.OrgId = row.TopicParts[0];
|
|
378
|
+
row.DeviceId = row.TopicParts[1];
|
|
379
|
+
row.Scope = row.TopicParts[2];
|
|
380
|
+
row.CapabilityName = row.TopicParts[3];
|
|
381
|
+
row.CapabilityVersion = row.TopicParts[4];
|
|
382
|
+
row.SubTopic = row.TopicParts.slice(5);
|
|
304
383
|
return row;
|
|
305
384
|
});
|
|
306
385
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@transitive-sdk/clickhouse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "A tiny ClickHouse utility class for use in the Transitive framework.",
|
|
5
5
|
"homepage": "https://transitiverobotics.com",
|
|
6
6
|
"repository": {
|
|
@@ -21,14 +21,13 @@
|
|
|
21
21
|
},
|
|
22
22
|
"main": "index.js",
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "
|
|
24
|
+
"test": "node --test --watch"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@clickhouse/client": "^1.12.1",
|
|
28
28
|
"@transitive-sdk/datacache": "^0.14.1"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"dotenv": "^17.2.3"
|
|
32
|
-
"mocha": "^11.7.5"
|
|
31
|
+
"dotenv": "^17.2.3"
|
|
33
32
|
}
|
|
34
33
|
}
|
package/test/clickhouse.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const assert = require('assert');
|
|
2
2
|
const { EventEmitter, once } = require('node:events');
|
|
3
|
+
const { describe, it, before, after, beforeEach } = require('node:test');
|
|
3
4
|
const dotenv = require('dotenv');
|
|
4
5
|
const { DataCache } = require('@transitive-sdk/datacache');
|
|
5
6
|
const { wait } = require('../../index');
|
|
@@ -30,32 +31,44 @@ const interceptInserts = () => {
|
|
|
30
31
|
|
|
31
32
|
/** Query mqtt_history rows for a given org */
|
|
32
33
|
const queryRowsByOrg = async (org, options = {}) =>
|
|
33
|
-
await clickhouse.queryMQTTHistory({
|
|
34
|
+
await clickhouse.queryMQTTHistory({
|
|
35
|
+
topicSelector: `/${org}/+/+/+/+/+`,
|
|
36
|
+
...options
|
|
37
|
+
});
|
|
34
38
|
|
|
35
39
|
/** Generate unique org ID for test isolation */
|
|
36
40
|
const testOrg = (suffix) => `clickhouse_test_${suffix}_${Date.now()}`;
|
|
37
41
|
|
|
38
42
|
|
|
39
43
|
describe('ClickHouse', function() {
|
|
40
|
-
this.timeout(10000);
|
|
44
|
+
// this.timeout(10000);
|
|
41
45
|
|
|
42
46
|
let emitter;
|
|
47
|
+
const dataCache = new DataCache({});
|
|
43
48
|
|
|
44
49
|
before(async () => {
|
|
45
50
|
clickhouse.init({ url: CLICKHOUSE_URL });
|
|
46
51
|
/* Register for `insert` events on ClickHouse client */
|
|
47
52
|
emitter = interceptInserts();
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
for (let query of [
|
|
55
|
+
`DROP TABLE IF EXISTS ${TABLE_NAME}`,
|
|
56
|
+
`DROP ROW POLICY IF EXISTS row_policy ON ${TABLE_NAME}`
|
|
57
|
+
]) await clickhouse.client.command({
|
|
58
|
+
query,
|
|
51
59
|
clickhouse_settings: { wait_end_of_query: 1 }
|
|
52
60
|
});
|
|
61
|
+
|
|
62
|
+
await clickhouse.enableHistory({
|
|
63
|
+
dataCache,
|
|
64
|
+
tableName: TABLE_NAME
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await clickhouse.registerMqttTopicForStorage(STANDARD_TOPIC_PATTERN);
|
|
53
68
|
});
|
|
54
69
|
|
|
55
|
-
describe('
|
|
70
|
+
describe('enableHistory', () => {
|
|
56
71
|
it('should create the mqtt_history table', async () => {
|
|
57
|
-
await clickhouse.ensureMqttHistoryTable(TABLE_NAME, 31);
|
|
58
|
-
|
|
59
72
|
const result = await clickhouse.client.query({
|
|
60
73
|
query: `SELECT name FROM system.tables WHERE name = '${TABLE_NAME}'`,
|
|
61
74
|
format: 'JSONEachRow'
|
|
@@ -67,15 +80,9 @@ describe('ClickHouse', function() {
|
|
|
67
80
|
});
|
|
68
81
|
|
|
69
82
|
describe('registerMqttTopicForStorage', () => {
|
|
70
|
-
before(async () => {
|
|
71
|
-
await clickhouse.ensureMqttHistoryTable(TABLE_NAME, 32);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
83
|
it('should insert MQTT messages into ClickHouse', async () => {
|
|
75
|
-
const dataCache = new DataCache({});
|
|
76
84
|
const org = testOrg('insert');
|
|
77
85
|
|
|
78
|
-
clickhouse.registerMqttTopicForStorage(dataCache, STANDARD_TOPIC_PATTERN);
|
|
79
86
|
dataCache.update([org, 'device1', '@myscope', 'test-cap', '1.0.0', 'data'], 42.5);
|
|
80
87
|
await once(emitter, 'insert');
|
|
81
88
|
|
|
@@ -90,10 +97,8 @@ describe('ClickHouse', function() {
|
|
|
90
97
|
});
|
|
91
98
|
|
|
92
99
|
it('should store string payloads as-is', async () => {
|
|
93
|
-
const dataCache = new DataCache({});
|
|
94
100
|
const org = testOrg('string');
|
|
95
101
|
|
|
96
|
-
clickhouse.registerMqttTopicForStorage(dataCache, STANDARD_TOPIC_PATTERN);
|
|
97
102
|
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'msg'], 'hello world');
|
|
98
103
|
await once(emitter, 'insert');
|
|
99
104
|
|
|
@@ -103,11 +108,9 @@ describe('ClickHouse', function() {
|
|
|
103
108
|
});
|
|
104
109
|
|
|
105
110
|
it('should store null values as NULL (omitted)', async () => {
|
|
106
|
-
const dataCache = new DataCache({});
|
|
107
111
|
const org = testOrg('null');
|
|
108
|
-
// const done = interceptInserts(2);
|
|
109
112
|
|
|
110
|
-
clickhouse.registerMqttTopicForStorage(
|
|
113
|
+
// clickhouse.registerMqttTopicForStorage('/+org/+device/#');
|
|
111
114
|
dataCache.update([org, 'device1', 'data'], 'initial');
|
|
112
115
|
// Small delay to ensure timestamp ordering
|
|
113
116
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
@@ -123,11 +126,9 @@ describe('ClickHouse', function() {
|
|
|
123
126
|
});
|
|
124
127
|
|
|
125
128
|
it('should store object payloads as JSON', async () => {
|
|
126
|
-
const dataCache = new DataCache({});
|
|
127
129
|
const org = testOrg('object');
|
|
128
130
|
const payload = { sensor: 'temp', value: 25.5, nested: { a: 1 } };
|
|
129
131
|
|
|
130
|
-
clickhouse.registerMqttTopicForStorage(dataCache, STANDARD_TOPIC_PATTERN);
|
|
131
132
|
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'readings'], payload);
|
|
132
133
|
await once(emitter, 'insert');
|
|
133
134
|
|
|
@@ -138,10 +139,8 @@ describe('ClickHouse', function() {
|
|
|
138
139
|
});
|
|
139
140
|
|
|
140
141
|
it('should parse nested subtopics correctly', async () => {
|
|
141
|
-
const dataCache = new DataCache({});
|
|
142
142
|
const org = testOrg('subtopic');
|
|
143
143
|
|
|
144
|
-
clickhouse.registerMqttTopicForStorage(dataCache, STANDARD_TOPIC_PATTERN);
|
|
145
144
|
dataCache.update([org, 'device1', '@myscope', 'cap', '2.0.0', 'level1', 'level2'], 'value');
|
|
146
145
|
await once(emitter, 'insert');
|
|
147
146
|
|
|
@@ -153,10 +152,8 @@ describe('ClickHouse', function() {
|
|
|
153
152
|
});
|
|
154
153
|
|
|
155
154
|
it('should handle multiple updates to different subtopics', async () => {
|
|
156
|
-
const dataCache = new DataCache({});
|
|
157
155
|
const org = testOrg('multi');
|
|
158
156
|
|
|
159
|
-
clickhouse.registerMqttTopicForStorage(dataCache, STANDARD_TOPIC_PATTERN);
|
|
160
157
|
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'battery'], 85);
|
|
161
158
|
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'temperature'], 42);
|
|
162
159
|
await once(emitter, 'insert');
|
|
@@ -172,10 +169,8 @@ describe('ClickHouse', function() {
|
|
|
172
169
|
});
|
|
173
170
|
|
|
174
171
|
it('should work with unnamed wildcards', async () => {
|
|
175
|
-
const dataCache = new DataCache({});
|
|
176
172
|
const org = testOrg('unnamed');
|
|
177
173
|
|
|
178
|
-
clickhouse.registerMqttTopicForStorage(dataCache, '/+/+/+/+/+/#');
|
|
179
174
|
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'data'], { x: 1 });
|
|
180
175
|
await once(emitter, 'insert');
|
|
181
176
|
|
|
@@ -184,24 +179,35 @@ describe('ClickHouse', function() {
|
|
|
184
179
|
assert.strictEqual(row.DeviceId, 'device1');
|
|
185
180
|
assert.deepStrictEqual(row.Payload, { x: 1 });
|
|
186
181
|
});
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
it('should avoid duplicates', async () => {
|
|
185
|
+
const org = testOrg('multi');
|
|
186
|
+
|
|
187
|
+
// register multiple overlapping topics, want to see only one insertion
|
|
188
|
+
await clickhouse.registerMqttTopicForStorage('/+org/+/+/+/+/#');
|
|
189
|
+
await clickhouse.registerMqttTopicForStorage('/+/+device/+/+/+/#');
|
|
190
|
+
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'data'], { x: 1 });
|
|
191
|
+
await once(emitter, 'insert');
|
|
192
|
+
const rows = await queryRowsByOrg(org);
|
|
193
|
+
assert.equal(rows.length, 1);
|
|
194
|
+
});
|
|
195
|
+
|
|
187
196
|
});
|
|
188
197
|
|
|
189
198
|
|
|
190
199
|
describe('queryMQTTHistory', () => {
|
|
191
200
|
|
|
192
|
-
const dataCache = new DataCache({});
|
|
193
201
|
const org = testOrg('query');
|
|
194
202
|
|
|
195
203
|
before(async () => {
|
|
196
|
-
await clickhouse.ensureMqttHistoryTable(TABLE_NAME, 33);
|
|
197
|
-
|
|
198
204
|
// clear
|
|
199
205
|
await clickhouse.client.command({
|
|
200
206
|
query: `TRUNCATE TABLE ${TABLE_NAME}`,
|
|
201
207
|
clickhouse_settings: { wait_end_of_query: 1 }
|
|
202
208
|
});
|
|
203
209
|
|
|
204
|
-
clickhouse.registerMqttTopicForStorage(
|
|
210
|
+
await clickhouse.registerMqttTopicForStorage('#');
|
|
205
211
|
dataCache.update([org, 'device1', '@myscope', 'nullcap', '1.0.0', 'willBeNull'], 1234);
|
|
206
212
|
dataCache.update([org, 'device1', '@myscope', 'capdata', '1.0.0', 'data'], { x: 1 });
|
|
207
213
|
dataCache.update([org, 'device1', '@myscope', 'cap', '1.0.0', 'data2'], { y: 2 });
|
|
@@ -237,20 +243,20 @@ describe('ClickHouse', function() {
|
|
|
237
243
|
|
|
238
244
|
it('queries based on sub-topic selectors', async () => {
|
|
239
245
|
const [row] = await clickhouse.queryMQTTHistory({
|
|
240
|
-
topicSelector: `/${org}
|
|
246
|
+
topicSelector: `/${org}/+/+/+/+/data2` });
|
|
241
247
|
assert.strictEqual(row.DeviceId, 'device1');
|
|
242
248
|
assert.deepStrictEqual(row.Payload, { y: 2 });
|
|
243
249
|
});
|
|
244
250
|
|
|
245
251
|
it('queries based on sub-topic selectors with wildcards', async () => {
|
|
246
252
|
const [row] = await clickhouse.queryMQTTHistory({
|
|
247
|
-
topicSelector: `/${org}
|
|
253
|
+
topicSelector: `/${org}/+/+/+/+/+/sub2/+` });
|
|
248
254
|
assert.deepStrictEqual(row.SubTopic[2], 'sub3.1');
|
|
249
255
|
});
|
|
250
256
|
|
|
251
257
|
it('queries based on multiple sub-topic selectors with wildcards', async () => {
|
|
252
258
|
const rows = await clickhouse.queryMQTTHistory({
|
|
253
|
-
topicSelector: `/${org}
|
|
259
|
+
topicSelector: `/${org}/+/+/+/+/sub1/+/+` });
|
|
254
260
|
assert.strictEqual(rows[0].SubTopic.length, 3);
|
|
255
261
|
assert.strictEqual(rows[0].SubTopic[2], 'sub3.1');
|
|
256
262
|
assert.strictEqual(rows[1].SubTopic[2], 'sub3.2');
|
|
@@ -258,7 +264,7 @@ describe('ClickHouse', function() {
|
|
|
258
264
|
|
|
259
265
|
it('returns the history', async () => {
|
|
260
266
|
const rows = await clickhouse.queryMQTTHistory({
|
|
261
|
-
topicSelector: `/${org}
|
|
267
|
+
topicSelector: `/${org}/+/+/+/+/data/+/+` });
|
|
262
268
|
assert.deepStrictEqual(rows.length, 2);
|
|
263
269
|
assert.deepStrictEqual(rows[0].Payload, {x: 1});
|
|
264
270
|
assert.deepStrictEqual(rows[1].Payload, {x: 2});
|
|
@@ -267,23 +273,20 @@ describe('ClickHouse', function() {
|
|
|
267
273
|
|
|
268
274
|
it('handles null values', async () => {
|
|
269
275
|
const rows = await clickhouse.queryMQTTHistory({
|
|
270
|
-
topicSelector: `/${org}
|
|
276
|
+
topicSelector: `/${org}/+/+/+/+/willBeNull` });
|
|
271
277
|
assert.strictEqual(rows.at(-1).Payload, null);
|
|
272
278
|
});
|
|
273
279
|
});
|
|
274
280
|
|
|
275
281
|
/** Test performance of the table (index). */
|
|
276
|
-
describe('performance', () => {
|
|
282
|
+
describe('performance', {timeout: 10000}, () => {
|
|
277
283
|
|
|
278
284
|
const ROWS = 1_000_000; // number of rows to insert (mock)
|
|
279
285
|
// time gap between inserted values (to stretch over several partitions):
|
|
280
286
|
const GAP = 1_000;
|
|
281
|
-
const dataCache = new DataCache({});
|
|
282
287
|
const now = Date.now();
|
|
283
288
|
|
|
284
289
|
before(async () => {
|
|
285
|
-
await clickhouse.ensureMqttHistoryTable(TABLE_NAME, 33);
|
|
286
|
-
|
|
287
290
|
// clear
|
|
288
291
|
await clickhouse.client.exec({
|
|
289
292
|
query: `TRUNCATE TABLE ${TABLE_NAME}`,
|
|
@@ -311,14 +314,9 @@ describe('ClickHouse', function() {
|
|
|
311
314
|
|
|
312
315
|
let start;
|
|
313
316
|
beforeEach(() => {
|
|
314
|
-
console.time('elapsed');
|
|
315
317
|
start = performance.now();
|
|
316
318
|
});
|
|
317
319
|
|
|
318
|
-
afterEach(() => {
|
|
319
|
-
console.timeEnd('elapsed');
|
|
320
|
-
});
|
|
321
|
-
|
|
322
320
|
/** Assert that no more than limit ms have passed since start of test case. */
|
|
323
321
|
const assertTimelimit = (limit) => {
|
|
324
322
|
assert(performance.now() - start < limit, `Less than ${limit} ms`);
|
|
@@ -345,25 +343,25 @@ describe('ClickHouse', function() {
|
|
|
345
343
|
|
|
346
344
|
it('quickly filters by DeviceId', async () => {
|
|
347
345
|
const rows = await clickhouse.queryMQTTHistory({
|
|
348
|
-
topicSelector: `/+/device123
|
|
346
|
+
topicSelector: `/+/device123/+/+/+/data`,
|
|
349
347
|
limit: 2 * ROWS,
|
|
350
348
|
});
|
|
351
349
|
assert.equal(rows.length, ROWS / 1000);
|
|
352
|
-
assertTimelimit(ROWS /
|
|
350
|
+
assertTimelimit(ROWS / 1000);
|
|
353
351
|
});
|
|
354
352
|
|
|
355
353
|
it('quickly filters by CapabilityName', async () => {
|
|
356
354
|
const rows = await clickhouse.queryMQTTHistory({
|
|
357
|
-
topicSelector: `/+/+/+/cap34
|
|
355
|
+
topicSelector: `/+/+/+/cap34/+/data`,
|
|
358
356
|
limit: 2 * ROWS,
|
|
359
357
|
});
|
|
360
358
|
assert.equal(rows.length, ROWS / 100);
|
|
361
|
-
assertTimelimit(ROWS /
|
|
359
|
+
assertTimelimit(ROWS / 1000);
|
|
362
360
|
});
|
|
363
361
|
|
|
364
362
|
it('quickly filters by time: since', async () => {
|
|
365
363
|
const rows = await clickhouse.queryMQTTHistory({
|
|
366
|
-
topicSelector:
|
|
364
|
+
topicSelector: `/+/+/+/+/+/data`,
|
|
367
365
|
since: new Date(now + (ROWS - 400) * GAP),
|
|
368
366
|
limit: 2 * ROWS,
|
|
369
367
|
});
|
|
@@ -373,7 +371,7 @@ describe('ClickHouse', function() {
|
|
|
373
371
|
|
|
374
372
|
it('quickly filters by time: until', async () => {
|
|
375
373
|
const rows = await clickhouse.queryMQTTHistory({
|
|
376
|
-
topicSelector:
|
|
374
|
+
topicSelector: `/+/+/+/+/+/data`,
|
|
377
375
|
until: new Date(now + 400 * GAP),
|
|
378
376
|
limit: 2 * ROWS,
|
|
379
377
|
});
|
|
@@ -383,14 +381,13 @@ describe('ClickHouse', function() {
|
|
|
383
381
|
|
|
384
382
|
it('quickly filters by org and time: since', async () => {
|
|
385
383
|
const rows = await clickhouse.queryMQTTHistory({
|
|
386
|
-
topicSelector: `/org23
|
|
384
|
+
topicSelector: `/org23/+/+/+/+/data`,
|
|
387
385
|
since: new Date(now + (ROWS - 400) * GAP),
|
|
388
386
|
limit: 2 * ROWS,
|
|
389
387
|
});
|
|
390
388
|
assert.equal(rows.length, 8);
|
|
391
389
|
assertTimelimit(ROWS / 10000);
|
|
392
390
|
});
|
|
393
|
-
|
|
394
391
|
});
|
|
395
392
|
|
|
396
393
|
});
|