@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 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
- mqttHistoryTable = null; /// name of the table used for MQTT history, if used
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
- /** Update the TTL for the mqtt_history table
124
- * @param {number} ttlDays - TTL in days
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 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
- }
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, create_table_query FROM system.tables WHERE name = '${this.mqttHistoryTable}' AND database = currentDatabase()`,
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 > 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 {
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 = `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`;
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
- /** Register an MQTT topic for storage in ClickHouse subscribes to the topic
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(dataCache, topic) {
221
- if (!this.mqttHistoryTable) {
222
- throw new Error('ensureMqttHistoryTable must be called before registerMqttTopicForStorage');
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
- // 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);
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 [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 = [];
327
+ const path = topicToPath(topicSelector);
268
328
 
269
329
  // 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 {
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(`${field} = '${value}'`);
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 ${selectors.join(',')} FROM ${this.mqttHistoryTable} ${
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.2.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": "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,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({ 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
+ ttlDays: 31
66
+ });
67
+
68
+ await clickhouse.registerMqttTopicForStorage(STANDARD_TOPIC_PATTERN);
53
69
  });
54
70
 
55
- describe('ensureMqttHistoryTable', () => {
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(dataCache, '/+org/+device/#');
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(dataCache, '#');
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}/+/+/+/+/+/data2` });
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}/+/+/+/+/+/+/sub2/+` });
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}/+/+/+/+/+/sub1/+/+` });
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}/+/+/+/+/+/data/+/+` });
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}/+/+/+/+/+/willBeNull` });
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/+/+/+/+/data`,
347
+ topicSelector: `/+/device123/+/+/+/data`,
349
348
  limit: 2 * ROWS,
350
349
  });
351
350
  assert.equal(rows.length, ROWS / 1000);
352
- assertTimelimit(ROWS / 10000);
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/+/+/data`,
356
+ topicSelector: `/+/+/+/cap34/+/data`,
358
357
  limit: 2 * ROWS,
359
358
  });
360
359
  assert.equal(rows.length, ROWS / 100);
361
- assertTimelimit(ROWS / 10000);
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: `/+/+/+/+/+/+/data`,
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: `/+/+/+/+/+/+/data`,
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/+/+/+/+/+/data`,
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
  });