SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'

There are periodic tasks of finding related data by a set of keys, until we gather the required total number of records..

The most 'realistic' example is to output the 20 oldest tasks, listed on the employee roster (for example, within a single department). For various management 'dashboards' with concise summaries of work areas, similar themes are often required.

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'

In this article, we will consider the implementation in PostgreSQL of a 'naive' version of solving such a task, of a 'smarter' and a completely complex algorithm of a 'cycle' in SQL with a condition for exit from found data, which can be useful for both general development and for application in other similar cases.

Let's take a test dataset from the previous article. To ensure that the displayed records do not 'jump' from time to time when sorting values match, we will expand the subject index by adding the primary key.This will also give it uniqueness right away and guarantee us the unambiguity of the sorting order:

CREATE INDEX ON task(owner_id, task_date, id);
-- and we'll delete the old one
DROP INDEX task_owner_id_task_date_idx;

As it sounds, so it is written

First, let’s sketch the simplest query option, passing the IDs of performers as an array for input parameter:

SELECT
  *
FROM
  task
WHERE
  owner_id = ANY('{1,2,4,8,16,32,64,128,256,512}'::integer[])
ORDER BY
  task_date, id
LIMIT 20;

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'
[view on explain.tensor.ru]

It's a bit sad — we ordered only 20 records, but the Index Scan returned us 960 rows, which then had to be sorted... Let's try reading less.

unnest + ARRAY

The first thought that will help us — if we need a total of 20 sorted records, then it's enough to read no more than 20 sorted in the same order for each key. Fortunately, we have a suitable index (owner_id, task_date, id).

We will use the same extraction and 'pivoting to columns' mechanism of the complete record of the table, just as in the last article. We will also apply aggregation into an array using the function ARRAY():

WITH T AS (
  SELECT
    unnest(ARRAY(
      SELECT
        t
      FROM
        task t
      WHERE
        owner_id = unnest
      ORDER BY
        task_date, id
      LIMIT 20 -- we limit here...
    )) r
  FROM
    unnest('{1,2,4,8,16,32,64,128,256,512}'::integer[])
)
SELECT
  (r).* 
FROM
  T
ORDER BY
  (r).task_date, (r).id
LIMIT 20; -- ... and here too

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'
[view on explain.tensor.ru]

Oh, already much better! 40% faster, and reading 4.5 times less data had to be done.

Materialization of table records through CTEI would like to note that in some cases trying to work with record fields immediately after searching for them in a subquery, without 'wrapping' them in a CTE can lead to the 'multiplication' of InitPlan proportional to the number of those same fields:

SELECT
  ((
    SELECT
      t
    FROM
      task t
    WHERE
      owner_id = 1
    ORDER BY
      task_date, id
    LIMIT 1
  ).*);

Result  (cost=4.77..4.78 rows=1 width=16) (actual time=0.063..0.063 rows=1 loops=1)
  Buffers: shared hit=16
  InitPlan 1 (returns $0)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.031..0.032 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t  (cost=0.42..387.57 rows=500 width=48) (actual time=0.030..0.030 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4
  InitPlan 2 (returns $1)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.008..0.009 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t_1  (cost=0.42..387.57 rows=500 width=48) (actual time=0.008..0.008 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4
  InitPlan 3 (returns $2)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.008..0.008 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t_2  (cost=0.42..387.57 rows=500 width=48) (actual time=0.008..0.008 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4"
  InitPlan 4 (returns $3)
    ->  Limit  (cost=0.42..1.19 rows=1 width=48) (actual time=0.009..0.009 rows=1 loops=1)
          Buffers: shared hit=4
          ->  Index Scan using task_owner_id_task_date_id_idx on task t_3  (cost=0.42..387.57 rows=500 width=48) (actual time=0.009..0.009 rows=1 loops=1)
                Index Cond: (owner_id = 1)
                Buffers: shared hit=4

The same record was 'searched' 4 times... Until PostgreSQL 11, this behavior occurred regularly, and the solution was to 'wrap' it in a CTE, which serves as an unconditional boundary for optimizers in these versions.

Recursive accumulator

In the previous version, we read a total of 200 rows for the needed 20. Not 960 anymore, but even less — can we?

Let's try to use the knowledge that we need a total of 20 records. That is, we will iterate the data extraction only until we reach the required amount.

Step 1: initial list

Clearly, our 'target' list of 20 records should start with the 'first' records from one of our owner_id keys. So first, we will find such 'earliest' ones for each key and add them to the list, sorting it in the order we want — (task_date, id).

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'

Step 2: finding the 'next' records

Now, if we take the first record from our list and start 'stepping' further along the index with the owner_id key preserved, all found records are precisely the next ones in the result set. Of course, only until we cross the application key the second record in the list.

If it turns out that we have "crossed" the second record, then the last read record should be added to the list instead of the first one (with the same owner_id), after which the list is sorted again.

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'

This means that we always get a situation where there is no more than one record per key in the list (if the records have run out and we haven't "crossed", the first record simply disappears from the list, and nothing will be added), and they are always sorted in ascending order of the application key (task_date, id).

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'

Step 3: filter and "unroll" records

In some rows of our recursive selection, some records rv are duplicated — first, we find those like "crossing the boundary of the 2nd record in the list", and then substitute them as the 1st in the list. So the first occurrence must be filtered out.

The terrifying final query

WITH RECURSIVE T AS (
  -- #1 : adding the "first" records for each key in the set to the list
  WITH wrap AS ( -- "materializing" records so that accessing fields does not cause InitPlan/SubPlan multiplication
    WITH T AS (
      SELECT
        (
          SELECT
            r
          FROM
            task r
          WHERE
            owner_id = unnest
          ORDER BY
            task_date, id
          LIMIT 1
        ) r
      FROM
        unnest('{1,2,4,8,16,32,64,128,256,512}'::integer[])
    )
    SELECT
      array_agg(r ORDER BY (r).task_date, (r).id) list -- sorting the list in the desired order
    FROM
      T
  )
  SELECT
    list
  , list[1] rv
  , FALSE not_cross
  , 0 size
  FROM
    wrap
UNION ALL
  -- #2 : reading the records of the 1st key in order, until we exceed the record of the 2nd
  SELECT
    CASE
      -- if nothing is found for the 1st record key
      WHEN X._r IS NOT DISTINCT FROM NULL THEN
        T.list[2:] -- removing it from the list
      -- if we do NOT cross the application key of the 2nd record
      WHEN X.not_cross THEN
        T.list -- simply extending the same list without modifications
      -- if there is no 2nd record in the list
      WHEN T.list[2] IS NULL THEN
        -- just returning an empty list
        '{}'
      -- reshuffling the dictionary, removing the 1st record and adding the last found
      ELSE (
        SELECT
          coalesce(T.list[2] || array_agg(r ORDER BY (r).task_date, (r).id), '{}')
        FROM
          unnest(T.list[3:] || X._r) r
      )
    END
  , X._r
  , X.not_cross
  , T.size + X.not_cross::integer
  FROM
    T
  , LATERAL(
      WITH wrap AS ( -- "materializing" record
        SELECT
          CASE
            -- if we indeed "crossed over" the 2nd record
            WHEN NOT T.not_cross
              -- then the required record is the first from the list
              THEN T.list[1]
            ELSE ( -- if not crossed, then the key remained as in the previous record - we base from it
              SELECT
                _r
              FROM
                task _r
              WHERE
                owner_id = (rv).owner_id AND
                (task_date, id) > ((rv).task_date, (rv).id)
              ORDER BY
                task_date, id
              LIMIT 1
            )
          END _r
      )
      SELECT
        _r
      , CASE
          -- if the 2nd record is no longer in the list, but we found something
          WHEN list[2] IS NULL AND _r IS DISTINCT FROM NULL THEN
            TRUE
          ELSE -- found nothing or "crossed over"
            coalesce(((_r).task_date, (_r).id) < ((list[2]).task_date, (list[2]).id), FALSE)
        END not_cross
      FROM
        wrap
    ) X
  WHERE
    T.size < 20 AND -- limiting the count here
    T.list IS DISTINCT FROM '{}' -- or while the list is not exhausted
)
-- #3 : "unfolding" records - order is guaranteed by construction
SELECT
  (rv).* 
FROM
  T
WHERE
  not_cross; -- taking only the "non-crossing" records

SQL HowTo: Writing a while-loop directly in a query, or 'An Elementary Three-Way'
[view on explain.tensor.ru]

Thus, we exchanged 50% of data reads for 20% of execution timeThis means that if you have reasons to believe that reading may take a long time (for example, data is often not cached and needs to be accessed from disk), then this method can help reduce dependency on reading.

In any case, the execution time turned out to be better than in the 'naive' first variant. But which of these 3 options to use is up to you.

Source: habr.com

Buy reliable website hosting with DDoS protection, VPS VDS servers 🔥 Buy reliable website hosting with DDoS protection, VPS VDS servers | ProHoster