How to paginate in Node.js using Azure Table Storage

Imagine you have millions of entities in an Azure table, and you want to page through them displaying 20 entities at a time. Fetching all entities and then dividing them into groups of 20 is hardly the most efficient way to do this. In addition, at the time this document is being written, Azure limits the number of returned entities to 1000 in any given request. Among other things, this keeps developers from accidentally querying for millions of entities.

Azure Table Storage supports continuation tokens to support paging through a large number of entities. You fetch a group entities, and the result contains a continuation token if there are more entities remaining to be fetched. The continuation token is like a bookmark which indicates where the query left off. For example, if you fetched entities 1-20, a continuation token is included to tell you to begin your next query at entity number 21. This is how you can efficiently page through a large number of entities.

The code at the bottom of this post illustrates how to do pagination in Node with Azure tables. Save it in a file called table-storage-pagination-sample.js. It creates a sample database containing totalEntities number of entities. It then queries the results and displays pageSize entities per page.

createTableIfNotExists() simply creates a table and populates it with sample data using a batch insertion. These can include at most 100 entities at a time, so the code loops through this operation until the number of entities in totalEntities has been created. Make totalEntities a multiple of 100 so that totalEntities / 100 is an integer. This indicates how many batches of 100 insertions should be performed.

In http.createServer() we ignore requests for /favicon.ico for the sake of simplicity.

Skip over the if block which handles requests to /nextPage for now. We'll come back to it.

This line defines our initial query:

 var query = azure.TableQuery.select().from(tableName).top(pageSize);

You've probably seen select() and from() before. top() limits the query to pageSize number of entities.

In the parameter list for entitiesQueriedCallback, note that we now include pageContinuation. This object contains the continuation token. If you're curious, set a breakpoint or use console.log() to inspect pageContinuation. You'll see that it contains these properties: tableService, tableQuery, nextPartitionKey, and nextRowKey.

entitiesQueriedCallback loops through the results and uses counter to keep track of how many total results have so far been returned. counter is initialized at the top of http.createServer() because it's also used by the if block which handles requests for /nextPage.

If there are more entities yet to be retrieved, pageContinuation.hasNextPage() will return true. If that's the case, then we emit a link to /nextPage which includes nextPartitionKey and nextRowKey as query strings.

In the if block which handles requests for /nextPage, we use the querystring module to extract nextPartitionKey and nextRowKey from the requested URL. We then create nextPageQuery which contains these keys and pass it to queryEntities().

When there are no more entities to be retrieved, pageContinuation.hasNextPage() returns false and we no longer display a link for the next page.

Here’s the code:

 var http = require('http');
var url = require('url');
var querystring = require('querystring');
var azure = require('azure');
var uuid = require('node-uuid');
var port = process.env.PORT || 1337;  // for Azure or when running locally.

var account = 'your account goes here';
var accountKey = 'your account key goes here';

var tableService = azure.createTableService(account, accountKey);
// Uncomment this to enable logging
//tableService.logger = new azure.Logger(azure.Logger.LogLevels.DEBUG); 
var totalEntities = 100; // Num of entities to create. Make it multiple of 100.
var pageSize = 20;
var tableName = 'largetable'; // Name your table here.

// Create and populate the table. Note that for batch operations,
// the PartitionKey must be the same for all entities.
tableService.createTableIfNotExists(tableName, function (err, created) {
    if (created) {
        var numberOfBatches = parseInt(totalEntities / 100);
        for (var i = 0; i < numberOfBatches; i++) {
            tableService.beginBatch();
            var now = new Date().toString();
            // Batches can include at most 100 entities.
            for (var j = 0; j < 100; j++) { 
                tableService.insertEntity(tableName, {
                    PartitionKey: 'White', RowKey: (i * 100 + j + 1).toString(),
                    Winery: 'Azure Vineyards', Variety: 'Chardonnay', 
                    Vintage: '2003', Notes: uuid(), TastedOn: now
                });
            }
            tableService.commitBatch(function () {
                console.log('Initialized table "' + tableName + 
                    '" with 100 sample entities.');
            });
        }
    }
});

http.createServer(function (req, res) {
    var counter = 0;
    if (req.url === '/favicon.ico') return;

    if (url.parse(req.url).pathname === '/nextPage') {
        var parsedQuerystring = querystring.parse(url.parse(req.url).query);
        var nextPartitionKey = parsedQuerystring.nextPartitionKey;
        var nextRowKey = parsedQuerystring.nextRowKey;
        var nextPageQuery = azure.TableQuery.select().from(tableName)
            .top(pageSize).whereNextKeys(nextPartitionKey, nextRowKey);
        tableService.queryEntities(nextPageQuery, entitiesQueriedCallback);
        return;
    }

    var query = azure.TableQuery.select().from(tableName).top(pageSize);
    tableService.queryEntities(query, entitiesQueriedCallback);

    function entitiesQueriedCallback(error, serverEntities, pageContinuation) {
        res.writeHead(200, { 'Content-Type': 'text/html' });
        if (error === null) {
            for (var index in serverEntities) {
                res.write('RowKey: ' + serverEntities[index].RowKey + 
                    ', Winery: ' + serverEntities[index].Winery + ', Notes: ' + 
                    serverEntities[index].Notes + '<br />');
                counter++;
            }
            res.write('<br />Returned ' + counter + ' entities. <br />');
            if (pageContinuation.hasNextPage()) {
                res.write(
                    '<a href="https://blogs.msdn.com/nextPage?nextPartitionKey=' 
                    + pageContinuation.nextPartitionKey +
                    '&nextRowKey=' + 
                    pageContinuation.nextRowKey + '">Next page</a>');
            }
            res.end();
        } else {
            res.end('Could not query entities: ' + error.code);
            console.log('Could not query entities: ' + error.code);
        }
    }

}).listen(port);

Post made with MarkdownPad.