@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 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
- mqttHistoryTable = null; /// name of the table used for MQTT history, if used
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
- /** Update the TTL for the mqtt_history table
124
- * @param {number} ttlDays - TTL in days
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 updateMqttHistoryTTL(ttlDays) {
127
- // console.log(`updating ttl to ${ttlDays}`);
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, create_table_query FROM system.tables WHERE name = '${this.mqttHistoryTable}' AND database = currentDatabase()`,
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 > 0) {
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 = `CREATE TABLE IF NOT EXISTS ${tableName} (${columns.join(', ')})
189
- ENGINE = MergeTree()
190
- PARTITION BY toYYYYMMDD(Timestamp)
191
- ORDER BY (OrgId, toUnixTimestamp64Micro(Timestamp), TopicParts)
192
- ${ttlExpression}
193
- SETTINGS
194
- index_granularity = 8192,
195
- ttl_only_drop_parts = 1`;
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
- /** Register an MQTT topic for storage in ClickHouse subscribes to the topic
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(dataCache, topic) {
221
- if (!this.mqttHistoryTable) {
222
- throw new Error('ensureMqttHistoryTable must be called before registerMqttTopicForStorage');
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
- // Subscribe to the topic
226
- dataCache.subscribePath(topic, async (value, topicString) => {
227
- const row = {
228
- Timestamp: new Date(),
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 [OrgId, DeviceId, Scope, CapabilityName, CapabilityVersion, ...subPath]
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
- _.forEach(fields, (value, field) => {
271
- if (value.startsWith('+')) {
272
- // it's a wild card, add to selectors
273
- selectors.push(field);
274
- } else {
275
- // it's a constant, filter by it
276
- where.push(`${field} = '${value}'`);
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 ${selectors.join(',')} FROM ${this.mqttHistoryTable} ${
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.2.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": "mocha -w test/*.test.js -b"
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
  }
@@ -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({ topicSelector: `/${org}/+/+/+/+/+` });
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
- await clickhouse.client.command({
50
- query: `DROP TABLE IF EXISTS ${TABLE_NAME}`,
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('ensureMqttHistoryTable', () => {
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(dataCache, '/+org/+device/#');
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(dataCache, '#');
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}/+/+/+/+/+/data2` });
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}/+/+/+/+/+/+/sub2/+` });
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}/+/+/+/+/+/sub1/+/+` });
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}/+/+/+/+/+/data/+/+` });
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}/+/+/+/+/+/willBeNull` });
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/+/+/+/+/data`,
346
+ topicSelector: `/+/device123/+/+/+/data`,
349
347
  limit: 2 * ROWS,
350
348
  });
351
349
  assert.equal(rows.length, ROWS / 1000);
352
- assertTimelimit(ROWS / 10000);
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/+/+/data`,
355
+ topicSelector: `/+/+/+/cap34/+/data`,
358
356
  limit: 2 * ROWS,
359
357
  });
360
358
  assert.equal(rows.length, ROWS / 100);
361
- assertTimelimit(ROWS / 10000);
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: `/+/+/+/+/+/+/data`,
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: `/+/+/+/+/+/+/data`,
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/+/+/+/+/+/data`,
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
  });