Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wwyd6-002KXP-1o for pgsql-hackers@arkaria.postgresql.org; Thu, 20 Aug 2026 09:00:08 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wwyd5-008BgA-1m for pgsql-hackers@arkaria.postgresql.org; Thu, 20 Aug 2026 09:00:07 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wwyd5-008Bg1-0b for pgsql-hackers@lists.postgresql.org; Thu, 20 Aug 2026 09:00:07 +0000 Received: from mail-wm1-x336.google.com ([2a00:1450:4864:20::336]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.98.2) (envelope-from ) id 1wwyd2-00000000H2W-3Pjy for pgsql-hackers@postgresql.org; Thu, 20 Aug 2026 09:00:06 +0000 Received: by mail-wm1-x336.google.com with SMTP id 5b1f17b1804b1-499840a2575so15085695e9.3 for ; Thu, 20 Aug 2026 02:00:04 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1787216404; x=1787821204; darn=postgresql.org; h=in-reply-to:from:content-language:references:cc:to:subject :user-agent:mime-version:date:message-id:content-type:from:to:cc :subject:date:message-id:reply-to:content-type; bh=hZOrdVt4iGVvF6NsjmkvTIB40+71ex0d20uAJNaHbYE=; b=HdRPhAxLzVAgiHNjwUb5ALlThzn2DQ/bmE6NAmWhd5Z9Q8P10hxAH2nh/yb2rlhs9n Ik2O/yLlkJv20hUHDVNnsLuPWeAMn6Ml6ovRqisUshL5COXZuVZ7ONOjvlsD/JA61FHe 8eITrC6oMzk+VivAsnzu/JC8d1OUIoiWYEHmOgiGvczNhJHq7kavRUUKNB5y/43K035O FPLC6ab0Zkg4tQN5NF8TRcDbUYlGoZPfTXel6yU0WJaSBIm79v9m8MF6LsYms+wgHUCP KFjhH73dFPyFGuuQNUWAiVXyIhwLHVGdZXNCLPUJgLkTU2sClC7R+5MiC7iP1KD0d4cD dFCw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1787216404; x=1787821204; h=in-reply-to:from:content-language:references:cc:to:subject :user-agent:mime-version:date:message-id:content-type:x-gm-gg :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to :content-type; bh=hZOrdVt4iGVvF6NsjmkvTIB40+71ex0d20uAJNaHbYE=; b=PEGsZaAAW1qV+VMer7YHSe1XQm2HWZr1UD06KU9GiFG1HEXR3YlSQGw8x4m9SoGWLy KxMio0opO6zKJ7vAmSjRCPaBNxwTKt6S+qdx2ITBM8ZSVH+iZZ64zFtPm1CNPoFpQT75 UoNFLCeo8TXLvwB33p9/rrhVpALeHk7gl0191mrtL6KAuvjLsBvyB7SyhihjyTLEiRrR Mtzg4qCgTo8+XqEVVaBEAdZsuH2aEbjeTAJbS7Ba/trdR2VB3ja7P+VySb3GTYLNTsdW jtD+YZtbF16HRbR1pusHFjxGi1bpNtv2uoxwH/stDQPleRaAaOuGIyq6rNCVGs7+4xy7 539A== X-Gm-Message-State: AOJu0YzssdFQg4/M5RxKmmBHsSoyRvyd1uXNpbJlJJkZ3bFzkRducSBZ Ltrn+q9jxqfp77dgp2xwn76tc1PM1rAaIJuLvnjp5DgdXZ1UllX/1Dxg X-Gm-Gg: AR+sD13zboX+++n9tDlloa0hoG93y+MmzKSG6FDpgjq0i++ykv6ENPcgl8T3rU5SN85 XnqdYKSyvGV7NvmfxlZR8x6ohARnXVa80wTtkYQ4uJ4aQZ0Z8X+JGvVbovcjkjhVV2LFdtmDilZ wpSi/FHek1L+D1a7YLLICOcdFaDCn9tQZ2nNPi/OXGglzYxf6rwAj8+S8mYouAliIg3VTQ9KBRq elaAh7ApGiqk/lEmaDz1ahOgBZl+kePnsDMn4gUxNc0RS3c6cr81BVDhXXAGG3ns2h9B9Tw+6hz iR4AF9vRlw2gAZGjt4o8ZHHw17tmCnj++mkoLvxQq+oqbJLzo23M1YkyylmFB4prURG5u58cRPe kjcS/tenVlfwPMD2kBB/6y1/j47tcWoceHwW+WboxM67c5HnBW0pOCcJkK7zBWfj+x0Ffjwi4pW BatDH+BqXauzsxgK4cWGtFecPd5rpyzIod2ObXZ3M6zM4+1ZFcksuz6xCNhCzkICMNgKPP+7rLr Q== X-Received: by 2002:a05:600d:8495:10b0:499:adb4:a922 with SMTP id 5b1f17b1804b1-499adb4a9camr129005555e9.12.1787216403290; Thu, 20 Aug 2026 02:00:03 -0700 (PDT) Received: from [192.168.0.50] ([89.149.120.180]) by smtp.gmail.com with ESMTPSA id 5b1f17b1804b1-499aa11f832sm124270265e9.7.2026.08.20.02.00.02 (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128); Thu, 20 Aug 2026 02:00:02 -0700 (PDT) Content-Type: multipart/alternative; boundary="------------JBurEjviai5Z56cxyFQYJeeg" Message-ID: Date: Thu, 20 Aug 2026 12:00:01 +0300 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Subject: Re: Test tidscan,sql is not immune to autovacuum in v14 To: David Rowley , Tom Lane Cc: pgsql-hackers References: Content-Language: en-US From: Alexander Lakhin In-Reply-To: List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk This is a multi-part message in MIME format. --------------JBurEjviai5Z56cxyFQYJeeg Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 8bit Hello David and Tom, 20.08.2026 03:32, David Rowley wrote: > I experimented, and I see the costs come out quite different if that > were changed to: > > SELECT count(*) FROM tenk1 WHERE ctid IN (SELECT DISTINCT ctid FROM tenk1); > > The winning plan is; > > QUERY PLAN > ------------------------------------------------------------------------------------------- > Aggregate (cost=1191.26..1191.27 rows=1 width=8) > -> Hash Join (cost=695.00..1166.26 rows=10000 width=0) > Hash Cond: (tenk1.ctid = tenk1_1.ctid) > -> Seq Scan on tenk1 (cost=0.00..445.00 rows=10000 width=6) > -> Hash (cost=570.00..570.00 rows=10000 width=6) > -> HashAggregate (cost=470.00..570.00 rows=10000 width=6) > Group Key: tenk1_1.ctid > -> Seq Scan on tenk1 tenk1_1 (cost=0.00..445.00 > rows=10000 width=6) > (8 rows) My "good" plan in REL_14_STABLE is: EXPLAIN (VERBOSE) SELECT count(*) FROM tenk1 t1 JOIN tenk1 t2 ON t1.ctid = t2.ctid;                                       QUERY PLAN --------------------------------------------------------------------------------------  Aggregate  (cost=1177.26..1177.27 rows=1 width=8)    Output: count(*)    ->  Hash Join  (cost=569.89..1152.27 rows=9995 width=0)          Hash Cond: (t1.ctid = t2.ctid)          ->  Seq Scan on public.tenk1 t1  (cost=0.00..444.95 rows=9995 width=6)                Output: t1.ctid          ->  Hash  (cost=444.95..444.95 rows=9995 width=6)                Output: t2.ctid                ->  Seq Scan on public.tenk1 t2 (cost=0.00..444.95 rows=9995 width=6)                      Output: t2.ctid (10 rows) versus "bad": EXPLAIN (VERBOSE) SELECT count(*) FROM tenk1 t1 JOIN tenk1 t2 ON t1.ctid = t2.ctid;                                       QUERY PLAN --------------------------------------------------------------------------------------  Aggregate  (cost=1177.33..1177.34 rows=1 width=8)    Output: count(*)    ->  Hash Join  (cost=569.89..1152.34 rows=9995 width=0)          Hash Cond: (t2.ctid = t1.ctid)          ->  Seq Scan on public.tenk1 t2  (cost=0.00..445.00 rows=10000 width=6)                Output: t2.ctid          ->  Hash  (cost=444.95..444.95 rows=9995 width=6)                Output: t1.ctid                ->  Seq Scan on public.tenk1 t1 (cost=0.00..444.95 rows=9995 width=6)                      Output: t1.ctid (10 rows) > and if I force the other Hash Join option via the debugger, I get: > > QUERY PLAN > ------------------------------------------------------------------------------------- > Aggregate (cost=1302.50..1302.51 rows=1 width=8) > -> Hash Join (cost=1040.00..1277.50 rows=10000 width=0) > Hash Cond: (tenk1_1.ctid = tenk1.ctid) > -> HashAggregate (cost=470.00..570.00 rows=10000 width=6) > Group Key: tenk1_1.ctid > -> Seq Scan on tenk1 tenk1_1 (cost=0.00..445.00 > rows=10000 width=6) > -> Hash (cost=445.00..445.00 rows=10000 width=6) > -> Seq Scan on tenk1 (cost=0.00..445.00 rows=10000 width=6) > (8 rows) > > There's probably also some argument to the distinct semi-join query > giving the code a bit more exercise due to the Hash Agg. > > Naturally, the current INNER JOIN query produces the same cost for > each join order, under normal circumstances. > > This makes me wonder how significant the estimates varied in the two > calls to estimate_rel_size() in the problem case you saw? At the commit 74388a1ac, with this logging added: --- a/src/backend/commands/vacuum.c +++ b/src/backend/commands/vacuum.c @@ -60,2 +60,3 @@  #include "utils/syscache.h" +#include "utils/lsyscache.h" @@ -1255,5 +1256,9 @@ vac_estimate_reltuples(Relation relation,          */ +char *rel_name = get_rel_name(relation->rd_id);         if (old_rel_pages == total_pages &&                 scanned_pages < (double) total_pages * 0.02) +{ +elog(LOG, "!!!vac_estimate_reltuples| rel_name: %s, old_rel_tuples: %lf", rel_name, old_rel_tuples);                 return old_rel_tuples; +} @@ -1275,2 +1280,3 @@ vac_estimate_reltuples(Relation relation,         total_tuples = old_density * unscanned_pages + scanned_tuples; +elog(LOG, "!!!vac_estimate_reltuples| rel_name: %s, floor(total_tuples + 0.5): %lf", rel_name, floor(total_tuples + 0.5));         return floor(total_tuples + 0.5); I can see the following: 2026-08-20 04:23:48.978 EDT client backend[672080] pg_regress/sanity_check LOG:  !!!vac_estimate_reltuples| rel_name: tenk1, old_rel_tuples: 10000.000000 2026-08-20 04:23:48.978 EDT client backend[672080] pg_regress/sanity_check CONTEXT:  while scanning relation "public.tenk1" 2026-08-20 04:23:48.978 EDT client backend[672080] pg_regress/sanity_check STATEMENT:  VACUUM; when the plan is expected, versus 2026-08-20 04:27:53.033 EDT client backend[674543] pg_regress/sanity_check LOG:  !!!vac_estimate_reltuples| rel_name: tenk1, floor(total_tuples + 0.5): 9995.000000 2026-08-20 04:27:53.033 EDT client backend[674543] pg_regress/sanity_check CONTEXT:  while scanning relation "public.tenk1" 2026-08-20 04:27:53.033 EDT client backend[674543] pg_regress/sanity_check STATEMENT:  VACUUM; at 74388a1ac~1 (with the "bad" plan). >> Given the current statistics, we won't see failures of this ilk anymore, >> because it is not reproduced in REL_15_STABLE..master, due to 74388a1ac + >> 4496020e6, which resulted in a different reltuples value returned for tenk1 >> during sanity_check/VACUUM and that indirectly affected the plan change. > I had also thought that it might not be worth troubling over given > that v14 has less than 3 months to live, but I believe it's generally > bad practice to have queries in tests where multiple plans are so > close together in cost. They're just too prone to very subtle changes > that can result in rare plan changes (as per what you're reporting). I absolutely agree with your and Tom's points -- the worthwhile thing here to me is the evidence how (auto)vacuum/analyze can affect the planner's decisions transiently (in a very small window). I remember a similar story when tenk2's reltuples drifted 10000 ->9995 [1], but in that case it affected sensitive queries that followed the drift. Thank you for your attention to this anomaly! [1] https://www.postgresql.org/message-id/66eb9a6e-fc67-a230-c5b1-2a741e8b88c6%40gmail.com Best regards, Alexander --------------JBurEjviai5Z56cxyFQYJeeg Content-Type: text/html; charset=UTF-8 Content-Transfer-Encoding: 8bit
Hello David and Tom,

20.08.2026 03:32, David Rowley wrote:
I experimented, and I see the costs come out quite different if that
were changed to:

SELECT count(*) FROM tenk1 WHERE ctid IN (SELECT DISTINCT ctid FROM tenk1);

The winning plan is;

                                        QUERY PLAN
-------------------------------------------------------------------------------------------
 Aggregate  (cost=1191.26..1191.27 rows=1 width=8)
   ->  Hash Join  (cost=695.00..1166.26 rows=10000 width=0)
         Hash Cond: (tenk1.ctid = tenk1_1.ctid)
         ->  Seq Scan on tenk1  (cost=0.00..445.00 rows=10000 width=6)
         ->  Hash  (cost=570.00..570.00 rows=10000 width=6)
               ->  HashAggregate  (cost=470.00..570.00 rows=10000 width=6)
                     Group Key: tenk1_1.ctid
                     ->  Seq Scan on tenk1 tenk1_1  (cost=0.00..445.00
rows=10000 width=6)
(8 rows)

My "good" plan in REL_14_STABLE is:
EXPLAIN (VERBOSE)
SELECT count(*) FROM tenk1 t1 JOIN tenk1 t2 ON t1.ctid = t2.ctid;
                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Aggregate  (cost=1177.26..1177.27 rows=1 width=8)
   Output: count(*)
   ->  Hash Join  (cost=569.89..1152.27 rows=9995 width=0)
         Hash Cond: (t1.ctid = t2.ctid)
         ->  Seq Scan on public.tenk1 t1  (cost=0.00..444.95 rows=9995 width=6)
               Output: t1.ctid
         ->  Hash  (cost=444.95..444.95 rows=9995 width=6)
               Output: t2.ctid
               ->  Seq Scan on public.tenk1 t2  (cost=0.00..444.95 rows=9995 width=6)
                     Output: t2.ctid
(10 rows)

versus "bad":
EXPLAIN (VERBOSE)
SELECT count(*) FROM tenk1 t1 JOIN tenk1 t2 ON t1.ctid = t2.ctid;
                                      QUERY PLAN
--------------------------------------------------------------------------------------
 Aggregate  (cost=1177.33..1177.34 rows=1 width=8)
   Output: count(*)
   ->  Hash Join  (cost=569.89..1152.34 rows=9995 width=0)
         Hash Cond: (t2.ctid = t1.ctid)
         ->  Seq Scan on public.tenk1 t2  (cost=0.00..445.00 rows=10000 width=6)
               Output: t2.ctid
         ->  Hash  (cost=444.95..444.95 rows=9995 width=6)
               Output: t1.ctid
               ->  Seq Scan on public.tenk1 t1  (cost=0.00..444.95 rows=9995 width=6)
                     Output: t1.ctid
(10 rows)

and if I force the other Hash Join option via the debugger, I get:

                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Aggregate  (cost=1302.50..1302.51 rows=1 width=8)
   ->  Hash Join  (cost=1040.00..1277.50 rows=10000 width=0)
         Hash Cond: (tenk1_1.ctid = tenk1.ctid)
         ->  HashAggregate  (cost=470.00..570.00 rows=10000 width=6)
               Group Key: tenk1_1.ctid
               ->  Seq Scan on tenk1 tenk1_1  (cost=0.00..445.00
rows=10000 width=6)
         ->  Hash  (cost=445.00..445.00 rows=10000 width=6)
               ->  Seq Scan on tenk1  (cost=0.00..445.00 rows=10000 width=6)
(8 rows)

There's probably also some argument to the distinct semi-join query
giving the code a bit more exercise due to the Hash Agg.

Naturally, the current INNER JOIN query produces the same cost for
each join order, under normal circumstances.

This makes me wonder how significant the estimates varied in the two
calls to estimate_rel_size() in the problem case you saw?

At the commit 74388a1ac, with this logging added:
--- a/src/backend/commands/vacuum.c
+++ b/src/backend/commands/vacuum.c
@@ -60,2 +60,3 @@
 #include "utils/syscache.h"
+#include "utils/lsyscache.h"
 
@@ -1255,5 +1256,9 @@ vac_estimate_reltuples(Relation relation,
         */
+char *rel_name = get_rel_name(relation->rd_id);
        if (old_rel_pages == total_pages &&
                scanned_pages < (double) total_pages * 0.02)
+{
+elog(LOG, "!!!vac_estimate_reltuples| rel_name: %s, old_rel_tuples: %lf", rel_name, old_rel_tuples);
                return old_rel_tuples;
+}
 
@@ -1275,2 +1280,3 @@ vac_estimate_reltuples(Relation relation,
        total_tuples = old_density * unscanned_pages + scanned_tuples;
+elog(LOG, "!!!vac_estimate_reltuples| rel_name: %s, floor(total_tuples + 0.5): %lf", rel_name, floor(total_tuples + 0.5));
        return floor(total_tuples + 0.5);

I can see the following:
2026-08-20 04:23:48.978 EDT client backend[672080] pg_regress/sanity_check LOG:  !!!vac_estimate_reltuples| rel_name: tenk1, old_rel_tuples: 10000.000000
2026-08-20 04:23:48.978 EDT client backend[672080] pg_regress/sanity_check CONTEXT:  while scanning relation "public.tenk1"
2026-08-20 04:23:48.978 EDT client backend[672080] pg_regress/sanity_check STATEMENT:  VACUUM;

when the plan is expected, versus
2026-08-20 04:27:53.033 EDT client backend[674543] pg_regress/sanity_check LOG:  !!!vac_estimate_reltuples| rel_name: tenk1, floor(total_tuples + 0.5): 9995.000000
2026-08-20 04:27:53.033 EDT client backend[674543] pg_regress/sanity_check CONTEXT:  while scanning relation "public.tenk1"
2026-08-20 04:27:53.033 EDT client backend[674543] pg_regress/sanity_check STATEMENT:  VACUUM;

at 74388a1ac~1 (with the "bad" plan).

Given the current statistics, we won't see failures of this ilk anymore,
because it is not reproduced in REL_15_STABLE..master, due to 74388a1ac +
4496020e6, which resulted in a different reltuples value returned for tenk1
during sanity_check/VACUUM and that indirectly affected the plan change.
I had also thought that it might not be worth troubling over given
that v14 has less than 3 months to live, but I believe it's generally
bad practice to have queries in tests where multiple plans are so
close together in cost. They're just too prone to very subtle changes
that can result in rare plan changes (as per what you're reporting).

I absolutely agree with your and Tom's points -- the worthwhile thing here
to me is the evidence how (auto)vacuum/analyze can affect the planner's
decisions transiently (in a very small window). I remember a similar story
when tenk2's reltuples drifted 10000 ->9995 [1], but in that case it
affected sensitive queries that followed the drift.

Thank you for your attention to this anomaly!

[1] https://www.postgresql.org/message-id/66eb9a6e-fc67-a230-c5b1-2a741e8b88c6%40gmail.com

Best regards,
Alexander
--------------JBurEjviai5Z56cxyFQYJeeg--