Query Resources

How to make resource queries with results that are updated in real time

One of the more complex issues when it comes to real time updates, are queries.

Let’s say we have an inventory database which we wish to make searchable. We want to be able to filter the search by fields such as category, stock status, and created date. In case the search result might be large, we also wish to limit how many items we return, letting the user browse through pagination. With pagination, we also might want to support sort order on different fields such as name and numbers in stock.

Let’s see how we can create such queries, while keeping the results updated in real time!

Query

The RES protocol allows for the client to add a query as part of the resource ID1. The query is separated from the resource name by a question mark (?). The format of the query is not enforced, but it is recommended to use URI query strings2 in case the resources are to be accessed through web requests. A resource ID with a query, might look like this:

inventory.items?category=toys&start=0&limit=25

When using ResClient, the query is added as a part of the resource ID, like this:

client.get('inventory.items?category=toys&start=0&limit=25').then(itemList => { /* ... */ });

And when making REST requests, it is similar:

GET http://localhost:8080/api/inventory/items?category=toys&start=0&limit=25

Query resources

If a service makes use of the query provided by the client, to decide what sort of result to send, then we call that resource a query resource3. When the service receives the request, the query part of the resource ID is provided as a property in the request message, and not part of the resource name in the subject.

Listening for a query resource could look like this:

nats.subscribe('get.inventory.items', (req, reply) => {
	let { query } = JSON.parse(req);
	/* ... */
});

Where query will contain the query part, without the initial question mark (?), eg. "category=toys&start=0&limit=25".

Note

A resource may be a query resource3 even if no query string was provided in the request. The service can assume default values (eg. limit=25) for missing query parameters, or if the query is missing entirely from the request.

Normalized query

The only difference in response to a get request4 is that the response should include a query field. The query field should contain a normalized query string, meaning a version of the query that contains the properties that was used by the service, sorted in a predictable order. Let’s look at a couple of examples:

  • a=1&b=2 and b=2&a=1 should be normalized to the same query (eg. a=1&b=2) if property order doesn’t matter
  • cat=toys&not=used should be normalized to cat=toys if not=used is ignored by the service
  • start=10 could be normalized to start=10&limit=25 if limit=25 is the default value.

Note

Query normalization is not required, but it will improve the performance. Resgate uses it to tell if two different looking queries are actually the same.

Example

In code, handling a get request could look like this:

const url = require('url');

function parseQuery(query) {
	// Use url to parse the query string, if we received one
	let q = query ? url.parse('?' + query, true).query : {};
	// Convert start and limit strings to integers
	let start = q.start ? parseInt(q.start, 10) : 0;
	let limit = q.limit ? parseInt(q.limit, 10) : 0;
	// Get category, or default to empty
	let category = q.category || "";

	/* ... query parameter validation ... */

	return { start, limit, category };
}

nats.subscribe('get.inventory.items', (req, reply) => {
	// Decode the JSON request
	let { query } = JSON.parse(req);
	// Parse the query string
	let { start, limit, category } = parseQuery(query);
	// Get collection from another function. Maybe from a DB.
	let collection = getInventoryCollection(start, limit, category);
	// Send a collection response
	nats.publish(reply, JSON.stringify({
		result: {
			collection,
			// Normalize the query in a predictable order
			query: "category=" + category + "&start=" + start + "&limit=" + limit
		}
	}));
});

Note

The service is free to ignore any part of the query, or ignore it entirely. If ignored entirely, the resource is considered an ordinary “non-query” resource, and no query string should be included in the response.

Updating the dynamic result

The challenge comes with events. Depending on the resource, there might be thousands of query combinations that can be affected by a simple event; a created item, or an item that changed category. It might be tricky to determine which queries are affected, and how they are affected. The RES protocol provides two different ways of handling this.

Easy method: Using system reset

The easiest but least performant way of updating a query resource is by sending a system reset event5, as described in Chapter 9 - Recovery. The reset should be sent whenever a query resource might be affected. This will trigger Resgate to make a new get request for each active query, and the result will be used to synchronize the clients.

In code, updating the query resource inventory.items could look like this:

nats.publish("system.reset", JSON.stringify({
	"resources": [ "inventory.items" ]
}));

Note

A system.reset will trigger Resgate to send new get requests for all active queries on that resource.

This can get costly.

A more performant solution is called a query event. It will allow the service to selectively send responses to the queries that are affected.

Let’s learn how it works.

Query event

The query event6 is the service’s way of telling Resgate that something has changed to the underlying data of a query resource. In response, Resgate will send all queries matching the resource back to the service, so that the service can determine how (or if) this change affects each specific query.

Sounds complicated? Let’s look at a simple sequence diagram:

Query Event Sequence Diagram

Subject

When sending a query event, the subject of the message will have the following pattern:

event.<resource>.query

Where <resource> is the resource ID (without query part). The inventory.items in our example.

Event message

Prior to sending the event, the service must generate a temporary inbox subject and subscribe to it. The inbox subject is a NATS subject which Resgate may send its query requests to, for this specific query event.

The event message itself is a JSON object containing the following property:

  • subject - subject which Resgate can send its query requests to

Example

In code, sending a query event could look like this:

// Some event context object containing information
// used to determine if a query might have been affected.
// Eg. Item data and event info.
let eventCtx = { /* ... */ };

// Create a new inbox subject
let subject = nats.createInbox();
// Subscribe to the inbox with a handler for query requests
let sid = nats.subscribe(subject, (req, reply) => {
	handleQueryRequest(nats, req, reply, eventCtx);
});
// Unsubscribe to the temporary inbox after 5 seconds
// This is plenty of time for Resgate to make its requests
setTimeout(() => nats.unsubscribe(sid), 5000);
// Send query event
nats.publish("event.inventory.items.query", JSON.stringify({
	"subject": subject
}));

Query request

Resgate will react to the query event by sending query requests7, using the temporary inbox subject received in the event. It will send one request for each unique query it holds in its cache, for that particular resource.

Request message

The query request’s message is a JSON object that contains a single property:

  • query - normalized query string as received in the response to the get request for the query resource.

The message payload could look like this:

{
	"query": "category=toys&start=0&limit=25"
}

If the services determines that the query might be affected, it can respond by sending the resource again. The result will be used by Resgate to synchronize the clients.

Such response is identical to that of a get request, as described in Chapter 3 - Serving Resources (except that query is not included).

In code, the implementation of the handleQueryRequest method above could look like this:

function handleQueryRequest(nats, req, reply, eventCtx) {
	let { query } = JSON.parse(req);
	// Parse the query. See function in example above.
	let { start, limit, category } = parseQuery(query);
	// Check if query might be affected.
	// Eg. a deleted item won't affect a ?category=toys query
	// unless it belongs to toys.
	if (isQueryPossiblyAffectedByEvent(category, eventCtx)) {
		// If possibly affected, then we get the collection again.
		let collection = getInventoryCollection(start, limit, category);
		// Send the current state of the collection
		nats.publish(reply, JSON.stringify({
			result: { collection }
		}));
	} else {
		// Query is not affected. We tell that with an empty list of events.
		nats.publish(reply, JSON.stringify({
			result: { events: [] }
		}));
	}
}

Query response with events (advanced)

Another option is to respond with a result containing a list of events to be applied to the query resource.

Only use this option if required due to performance reasons. The implementation is much more complex.

Warning

The events in a query response must be based on the state of the underlaying data at the time when the query event was sent.

This means that the service must keep track of the changes made to a query resource’s underlaying data for as long as the temporary subject inbox is being subscribed to.

The result sent on an query request is a JSON object with a single property:

  • events - array of events to apply to the query resource

Each event in the events array is a JSON object with the following properties:

A response to our category=toys&start=0&limit=25 query could look like this:

{
	"result": {
		"events": [
			{
				"event": "remove",
				"data": {
					"idx": 24
				}
			},
			{
				"event": "add",
				"data": {
					"value": { "rid": "inventory.item.42" }
					"idx": 0
				}
			}
		]
	}
}

Note

The query response should always send a result. If the query resource is unaffected, send an empty events array:

nats.publish(reply, JSON.stringify({
    "result": { "events": [] }
}));

Conclusion

Resgate and the RES protocol provides full support for queries, and lets you keep the results updated in real time, just like with ordinary resources.

Updating the query results can either be made quick and simple using system reset. But to get better performance, a more sofisticated method is available through the query event; the implementation may is some cases be tricky, so it is often a good idea to use a RES service library that makes the process easier.

Tip

Are you missing a RES service library for your preferred language?

You are more than welcome to contribute with one!