@uwdata/mosaic-core 0.9.0 → 0.11.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.
@@ -1,194 +1,269 @@
1
- import { Query, and, asColumn, create, isBetween, scaleTransform, sql } from '@uwdata/mosaic-sql';
2
- import { fnv_hash } from './util/hash.js';
1
+ import {
2
+ Query, and, asColumn, create, isBetween, scaleTransform, sql
3
+ } from '@uwdata/mosaic-sql';
3
4
  import { indexColumns } from './util/index-columns.js';
5
+ import { fnv_hash } from './util/hash.js';
6
+
7
+ const Skip = { skip: true, result: null };
8
+
9
+ /**
10
+ * @typedef {object} DataCubeIndexerOptions
11
+ * @property {string} [schema] Database schema (namespace) in which to write
12
+ * data cube index tables (default 'mosaic').
13
+ * @property {boolean} [options.enabled=true] Flag to enable or disable the
14
+ * indexer. This setting can later be updated via the `enabled` method.
15
+ */
4
16
 
5
17
  /**
6
18
  * Build and query optimized indices ("data cubes") for fast computation of
7
19
  * groupby aggregate queries over compatible client queries and selections.
8
20
  * A data cube contains pre-aggregated data for a Mosaic client, subdivided
9
- * by possible query values from an active view. Indexes are realized as
10
- * as temporary database tables that can be queried for rapid updates.
11
- * Compatible client queries must pull data from the same backing table and
12
- * must consist of only groupby dimensions and supported aggregates.
13
- * Compatible selections must contain an active clause that exposes metadata
14
- * for an interval or point value predicate.
21
+ * by possible query values from an active selection clause. These cubes are
22
+ * realized as as database tables that can be queried for rapid updates.
23
+ *
24
+ * Compatible client queries must consist of only groupby dimensions and
25
+ * supported aggregate functions. Compatible selections must contain an active
26
+ * clause that exposes metadata for an interval or point value predicate.
27
+ *
28
+ * Data cube index tables are written to a dedicated schema (namespace) that
29
+ * can be set using the *schema* constructor option. This schema acts as a
30
+ * persistent cache, and index tables may be used across sessions. The
31
+ * `dropIndexTables` method issues a query to remove *all* tables within
32
+ * this schema. This may be needed if the original tables have updated data,
33
+ * but should be used with care.
15
34
  */
16
35
  export class DataCubeIndexer {
17
36
  /**
18
- *
19
- * @param {import('./Coordinator.js').Coordinator} mc a Mosaic coordinator
20
- * @param {*} options Options hash to configure the data cube indexes and pass selections to the coordinator.
37
+ * Create a new data cube index table manager.
38
+ * @param {import('./Coordinator.js').Coordinator} coordinator A Mosaic coordinator.
39
+ * @param {DataCubeIndexerOptions} [options] Data cube indexer options.
21
40
  */
22
- constructor(mc, { selection, temp = true }) {
23
- /** @type import('./Coordinator.js').Coordinator */
24
- this.mc = mc;
25
- this.selection = selection;
26
- this.temp = temp;
27
- this.reset();
28
- }
29
-
30
- reset() {
31
- this.enabled = false;
32
- this.clients = null;
33
- this.indices = null;
41
+ constructor(coordinator, {
42
+ schema = 'mosaic',
43
+ enabled = true
44
+ } = {}) {
45
+ /** @type {Map<import('./MosaicClient.js').MosaicClient, DataCubeInfo | Skip | null>} */
46
+ this.indexes = new Map();
34
47
  this.active = null;
48
+ this.mc = coordinator;
49
+ this._schema = schema;
50
+ this._enabled = enabled;
35
51
  }
36
52
 
37
- clear() {
38
- if (this.indices) {
39
- this.mc.cancel(Array.from(this.indices.values(), index => index?.result));
40
- this.indices = null;
53
+ /**
54
+ * Set the enabled state of this indexer. If false, any local state is
55
+ * cleared and subsequent index calls will return null until re-enabled.
56
+ * This method has no effect on any index tables already in the database.
57
+ * @param {boolean} [state] The enabled state to set.
58
+ */
59
+ set enabled(state) {
60
+ if (this._enabled !== state) {
61
+ if (!state) this.clear();
62
+ this._enabled = state;
41
63
  }
42
64
  }
43
65
 
44
- index(clients, activeClause) {
45
- if (this.clients !== clients) {
46
- // test client views for compatibility
47
- const cols = Array.from(clients, indexColumns).filter(x => x);
48
- const from = cols[0]?.from;
49
- this.enabled = cols.length && cols.every(c => c.from === from);
50
- this.clients = clients;
51
- this.active = null;
66
+ /**
67
+ * Get the enabled state of this indexer.
68
+ * @returns {boolean} The current enabled state.
69
+ */
70
+ get enabled() {
71
+ return this._enabled;
72
+ }
73
+
74
+ /**
75
+ * Set the database schema used by this indexer. Upon changes, any local
76
+ * state is cleared. This method does _not_ drop any existing data cube
77
+ * tables, use `dropIndexTables` before changing the schema to also remove
78
+ * existing index tables in the database.
79
+ * @param {string} [schema] The schema name to set.
80
+ */
81
+ set schema(schema) {
82
+ if (this._schema !== schema) {
52
83
  this.clear();
84
+ this._schema = schema;
53
85
  }
54
- if (!this.enabled) return false; // client views are not indexable
86
+ }
55
87
 
56
- activeClause = activeClause || this.selection.active;
57
- const { source } = activeClause;
58
- // exit early if indexes already set up for active view
59
- if (source && source === this.active?.source) return true;
88
+ /**
89
+ * Get the database schema used by this indexer.
90
+ * @returns {string} The current schema name.
91
+ */
92
+ get schema() {
93
+ return this._schema;
94
+ }
60
95
 
96
+ /**
97
+ * Issues a query through the coordinator to drop the current index table
98
+ * schema. *All* tables in the schema will be removed and local state is
99
+ * cleared. Call this method if the underlying base tables have been updated,
100
+ * causing derived index tables to become stale and inaccurate. Use this
101
+ * method with care! Once dropped, the schema will be repopulated by future
102
+ * data cube indexer requests.
103
+ * @returns A query result promise.
104
+ */
105
+ dropIndexTables() {
61
106
  this.clear();
62
- if (!source) return false; // nothing to work with
63
- const active = this.active = activeColumns(activeClause);
64
- if (!active) return false; // active selection clause not compatible
65
-
66
- const logger = this.mc.logger();
67
- logger.warn('DATA CUBE INDEX CONSTRUCTION');
68
-
69
- // create a selection with the active source removed
70
- const sel = this.selection.remove(source);
71
-
72
- // generate data cube indices
73
- const indices = this.indices = new Map;
74
- const { mc, temp } = this;
75
- for (const client of clients) {
76
- // determine if client should be skipped due to cross-filtering
77
- if (sel.skip(client, activeClause)) {
78
- indices.set(client, null);
79
- continue;
80
- }
81
-
82
- // generate column definitions for data cube and cube queries
83
- const index = indexColumns(client);
84
-
85
- // skip if client is not indexable
86
- if (!index) {
87
- continue;
88
- }
89
-
90
- // build index table construction query
91
- const query = client.query(sel.predicate(client))
92
- .select({ ...active.columns, ...index.aux })
93
- .groupby(Object.keys(active.columns));
94
-
95
- // ensure active view columns are selected by subqueries
96
- const [subq] = query.subqueries;
97
- if (subq) {
98
- const cols = Object.values(active.columns).flatMap(c => c.columns);
99
- subqueryPushdown(subq, cols);
100
- }
101
-
102
- // push orderby criteria to later cube queries
103
- const order = query.orderby();
104
- query.query.orderby = [];
105
-
106
- const sql = query.toString();
107
- const id = (fnv_hash(sql) >>> 0).toString(16);
108
- const table = `cube_index_${id}`;
109
- const result = mc.exec(create(table, sql, { temp }));
110
- result.catch(e => logger.error(e));
111
- indices.set(client, { table, result, order, ...index });
112
- }
113
-
114
- // index creation successful
115
- return true;
107
+ return this.mc.exec(`DROP SCHEMA IF EXISTS "${this.schema}" CASCADE`);
116
108
  }
117
109
 
118
- async update() {
119
- const { clients, selection, active } = this;
120
- const filter = active.predicate(selection.active.predicate);
121
- return Promise.all(
122
- Array.from(clients).map(client => this.updateClient(client, filter))
123
- );
110
+ /**
111
+ * Clear the cache of data cube index table entries for the current active
112
+ * selection clause. This method does _not_ drop any existing data cube
113
+ * tables. Use `dropIndexTables` to remove existing index tables from the
114
+ * database.
115
+ */
116
+ clear() {
117
+ this.indexes.clear();
118
+ this.active = null;
124
119
  }
125
120
 
126
- async updateClient(client, filter) {
127
- const { mc, indices, selection } = this;
128
-
129
- // if client has no index, perform a standard update
130
- if (!indices.has(client)) {
131
- filter = selection.predicate(client);
132
- return mc.updateClient(client, client.query(filter));
133
- };
134
-
135
- const index = this.indices.get(client);
136
-
137
- // skip update if cross-filtered
138
- if (!index) return;
139
-
140
- // otherwise, query a data cube index table
141
- const { table, dims, aggr, order = [] } = index;
142
- const query = Query
143
- .select(dims, aggr)
144
- .from(table)
145
- .groupby(dims)
146
- .where(filter)
147
- .orderby(order);
148
- return mc.updateClient(client, query);
121
+ /**
122
+ * Return data cube index table information for the active state of a
123
+ * client-selection pair, or null if the client is not indexable. This
124
+ * method has multiple possible side effects, including data cube table
125
+ * generation and updating internal caches.
126
+ * @param {import('./MosaicClient.js').MosaicClient} client A Mosaic client.
127
+ * @param {import('./Selection.js').Selection} selection A Mosaic selection
128
+ * to filter the client by.
129
+ * @param {import('./util/selection-types.js').SelectionClause} activeClause
130
+ * A representative active selection clause for which to (possibly) generate
131
+ * data cube index tables.
132
+ * @returns {DataCubeInfo | Skip | null} Data cube index table
133
+ * information and query generator, or null if the client is not indexable.
134
+ */
135
+ index(client, selection, activeClause) {
136
+ // if not enabled, do nothing
137
+ if (!this.enabled) return null;
138
+
139
+ const { indexes, mc, schema } = this;
140
+ const { source } = activeClause;
141
+
142
+ // if there is no clause source to track, do nothing
143
+ if (!source) return null;
144
+
145
+ // if we have cached active columns, check for updates or exit
146
+ if (this.active) {
147
+ // if the active clause source has changed, clear indexer state
148
+ // this cancels outstanding requests and clears the index cache
149
+ // a clear also sets this.active to null
150
+ if (this.active.source !== source) this.clear();
151
+ // if we've seen this source and it's not indexable, do nothing
152
+ if (this.active?.source === null) return null;
153
+ }
154
+
155
+ // the current active columns cache value
156
+ let { active } = this;
157
+
158
+ // if cached active columns are unset, analyze the active clause
159
+ if (!active) {
160
+ // generate active data cube dimension columns to select over
161
+ // will return an object with null source if not indexable
162
+ this.active = active = activeColumns(activeClause);
163
+ // if the active clause is not indexable, exit now
164
+ if (active.source === null) return null;
165
+ }
166
+
167
+ // if we have cached data cube index table info, return that
168
+ if (indexes.has(client)) {
169
+ return indexes.get(client);
170
+ }
171
+
172
+ // get non-active data cube index table columns
173
+ const indexCols = indexColumns(client);
174
+
175
+ let info;
176
+ if (!indexCols) {
177
+ // if client is not indexable, record null index
178
+ info = null;
179
+ } else if (selection.skip(client, activeClause)) {
180
+ // skip client if untouched by cross-filtering
181
+ info = Skip;
182
+ } else {
183
+ // generate data cube index table
184
+ const filter = selection.remove(source).predicate(client);
185
+ info = dataCubeInfo(client.query(filter), active, indexCols, schema);
186
+ info.result = mc.exec([
187
+ `CREATE SCHEMA IF NOT EXISTS ${schema}`,
188
+ create(info.table, info.create, { temp: false })
189
+ ]);
190
+ info.result.catch(e => mc.logger().error(e));
191
+ }
192
+
193
+ indexes.set(client, info);
194
+ return info;
149
195
  }
150
196
  }
151
197
 
198
+ /**
199
+ * Determines the active data cube dimension columns to select over. Returns
200
+ * an object with the clause source, column definitions, and a predicate
201
+ * generator function for the active dimensions of a data cube index table. If
202
+ * the active clause is not indexable or is missing metadata, this method
203
+ * returns an object with a null source property.
204
+ * @param {import('./util/selection-types.js').SelectionClause} clause The
205
+ * active selection clause to analyze.
206
+ */
152
207
  function activeColumns(clause) {
153
208
  const { source, meta } = clause;
154
- let columns = clause.predicate?.columns;
155
- if (!meta || !columns) return null;
156
- const { type, scales, bin, pixelSize = 1 } = meta;
209
+ const clausePred = clause.predicate;
210
+ const clauseCols = clausePred?.columns;
157
211
  let predicate;
212
+ let columns;
158
213
 
159
- if (type === 'interval' && scales) {
214
+ if (!meta || !clauseCols) {
215
+ return { source: null, columns, predicate };
216
+ }
217
+
218
+ // @ts-ignore
219
+ const { type, scales, bin, pixelSize = 1 } = meta;
220
+
221
+ if (type === 'point') {
222
+ predicate = x => x;
223
+ columns = Object.fromEntries(
224
+ clauseCols.map(col => [`${col}`, asColumn(col)])
225
+ );
226
+ } else if (type === 'interval' && scales) {
160
227
  // determine pixel-level binning
161
228
  const bins = scales.map(s => binInterval(s, pixelSize, bin));
162
229
 
163
- // bail if the scale type is unsupported
164
- if (bins.some(b => b == null)) return null;
165
-
166
- if (bins.length === 1) {
230
+ if (bins.some(b => !b)) {
231
+ // bail if a scale type is unsupported
232
+ } else if (bins.length === 1) {
167
233
  // single interval selection
168
234
  predicate = p => p ? isBetween('active0', p.range.map(bins[0])) : [];
169
- columns = { active0: bins[0](clause.predicate.field) };
235
+ // @ts-ignore
236
+ columns = { active0: bins[0](clausePred.field) };
170
237
  } else {
171
238
  // multiple interval selection
172
239
  predicate = p => p
173
- ? and(p.children.map(({ range }, i) => isBetween(`active${i}`, range.map(bins[i]))))
240
+ ? and(p.children.map(
241
+ ({ range }, i) => isBetween(`active${i}`, range.map(bins[i]))
242
+ ))
174
243
  : [];
175
244
  columns = Object.fromEntries(
176
- clause.predicate.children.map((p, i) => [`active${i}`, bins[i](p.field)])
245
+ // @ts-ignore
246
+ clausePred.children.map((p, i) => [`active${i}`, bins[i](p.field)])
177
247
  );
178
248
  }
179
- } else if (type === 'point') {
180
- predicate = x => x;
181
- columns = Object.fromEntries(columns.map(col => [`${col}`, asColumn(col)]));
182
- } else {
183
- // unsupported selection type
184
- return null;
185
249
  }
186
250
 
187
- return { source, columns, predicate };
251
+ return { source: columns ? source : null, columns, predicate };
188
252
  }
189
253
 
190
254
  const BIN = { ceil: 'CEIL', round: 'ROUND' };
191
255
 
256
+ /**
257
+ * Returns a bin function generator to discretize a selection interval domain.
258
+ * @param {import('./util/selection-types.js').Scale} scale A scale that maps
259
+ * domain values to the output range (typically pixels).
260
+ * @param {number} pixelSize The interactive pixel size. This value indicates
261
+ * the bin step size and may be greater than an actual screen pixel.
262
+ * @param {import('./util/selection-types.js').BinMethod} bin The binning
263
+ * method to apply, one of `floor`, `ceil', or `round`.
264
+ * @returns {(value: any) => import('@uwdata/mosaic-sql').SQLExpression}
265
+ * A bin function generator.
266
+ */
192
267
  function binInterval(scale, pixelSize, bin) {
193
268
  const { type, domain, range, apply, sqlApply } = scaleTransform(scale);
194
269
  if (!apply) return; // unsupported scale type
@@ -201,6 +276,51 @@ function binInterval(scale, pixelSize, bin) {
201
276
  return value => sql`${fn}(${s}(${sqlApply(value)}${d}))::INTEGER`;
202
277
  }
203
278
 
279
+ /**
280
+ * Generate data cube table query information.
281
+ * @param {Query} clientQuery The original client query.
282
+ * @param {*} active Active (selected) column definitions.
283
+ * @param {*} indexCols Data cube index column definitions.
284
+ * @returns {DataCubeInfo}
285
+ */
286
+ function dataCubeInfo(clientQuery, active, indexCols, schema) {
287
+ const { dims, aggr, aux } = indexCols;
288
+ const { columns } = active;
289
+
290
+ // build index table construction query
291
+ const query = clientQuery
292
+ .select({ ...columns, ...aux })
293
+ .groupby(Object.keys(columns));
294
+
295
+ // ensure active clause columns are selected by subqueries
296
+ const [subq] = query.subqueries;
297
+ if (subq) {
298
+ const cols = Object.values(columns).flatMap(c => c.columns);
299
+ subqueryPushdown(subq, cols);
300
+ }
301
+
302
+ // push orderby criteria to later cube queries
303
+ const order = query.orderby();
304
+ query.query.orderby = [];
305
+
306
+ // generate creation query string and hash id
307
+ const create = query.toString();
308
+ const id = (fnv_hash(create) >>> 0).toString(16);
309
+ const table = `${schema}.cube_${id}`;
310
+
311
+ // generate data cube select query
312
+ const select = Query
313
+ .select(dims, aggr)
314
+ .from(table)
315
+ .groupby(dims)
316
+ .orderby(order);
317
+
318
+ return new DataCubeInfo({ id, table, create, active, select });
319
+ }
320
+
321
+ /**
322
+ * Push column selections down to subqueries.
323
+ */
204
324
  function subqueryPushdown(query, cols) {
205
325
  const memo = new Set;
206
326
  const pushdown = q => {
@@ -213,3 +333,46 @@ function subqueryPushdown(query, cols) {
213
333
  };
214
334
  pushdown(query);
215
335
  }
336
+
337
+ /**
338
+ * Metadata and query generator for a data cube index table. This
339
+ * object provides the information needed to generate and query
340
+ * a data cube index table for a client-selection pair relative to
341
+ * a specific active clause and selection state.
342
+ */
343
+ export class DataCubeInfo {
344
+ /**
345
+ * Create a new DataCubeInfo instance.
346
+ * @param {object} options
347
+ */
348
+ constructor({ table, create, active, select } = {}) {
349
+ /** The name of the data cube index table. */
350
+ this.table = table;
351
+ /** The SQL query used to generate the data cube index table. */
352
+ this.create = create;
353
+ /** A result promise returned for the data cube creation query. */
354
+ this.result = null;
355
+ /**
356
+ * Definitions and predicate function for the active columns,
357
+ * which are dynamically filtered by the active clause.
358
+ */
359
+ this.active = active;
360
+ /** Select query (sans where clause) for data cube tables. */
361
+ this.select = select;
362
+ /**
363
+ * Boolean flag indicating a client that should be skipped.
364
+ * This value is always false for completed data cube info.
365
+ */
366
+ this.skip = false;
367
+ }
368
+
369
+ /**
370
+ * Generate a data cube index table query for the given predicate.
371
+ * @param {import('@uwdata/mosaic-sql').SQLExpression} predicate The current
372
+ * active clause predicate.
373
+ * @returns {Query} A data cube index table query.
374
+ */
375
+ query(predicate) {
376
+ return this.select.clone().where(this.active.predicate(predicate));
377
+ }
378
+ }
@@ -94,8 +94,8 @@ export class MosaicClient {
94
94
  * @param {*} error
95
95
  * @returns {this}
96
96
  */
97
- queryError(error) {
98
- console.error(error);
97
+ queryError(error) { // eslint-disable-line no-unused-vars
98
+ // do nothing, the coordinator logs the error
99
99
  return this;
100
100
  }
101
101
 
@@ -103,6 +103,7 @@ export class MosaicClient {
103
103
  * Request the coordinator to execute a query for this client.
104
104
  * If an explicit query is not provided, the client query method will
105
105
  * be called, filtered by the current filterBy selection.
106
+ * @returns {Promise}
106
107
  */
107
108
  requestQuery(query) {
108
109
  const q = query || this.query(this.filterBy?.predicate(this));
@@ -119,10 +120,18 @@ export class MosaicClient {
119
120
  this._requestUpdate();
120
121
  }
121
122
 
123
+ /**
124
+ * Reset this client, initiating new field info and query requests.
125
+ * @returns {Promise}
126
+ */
127
+ initialize() {
128
+ return this._coordinator.initializeClient(this);
129
+ }
130
+
122
131
  /**
123
132
  * Requests a client update.
124
133
  * For example to (re-)render an interface component.
125
- *
134
+ *
126
135
  * @returns {this | Promise<any>}
127
136
  */
128
137
  update() {
@@ -1,5 +1,5 @@
1
1
  import { Query, Ref, isDescribeQuery } from '@uwdata/mosaic-sql';
2
- import { queryResult } from './util/query-result.js';
2
+ import { QueryResult } from './util/query-result.js';
3
3
 
4
4
  function wait(callback) {
5
5
  const method = typeof requestAnimationFrame !== 'undefined'
@@ -108,6 +108,12 @@ function consolidationKey(query, cache) {
108
108
  // @ts-ignore
109
109
  q.$groupby(groupby.map(e => (e instanceof Ref && map[e.column]) || e));
110
110
  }
111
+ // @ts-ignore
112
+ else if (query.select().some(({ expr }) => expr.aggregate)) {
113
+ // if query is an ungrouped aggregate, add an explicit groupby to
114
+ // prevent improper consolidation with non-aggregate queries
115
+ q.$groupby('ALL');
116
+ }
111
117
 
112
118
  // key is just the transformed query as SQL
113
119
  return `${q}`;
@@ -133,7 +139,7 @@ function consolidate(group, enqueue, record) {
133
139
  record: false,
134
140
  query: (group.query = consolidatedQuery(group, record))
135
141
  },
136
- result: (group.result = queryResult())
142
+ result: (group.result = new QueryResult())
137
143
  });
138
144
  } else {
139
145
  // issue queries directly
@@ -244,22 +250,20 @@ async function processResults(group, cache) {
244
250
 
245
251
  /**
246
252
  * Project a consolidated result to a client result
247
- * @param {*} data Consolidated query result, as an Apache Arrow Table
248
- * @param {*} map Column name map as [source, target] pairs
253
+ * @param {import('@uwdata/flechette').Table} data
254
+ * Consolidated query result, as an Arrow Table
255
+ * @param {[string, string][]} map Column name map as [source, target] pairs
249
256
  * @returns the projected Apache Arrow table
250
257
  */
251
258
  function projectResult(data, map) {
252
- const cols = {};
253
- for (const [name, as] of map) {
254
- cols[as] = data.getChild(name);
255
- }
256
- return new data.constructor(cols);
259
+ return data.select(map.map(x => x[0]), map.map(x => x[1]));
257
260
  }
258
261
 
259
262
  /**
260
263
  * Filter a consolidated describe query result to a client result
261
- * @param {*} data Consolidated query result
262
- * @param {*} map Column name map as [source, target] pairs
264
+ * @param {import('@uwdata/flechette').Table} data
265
+ * Consolidated query result, as an Arrow Table
266
+ * @param {[string, string][]} map Column name map as [source, target] pairs
263
267
  * @returns the filtered table data
264
268
  */
265
269
  function filterResult(data, map) {
@@ -1,13 +1,13 @@
1
1
  import { consolidator } from './QueryConsolidator.js';
2
2
  import { lruCache, voidCache } from './util/cache.js';
3
- import { priorityQueue } from './util/priority-queue.js';
4
- import { queryResult } from './util/query-result.js';
3
+ import { PriorityQueue } from './util/priority-queue.js';
4
+ import { QueryResult } from './util/query-result.js';
5
5
 
6
6
  export const Priority = { High: 0, Normal: 1, Low: 2 };
7
7
 
8
8
  export class QueryManager {
9
9
  constructor() {
10
- this.queue = priorityQueue(3);
10
+ this.queue = new PriorityQueue(3);
11
11
  this.db = null;
12
12
  this.clientCache = null;
13
13
  this._logger = null;
@@ -96,7 +96,7 @@ export class QueryManager {
96
96
  }
97
97
 
98
98
  request(request, priority = Priority.Normal) {
99
- const result = queryResult();
99
+ const result = new QueryResult();
100
100
  const entry = { request, result };
101
101
  if (this._consolidate) {
102
102
  this._consolidate.add(entry, priority);
@@ -108,7 +108,15 @@ export class QueryManager {
108
108
 
109
109
  cancel(requests) {
110
110
  const set = new Set(requests);
111
- this.queue.remove(({ result }) => set.has(result));
111
+ if (set.size) {
112
+ this.queue.remove(({ result }) => {
113
+ if (set.has(result)) {
114
+ result.reject('Canceled');
115
+ return true;
116
+ }
117
+ return false;
118
+ });
119
+ }
112
120
  }
113
121
 
114
122
  clear() {