Paginator tester

How do you get both the rows and page count in a pagination scenario? This app tests different pagination strategies on a large-ish data set.

You need to create a postgres database first. You can supply db params as ENV-s.

npm install
PGPASSWORD=whatever ./bin/generate.js 1000000
PGPASSWORD=whatever ./bin/tester.js

Testing

Testing involves performing 4 different SELECT queries, filtering and sorting on different indexed or non-indexed columns. Each query is executed in 4 variants, trying out different pagination strategies.

Variant 1

Separate SELECT * and SELECT COUNT(*) queries run in parallel. The most basic and often used solution.

SELECT *
FROM data
WHERE condition
ORDER BY field ASC
OFFSET 40
LIMIT 20;

SELECT COUNT(*)
FROM data
WHERE condition;

Programming challenge is moderate. Need to split queries into 2 versions (one with only WHERE-s, other with OFFSET, LIMIT and ORDER).

Variant 2

Add a new column using a window function, so each row of the result set contains the full count. Inspired by https://stackoverflow.com/a/28888696/2405595

SELECT *, count(*) OVER() AS __full_count__
FROM data
WHERE condition
ORDER BY field ASC
OFFSET 40
LIMIT 20;

Programming challenge is easy. You just need to add an extra item in the SELECT statement, and later delete the extra column from the result rows. If you need page count in case of empty result set, it can become dicy, though.

Window function has an impact on query optimizer, making performance very variable (see below).

Variant 3

Same as variant 2, with a wrapper that sets enable_indexscan to OFF. This is an attempt to improve the impact of the window function, which seems to produce bad plans in some cases (index scan where full scan would be faster).

SET SESSION enable_indexscan = OFF;
SELECT *, count(*) OVER() AS __full_count__
FROM data
WHERE condition
ORDER BY field ASC
OFFSET 40
LIMIT 20;
SET SESSION enable_indexscan = ON;

Results of this optimization are mixed.

Variant 4

Add count as an extra row at the end of the result set. To do this, we need to perform two different CTE-s, and also to cast all results to JSON, so that we can combine 2 result sets in a generic maner.

WITH __data__ AS (
    SELECT *
    FROM data
    WHERE condition
)
, __rows__ AS (
    SELECT *
    FROM __data__
    ORDER BY field ASC
    OFFSET 40
    LIMIT 20
)
SELECT row_to_json(__rows__)
FROM __rows__
UNION ALL
SELECT to_json(COUNT(*))
FROM __data__

Programming challenge is impossible if returning native result sets - UNION ALL must match all columns in 2 sets precisely, and how can we do that in a generic manner? If results are cast to JSON (as shown above), then it's fairly easy (presuming a nodejs backend).

Results

Results on 2 million rows, on a strong linux box.

Testing the most basic query, without filters, sort on PK...
    [Page          1]     V1: 0.13 sec     V2: 0.57 sec     V3: 0.97 sec     V4: 0.57 sec
    [Page      21182]     V1: 0.16 sec     V2: 0.61 sec     V3: 1.81 sec     V4: 1.36 sec
    [Page      42363]     V1: 0.20 sec     V2: 0.65 sec     V3: 1.87 sec     V4: 1.42 sec
    [Page      63544]     V1: 0.24 sec     V2: 0.71 sec     V3: 1.88 sec     V4: 1.43 sec
    [Page      84725]     V1: 0.29 sec     V2: 0.79 sec     V3: 1.92 sec     V4: 1.47 sec
    [Page     105906]     V1: 0.33 sec     V2: 0.81 sec     V3: 1.96 sec     V4: 1.50 sec

Testing a simple filter, sort on a non-indexed column
    [Page          1]     V1: 1.30 sec     V2: 0.65 sec     V3: 0.66 sec     V4: 0.65 sec
    [Page        162]     V1: 1.31 sec     V2: 0.67 sec     V3: 0.66 sec     V4: 0.65 sec
    [Page        323]     V1: 1.29 sec     V2: 0.65 sec     V3: 0.65 sec     V4: 0.65 sec
    [Page        484]     V1: 1.28 sec     V2: 0.66 sec     V3: 0.66 sec     V4: 0.65 sec
    [Page        645]     V1: 1.29 sec     V2: 0.66 sec     V3: 0.66 sec     V4: 0.65 sec
    [Page        806]     V1: 1.29 sec     V2: 0.67 sec     V3: 0.66 sec     V4: 0.65 sec

Testing a simple where condition, sort on a non-unique index
    [Page          1]     V1: 0.23 sec     V2: 3.51 sec     V3: 0.90 sec     V4: 0.64 sec
    [Page      14008]     V1: 0.90 sec     V2: 3.55 sec     V3: 1.99 sec     V4: 1.74 sec
    [Page      28015]     V1: 1.52 sec     V2: 3.60 sec     V3: 2.05 sec     V4: 1.79 sec
    [Page      42022]     V1: 2.19 sec     V2: 3.63 sec     V3: 2.06 sec     V4: 1.83 sec
    [Page      56029]     V1: 2.83 sec     V2: 3.68 sec     V3: 2.14 sec     V4: 1.89 sec
    [Page      70036]     V1: 3.48 sec     V2: 3.67 sec     V3: 2.18 sec     V4: 1.91 sec

Testing a complex query, sort on a unique index...
    [Page          1]     V1: 0.54 sec     V2: 3.48 sec     V3: 0.41 sec     V4: 0.40 sec
    [Page          4]     V1: 1.13 sec     V2: 3.48 sec     V3: 0.40 sec     V4: 0.40 sec
    [Page          7]     V1: 0.80 sec     V2: 0.40 sec     V3: 0.41 sec     V4: 0.40 sec
    [Page         10]     V1: 0.80 sec     V2: 0.40 sec     V3: 0.40 sec     V4: 0.40 sec
    [Page         13]     V1: 0.79 sec     V2: 0.40 sec     V3: 0.41 sec     V4: 0.40 sec
    [Page         16]     V1: 0.80 sec     V2: 0.40 sec     V3: 0.41 sec     V4: 0.41 sec

Variant 1 is generally the most stable and predictable of the bunch. It has good performance on low page counts and if indexes are present. It quickly deteriorates as page goes higher and if there are no indexes.

Variants 2 and 3 are performing better than 1 on no indexes. With indexes, performance is very volatile. PG engine (9.6) can't seem to figure out how to optimize these plans properly.

Variant 4 has a constant penalty of JSON serialization and subqueries, but this is mostly visible on low page counts. On higher pages, it is both fast (fastest?) and consistent in its performance.

Thoughts

For a generic application, I think I would still go with Variant 1. It is well understood, and has solid consistent performance in the low page ranges, which is what most queries will be anyway.

The only place where 2 queries instead of one is clearly felt is in Test 2, with non-indexed fields, where it is twice slower than any of the single query methods. But if this becomes a problem, it is a well understood problem that is easy to resolve (add indexes).

My second choice would be Variant 4. It offers advantages of a single query in scenarios without indexes, but without volatility of V2 and V3. On the downside, it needs a very complex query, with 2 CTE-s and all rows cast to JSON. This creates a large surface area for potential problems and edge cases. Can everything be cast to JSON? What if caller wants to have their own CTE-s? Will all tooling know how to handle JSON results? Etc. Better keep it simple, until the performance really starts hurting.