This is the JavaScript implementation of the EventReduce algorithm: update live query results from database change events without re-running the query.
EventReduce is an algorithm to optimize database queries that run multiple times. When you observe the results of a query, every write to the database can change them. The simple solution is to run the full query again after each write, which costs disk-IO and time. EventReduce instead takes the write event and the previous results of the query and calculates the new results on the CPU, without touching the database.
- You run a query and get its results.
- A write event (insert, update or delete) changes some data.
- EventReduce gets the event and the previous results and tells you what to do with the results, for example
insertFirst,removeExistingorinsertAtSortPosition. Only when that is not possible, it tells you to run the query again (runFullQueryAgain).
To decide this quickly, EventReduce describes the event and the previous results with 18 boolean state functions like isInsert(), wasResultsEmpty() or sortParamsChanged().
A binary decision diagram that was created from the truth table of all state combinations and optimized for the fewest and cheapest calls,
then picks one of 16 action functions that changes the results exactly like the database would.
In the browser demo, about 94% of randomly generated events could be optimized this way, and new query results are displayed up to 12 times faster after a write.
EventReduce works with any database and query language, as long as you can tell it which documents match the query and how they are sorted. The sort order must be predictable, which you get by adding the primary key as the last sort field.
- RxDB, the local-first JavaScript database, uses EventReduce for its realtime queries since version 9.0.0. When you subscribe to a query in RxDB, writes to the collection update the results with EventReduce instead of running the query again.
- It is a good fit wherever query results are observed or cached:
- reduce the latency until a change to the database updates your application
- make observing query results more scalable by doing less disk-IO
- reduce the bandwidth when streaming realtime query results from the backend to the client
- update a query cache on writes instead of invalidating it
npm install event-reduce-jsIn the following we will use EventReduce together with minimongo as an example. You can apply the code to any other database.
- First you need some
QueryParamsthat can be used by EventReduce to analyze result-event combinations.
import {
getSortFieldsOfQuery,
ChangeEvent,
MongoQuery,
QueryParams,
calculateActionName,
StateResolveFunctionInput,
runAction
} from 'event-reduce-js';
// some stuff must not be coded by hand but is already in the minimongo library
import {
compileDocumentSelector,
compileSort
} from 'minimongo/src/selector';
// create this helper function that can be used for all queries
export function getQueryParamsByMongoQuery(query: MongoQuery): QueryParams<any> {
const sort = query.sort ? query.sort : ['_id'];
return {
// primary key of the documents.
// Ids can be strings or numbers. They are compared with ===,
// so the id of a change event must have the same type as the id of the document.
primaryKey: '_id',
// (optional) a function that returns the id of a document,
// for ids that are not stored in a single top-level field,
// like compound or nested primary keys. Overrides primaryKey.
// getDocumentId: doc => doc.key.team + '|' + doc.key.num,
// a string[] with all fields that are used in the sorting
sortFields: getSortFieldsOfQuery(query),
skip: query.skip ? query.skip : undefined,
// do not use `query.limit ? … : undefined`, that would turn limit 0 into "no limit"
limit: typeof query.limit === 'number' ? query.limit : undefined,
// a function that returns true if the given document matches the query's selector
queryMatcher: compileDocumentSelector(query.selector),
// a function that can be used as comparator in Array.sort().
// It is not allowed to return 0 for two different documents,
// so the sort must end with the primary key, like in the query below.
sortComparator: compileSort(sort)
};
}
const exampleQuery: MongoQuery = {
selector: {
age: {
$gt: 18
},
gender: 'm'
},
limit: 10,
sort: ['name', '_id']
};
const queryParams = getQueryParamsByMongoQuery(exampleQuery);- Now lets say you have an
changeEventfrom whatever changestream or observable your database provides. You also have thecurrentResultsof the query.
// build the input
const input: StateResolveFunctionInput<DocumentType> = {
// the changeEvent
changeEvent,
// queryParams from above
queryParams,
// array with previous results documents
previousResults: currentResults,
// key->document map with previous results indexed by primary key
// (optional) improves performance
keyDocumentMap: currentDocMap
};
// calculate the correct action name
const action = calculateActionName(input);
if (action === 'runFullQueryAgain') {
/**
* when EventReduce could not optimize the input,
* we get the actionName 'runFullQueryAgain'
* and run the query over the database again
*/
currentResults = await implementation.getRawResults(query);
// also refresh the key-document map
currentDocMap.clear();
currentResults.forEach(doc => currentDocMap.set(doc._id, doc));
} else {
// event-reduce could optimize the event, run the action function
runAction(
action,
queryParams,
changeEvent,
currentResults,
currentDocMap
);
}
// show new results
// notice that for performance resons,
// the functions of event-reduce will mutate the input variables
console.dir(currentResults);calculateActionName() and runAction() work with action names, so the names of all actions end up in your bundle. If bundle size matters, use calculateActionFunction() instead. It returns the action function directly:
import { calculateActionFunction, runFullQueryAgain } from 'event-reduce-js';
const action = calculateActionFunction(input);
if (action === runFullQueryAgain) {
// run the query over the database again
} else {
action(input); // mutates input.previousResults
}The code that generates the truth table and the BDD is exported as event-reduce-js/truth-table-generator.
You only need it to generate your own BDD or to fuzz-test your own query implementation, see DEVELOPER.md.
It needs the optional peer dependencies async-test-util, deep-equal and mingo, which you have to install yourself.
import { generateTruthTable } from 'event-reduce-js/truth-table-generator';For a query with a limit or a skip, EventReduce returns runFullQueryAgain when a row has to move into the result window that is not in the previous results:
the row after the window (when a row leaves a full window) or the last skipped row (when a row is added in front of the window).
The result window buffer avoids most of these full queries: it keeps limitBufferSize rows after the window and skipBufferSize skipped rows before it in memory.
import {
createResultWindowBuffer,
applyChangeEventsToResultWindowBuffer,
getRecommendedResultWindowBufferSize,
getResultWindowBufferQuery,
getResultWindowBufferResults,
resetResultWindowBuffer
} from 'event-reduce-js';
// recommended size: about log2(limit), at most 30, see the docs link below
const size = getRecommendedResultWindowBufferSize(queryParams.limit);
const resultWindowBuffer = createResultWindowBuffer(queryParams, { limitBufferSize: size, skipBufferSize: size });
// run the query with the skip and limit of the buffer
resetResultWindowBuffer(resultWindowBuffer, await runQuery(getResultWindowBufferQuery(resultWindowBuffer, query)));
let results = getResultWindowBufferResults(resultWindowBuffer); // the rows of the original skip and limit
// on new change events
const result = applyChangeEventsToResultWindowBuffer(resultWindowBuffer, changeEvents);
if (result.runFullQueryAgain) {
resetResultWindowBuffer(resultWindowBuffer, await runQuery(getResultWindowBufferQuery(resultWindowBuffer, query)));
results = getResultWindowBufferResults(resultWindowBuffer);
} else if (result.changed) {
results = result.results;
}See Result window buffer for how it works, how to choose the buffer sizes and measurements.
To keep the bundle small, errors only contain a short code:
| Code | Meaning |
|---|---|
EventReduce:RUN_FULL |
The runFullQueryAgain action was called. You have to re-run the query yourself. |
EventReduce:UNK_ACT |
The unknownAction action was called, or runAction() was called with alwaysWrong or with a name that is not an action. This should never happen. |
EventReduce:NEG_LIMIT |
The limit of the QueryParams is negative. A limit of 0 is allowed, the query then returns no documents and the action is always doNothing. |
EventReduce:CMP_ZERO |
The sortComparator returned 0 for two different documents. It must break ties itself, in the same way as the database, for example with the primary key as the last sort field. |
EventReduce:RWB_SIZE |
createResultWindowBuffer() got a buffer size that is negative or not a number, like NaN. |
EventReduce:RWB_OUTDATED |
getResultWindowBufferResults() was called while the result window is not in the buffer: before resetResultWindowBuffer(), or after applyChangeEventToResultWindowBuffer() returned 'runFullQueryAgain' and the query did not run again. |
EventReduce:UNK_STATE |
resolveState() was called with an unknown state name. |
EventReduce:FALSY |
ensureNotFalsy() got a falsy value. |