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