0
\$\begingroup\$

I have an API where users can retrieve a list of "venues".

  • Every venue is a profile, but not every profile is a venue
  • Venues can be "parents" of other venues (think hotel - floor - room)
  • When a latitude and longitude is given, the list should be sorted by distance
  • When a latitude and longitude is not given, the list should be sorted alphabetically, by venue name (located in the profiles.profiles table)
  • Clients use key-based pagination (i.e. a fixed number of results is returned per page. to get the next page, the client should include the first id of the next page - hence LIMIT env.RESULT_PAGE_SIZE + 1).

I have constructed a query, utilizing multiple CTEs and "short circuits" (false AND condition). However, I'm still quite new to CTEs and would appreciate some feedback on the performance of my query. I have also attached the query plan and would appreciate a step-by-step explanation of how to read it and what conclusions to draw.

Context

dansdata=# \d profiles.profiles
                          Table "profiles.profiles"
     Column      |           Type           | Collation | Nullable | Default 
-----------------+--------------------------+-----------+----------+---------
 id              | text                     |           | not null | 
 type            | profiles.profile_type    |           | not null | 
 name            | text                     |           | not null | 
 description     | text                     |           | not null | 
 created_at      | timestamp with time zone |           | not null | now()
 cover_image_id  | text                     |           |          | 
 poster_image_id | text                     |           |          | 
 square_image_id | text                     |           |          | 
Indexes:
    "profiles_pkey" PRIMARY KEY, btree (id)
    "profiles_cover_image_id_idx" btree (cover_image_id)
    "profiles_name_idx" gin (name gin_trgm_ops)
    "profiles_poster_image_id_idx" btree (poster_image_id)
    "profiles_square_image_id_idx" btree (square_image_id)
Foreign-key constraints:
    "profiles_cover_image_id_fkey" FOREIGN KEY (cover_image_id) REFERENCES storage.images(id) ON UPDATE CASCADE ON DELETE SET NULL
    "profiles_poster_image_id_fkey" FOREIGN KEY (poster_image_id) REFERENCES storage.images(id) ON UPDATE CASCADE ON DELETE SET NULL
    "profiles_square_image_id_fkey" FOREIGN KEY (square_image_id) REFERENCES storage.images(id) ON UPDATE CASCADE ON DELETE SET NULL
Referenced by:
    TABLE "events.event_slot_participants" CONSTRAINT "event_slot_participants_profile_id_fkey" FOREIGN KEY (profile_id) REFERENCES profiles.profiles(id) ON UPDATE CASCADE ON DELETE RESTRICT
    TABLE "profiles.individuals" CONSTRAINT "individuals_profile_id_fkey" FOREIGN KEY (profile_id) REFERENCES profiles.profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
    TABLE "profiles.organizations" CONSTRAINT "organizations_profile_id_fkey" FOREIGN KEY (profile_id) REFERENCES profiles.profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
    TABLE "profiles.profile_links" CONSTRAINT "profile_links_profile_id_fkey" FOREIGN KEY (profile_id) REFERENCES profiles.profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
    TABLE "profiles.venues" CONSTRAINT "venues_profile_id_fkey" FOREIGN KEY (profile_id) REFERENCES profiles.profiles(id) ON UPDATE CASCADE ON DELETE CASCADE

dansdata=# \d profiles.venues
                           Table "profiles.venues"
       Column       |         Type          | Collation | Nullable | Default 
--------------------+-----------------------+-----------+----------+---------
 profile_id         | text                  |           | not null | 
 parent_id          | text                  |           |          | 
 coords             | geography(Point,4326) |           | not null | 
 permanently_closed | boolean               |           | not null | false
Indexes:
    "venues_pkey" PRIMARY KEY, btree (profile_id)
    "venues_coords_idx" gist (coords)
Foreign-key constraints:
    "venues_parent_id_fkey" FOREIGN KEY (parent_id) REFERENCES profiles.venues(profile_id) ON UPDATE CASCADE ON DELETE SET NULL
    "venues_profile_id_fkey" FOREIGN KEY (profile_id) REFERENCES profiles.profiles(id) ON UPDATE CASCADE ON DELETE CASCADE
Referenced by:
    TABLE "events.event_slots" CONSTRAINT "event_slots_venue_id_fkey" FOREIGN KEY (venue_id) REFERENCES profiles.venues(profile_id) ON UPDATE CASCADE ON DELETE RESTRICT
    TABLE "profiles.venues" CONSTRAINT "venues_parent_id_fkey" FOREIGN KEY (parent_id) REFERENCES profiles.venues(profile_id) ON UPDATE CASCADE ON DELETE SET NULL
Triggers:
    enforce_non_circular_hierarchy_insert BEFORE INSERT ON profiles.venues FOR EACH ROW WHEN (new.parent_id IS NOT NULL) EXECUTE FUNCTION profiles.trigger_func_venue_enforce_non_circular_hierarchy()
    enforce_non_circular_hierarchy_update BEFORE UPDATE ON profiles.venues FOR EACH ROW WHEN (new.parent_id IS NOT NULL AND (old.parent_id IS DISTINCT FROM new.parent_id OR new.profile_id IS DISTINCT FROM old.profile_id)) EXECUTE FUNCTION profiles.trigger_func_venue_enforce_non_circular_hierarchy()
    refresh_venue_parents AFTER INSERT OR DELETE OR UPDATE OF parent_id OR TRUNCATE ON profiles.venues FOR EACH STATEMENT EXECUTE FUNCTION profiles.trigger_func_refresh_venue_parents()

dansdata=# \d profiles.venue_parents
      Materialized view "profiles.venue_parents"
  Column   |  Type   | Collation | Nullable | Default 
-----------+---------+-----------+----------+---------
 child_id  | text    |           |          | 
 parent_id | text    |           |          | 
 distance  | integer |           |          | 
Indexes:
    "venue_distance_idx" btree (distance)
    "venue_parents_child_idx" btree (child_id)
    "venue_parents_child_parent_idx" UNIQUE, btree (child_id, parent_id)
    "venue_parents_parent_idx" btree (parent_id)
CREATE MATERIALIZED VIEW "profiles"."venue_parents"(child_id, parent_id, distance) AS (
  WITH RECURSIVE parent_query AS (
    SELECT
      profile_id AS child_id,
      parent_id,
      1 AS distance
    FROM profiles.venues
    WHERE parent_id IS NOT NULL
    UNION ALL
      SELECT
        parent_query.child_id AS child_id,
        profiles.venues.parent_id AS parent_id,
        parent_query.distance + 1 AS distance
      FROM parent_query, profiles.venues
      WHERE profiles.venues.parent_id IS NOT NULL
        AND profiles.venues.profile_id = parent_query.parent_id
  )
  SELECT child_id, parent_id, distance
  FROM parent_query
);
CREATE UNIQUE INDEX "venue_parents_child_parent_idx" ON "profiles"."venue_parents"(child_id, parent_id);
CREATE INDEX "venue_parents_child_idx" ON "profiles"."venue_parents"(child_id);
CREATE INDEX "venue_parents_parent_idx" ON "profiles"."venue_parents"(parent_id);
CREATE INDEX "venue_distance_idx" ON "profiles"."venue_parents"(distance);

Query Under Review

WITH
  root_nodes(profile_id) AS (
    SELECT profile_id
    FROM profiles.venues
    WHERE profile_id NOT IN (
      SELECT child_id
      FROM profiles.venue_parents
    )
  ),
  leaf_nodes(profile_id) AS (
    SELECT profile_id
    FROM profiles.venues
    WHERE profile_id NOT IN (
      SELECT parent_id
      FROM profiles.venue_parents
    )
  ),
  joined AS (
    SELECT
      p.*,
      t.*
    FROM (
      (
        SELECT
          *,
          -1 AS distance
        FROM profiles.venues
        WHERE ${filterModel.near && 1}::int IS NULL
      )
      UNION ALL
      (
        SELECT
          *,
          ST_DistanceSphere(
            coords::geometry,
            ST_MakePoint(${filterModel.near?.lng ?? 0}, ${filterModel.near?.lat ?? 0})
          ) AS distance
        FROM profiles.venues
        WHERE ${filterModel.near && 1}::int IS NOT NULL
      ) 
    ) AS t
    INNER JOIN profiles.profiles p
      ON p.id = t.profile_id
  ),
  sorted AS (
    SELECT
      *
    FROM joined
    ORDER BY
        distance ASC,
        name ASC,
        id ASC
  ),
  enumerated AS (
    SELECT
      *,
      ROW_NUMBER() OVER () AS index
    FROM sorted
  )
SELECT id AS "profileId"
FROM enumerated
WHERE (
  ${filterModel.includePermanentlyClosed}
  OR permanently_closed = FALSE          
) AND (
  ${filterModel.level === "any"}
  OR (
    ${filterModel.level === "root"}
    AND profile_id IN (SELECT * FROM root_nodes)
  )
  OR (
    ${filterModel.level === "leaf"}
    AND profile_id IN (SELECT * FROM leaf_nodes)
  )
) AND (
  index >= COALESCE(
    (
      SELECT index
      FROM enumerated
      WHERE id = ${filterModel.pageKey}
    ),
    0
  )
)
ORDER BY index
LIMIT ${env.RESULT_PAGE_SIZE + 1}

Example with actual values

    ┌──────────────────────────┬───────────────────┬────────────────────┬────────┐
    │         (index)          │        lat        │        lng         │ Values │
    ├──────────────────────────┼───────────────────┼────────────────────┼────────┤
    │  env.RESULT_PAGE_SIZE.   │                   │                    │   30   │
    │          level           │                   │                    │ 'any'  │
    │ includePermanentlyClosed │                   │                    │  true  │
    │           near           │ 58.41616195587502 │ 15.625933242341707 │        │
    └──────────────────────────┴───────────────────┴────────────────────┴────────┘
WITH
  root_nodes(profile_id) AS (
    SELECT profile_id
    FROM profiles.venues
    WHERE profile_id NOT IN (
      SELECT child_id
      FROM profiles.venue_parents
    )
  ),
  leaf_nodes(profile_id) AS (
    SELECT profile_id
    FROM profiles.venues
    WHERE profile_id NOT IN (
      SELECT parent_id
      FROM profiles.venue_parents
    )
  ),
  joined AS (
    SELECT
      p.*,
      t.*
    FROM (
      (
        SELECT
          *,
          -1 AS distance
        FROM profiles.venues
        WHERE 1::int IS NULL
      )
      UNION ALL
      (
        SELECT
          *,
          ST_DistanceSphere(
            coords::geometry,
            ST_MakePoint(15.625933242341707, 58.41616195587502)
          ) AS distance
        FROM profiles.venues
        WHERE 1::int IS NOT NULL
      ) 
    ) AS t
    INNER JOIN profiles.profiles p
      ON p.id = t.profile_id
  ),
  sorted AS (
    SELECT
      *
    FROM joined
    ORDER BY
        distance ASC,
        name ASC,
        id ASC
  ),
  enumerated AS (
    SELECT
      *,
      ROW_NUMBER() OVER () AS index
    FROM sorted
  )
SELECT id AS "profileId"
FROM enumerated
WHERE (
  true
  OR permanently_closed = FALSE          
) AND (
  true
  OR (
    false
    AND profile_id IN (SELECT * FROM root_nodes)
  )
  OR (
    false
    AND profile_id IN (SELECT * FROM leaf_nodes)
  )
) AND (
  index >= COALESCE(
    (
      SELECT index
      FROM enumerated
      WHERE id = null
    ),
    0
  )
)
ORDER BY index
LIMIT 31

Query Plan

Output from EXPLAIN ..., given the above example with actual values and run on a database instance with only 1 profile, which is not a venue.

                                            QUERY PLAN                                             
---------------------------------------------------------------------------------------------------
 Limit  (cost=8248.43..8248.51 rows=31 width=40)
   CTE enumerated
     ->  WindowAgg  (cost=8211.05..8227.32 rows=651 width=317)
           ->  Sort  (cost=8211.05..8212.67 rows=651 width=309)
                 Sort Key: (('-1'::integer)::double precision), p.name, p.id
                 ->  Hash Join  (cost=17.88..8180.62 rows=651 width=309)
                       Hash Cond: ("*SELECT* 1".profile_id = p.id)
                       ->  Append  (cost=0.00..8154.51 rows=651 width=105)
                             ->  Subquery Scan on "*SELECT* 1"  (cost=0.00..0.00 rows=1 width=105)
                                   ->  Result  (cost=0.00..0.00 rows=0 width=101)
                                         One-Time Filter: false
                             ->  Seq Scan on venues  (cost=0.00..8144.75 rows=650 width=105)
                       ->  Hash  (cost=13.50..13.50 rows=350 width=204)
                             ->  Seq Scan on profiles p  (cost=0.00..13.50 rows=350 width=204)
   InitPlan 2 (returns $1)
     ->  Result  (cost=0.00..0.00 rows=0 width=0)
           One-Time Filter: false
   ->  Sort  (cost=21.11..21.65 rows=217 width=40)
         Sort Key: enumerated.index
         ->  CTE Scan on enumerated  (cost=0.00..14.65 rows=217 width=40)
               Filter: (index >= COALESCE($1, '0'::bigint))
(21 rows)
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

distance

Here's a curious column:

      Materialized view "profiles.venue_parents"
  Column   |  Type   | Collation | Nullable | Default 
-----------+---------+-----------+----------+---------
 ...
 distance  | integer |           |          | 

Distance to what? Charing Cross? Linköping Central Station? Document the origin point.

Or better, include the pair of (lat, long)'s, for total of five columns, and then you'll have a self-documenting relation.

I am glad to see it's in SI units of meters.

CREATE MATERIALIZED VIEW venue_parents ...
      ...
      1 AS distance ...

Why a sentinel of one meter, rather than zero?

You seem to sum up +1 and -1 a fair amount -- what does that mean? Maybe you wanted a different word, like priority, to describe your ORDER BY preference, if we don't have actual geographic distances here.


long query

Your WITH root_nodes... query is Too Long. Maybe you can keep all those plates spinning in the air at once, but I can't. Consider breaking out meaningful relations, giving them names, and defining each as a VIEW. Then this long query becomes a much simpler combination of the VIEWs you built up.

Such decomposition doesn't really matter to the backend query planner -- it will catenate them together and still produce plans similar to the current one. The advantage is that you get to do piecewise debugging, of both a result's correctness and also its performance.

Also, the VIEWs would hopefully discourage you from sprinkling expressions like ${filterModel.near?.lng ?? 0} and ${filterModel.near && 1}::int throughout the interior of your queries. Have the query surface attributes like a longitude column, and then let your UI interface select rows WHERE longitude has some value, or perhaps WHERE ST_DistanceSphere() has some value.


meaningful names

    ...
    ) AS t

That's not really helping me out. Give it a proper name, describe the relation you just built.

With p.* you at least told us it's the profiles table, so I know how to mentally pronounce it.


query plan

You didn't mention any symptoms like only getting X result rows per second, or having to wait Y seconds for first result row to appear. Mostly it all looks good to me.

You can use EXPLAIN ANALYZE for more detailed timings. The EXPLAIN will comment on "how many rows?" and "is there an index?", without actually fetching rows. An ANALYZE will fetch the rows, and measure how many seconds that took.

Planning only becomes interesting when you have "lots" of data, and most of it remains on-disk, ignored by a query that looks for just a narrow range of result values. You appear to have only a small amount of data, less than seven hundred rows, which suggests that all queries would be fast, even if there were no indexes. INSERT more rows if you want to explore performance in depth.

The plan, and the use of indexes, mostly looks fine. The only noteworthy item that leaps out at me is this tablescan:

         ->  Seq Scan on venues  (cost=0.00..8144.75 rows=650 width=105)

And of course tablescans are not a bad thing, sometimes that's the very best access plan, if your query needs to retrieve every single stored value.

If we're focused on nice snappy interactive response times, then query selectivity is something you want to pay attention to. A highly selective WHERE clause is one that lets us retrieve just a handful of rows to obtain a result, while we get to ignore 99% of a table's contents. Usually some suitable index will help us achieve that. In contrast a low selectivity query forces the backend to retrieve most of the rows, and there's just no optimizing that, it fundamentally requires doing a lot of time consuming work.

Your query is on the complex side. Think about how storing certain facts in a reporting table would allow you to submit a query that is both simpler for the backend to plan and easier for humans to reason about.


sql92 keywords

I'm not fond of naming a column index, given that that is a pretty important part of DDL. Prefer an alternate spelling, perhaps idx.


paging

Assume that each interactive user has a unique ID, perhaps a GUID in a web session cookie. Equivalently, we could use hash of the first part of a query, omitting the page number.

You run a complex query and then return just the subset of result rows that fall within the requested page. And then re-run it for the subsequent page.

Consider caching the full result set in a reporting table, indexed on guid + row number. Now satisfying a 2nd page or 3rd page request becomes trivial.

Put a timestamp in such rows, so you can purge yesterday's trash.

Depending on user behavior, maybe don't bother with storing results for 1st page requests, if users typically jump around while looking only at 1st page results (so any 2nd page effort would typically be wasted). Once you see a 2nd page request, that may be a fair indicator that user will soon generate 3rd page and 4th page requests, as well.

\$\endgroup\$
13
  • \$\begingroup\$ Wow, thank you for taking the time to write such an in-depth answer! I see there is some confusion about a few things - I'll try to clarify here, but in general you have some really good points that I'll make sure to implement. Thanks! \$\endgroup\$
    – Felix ZY
    Commented Dec 4, 2023 at 22:26
  • \$\begingroup\$ distance - distance in the venue_parents view refers to node distance in the venue tree. For example, The node distance between "hotel" and "room" in the tree "hotel - floor - room" would be 2. \$\endgroup\$
    – Felix ZY
    Commented Dec 4, 2023 at 22:28
  • \$\begingroup\$ 1 AS distance [...] Why a sentinel of one meter, rather than zero? - I'm actually using -1 meter. I'm doing this to indicate that in the case when there is no origin point, it does not make sense to reason about the distance. Zero would also be a possible value, though that would fall within the "valid" range. I therefore chose -1 to avoid confusion. \$\endgroup\$
    – Felix ZY
    Commented Dec 4, 2023 at 22:31
  • \$\begingroup\$ You seem to sum up +1 and -1 a fair amount - I'm not sure I follow. The only thing I can think of is WHERE 1::int IS NULL? In this case, I'm using the statement as a short circuit, to pick the FROM query I'm actually interested in, as this depends on what is originally an object (WHERE ${filterModel.near && 1}::int IS NULL) in javascript. This object can be either null or non-null. \$\endgroup\$
    – Felix ZY
    Commented Dec 4, 2023 at 22:36
  • 1
    \$\begingroup\$ IDK where I got the notion we were adding distances from parent to child to child -- that is my error. I also failed to understand the role that "distance of negative one" plays in this -- I just assumed you must be subtracting 1 to rejigger a sort order, since negative distance doesn't make any sense. // For the positive 1, I was looking at venue_parents where it appeared we were starting an accumulator at 1, then there is parent_query.distance + 1 AS distance so it looked like we might be counting up how many steps from leaf to ancestor root. Generally, I found it unclear. \$\endgroup\$
    – J_H
    Commented Dec 4, 2023 at 22:42

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.