public inbox for pgsql-hackers@postgresql.org  
help / color / mirror / Atom feed
From: Chao Li <li.evan.chao@gmail.com>
To: PostgreSQL-development <pgsql-hackers@postgresql.org>
Cc: Michael Paquier <michael.paquier@gmail.com>
Cc: nagnrik@gmail.com
Subject: Fix pg_get_multixact_stats() members_size calculation
Date: Fri, 22 May 2026 15:02:48 +0800
Message-ID: <819AC1B2-1A71-4244-B081-3ADD85D1725D@gmail.com> (raw)

Hi,

While testing pg_get_multixact_stats(), I found that it can undercount members_size.

This is a simple repro, starting from a fresh cluster.

Session 1:
```
evantest=# create table t (i int primary key);
CREATE TABLE
evantest=# insert into t values (1);
INSERT 0 1
evantest=#
evantest=# begin;
BEGIN
evantest=*# select * from t where i = 1 for share;
 i
---
 1
(1 row)
```

Session 2:
```
evantest=# begin;
BEGIN
evantest=*# select * from t where i = 1 for share;
 i
---
 1
(1 row)

evantest=*# select * from pg_get_multixact_stats();
 num_mxids | num_members | members_size | oldest_multixact
-----------+-------------+--------------+------------------
         1 |           2 |            0 |                1
(1 row)
```

num_members is reported as 2, but members_size is reported as 0, which looks surprising.

The current implementation does the division before multiplying by the member-group size:
```
static inline uint64
MultiXactOffsetStorageSize(MultiXactOffset new_offset,
                           MultiXactOffset old_offset)
{
    Assert(new_offset >= old_offset);
    return (uint64) ((new_offset - old_offset) / MULTIXACT_MEMBERS_PER_MEMBERGROUP) *
        MULTIXACT_MEMBERGROUP_SIZE;
}
```

Since MULTIXACT_MEMBERS_PER_MEMBERGROUP is 4, any remainder of 1 to 3 members is truncated. This is less visible with large values, but it becomes obvious with a small number of members, as in the example above.

I checked the related commits, 0e3ad4b96aedee57fc2694e28486fe0ceca8110a and 97b101776ce23dd6c4abbdae213806bc24ed6133, and I didn't see anything suggesting that this truncation was intentional. So even though this is a small issue, I think it is better to fix it before PostgreSQL 19 is released.

The fix is straightforward, just compute the per-member size first, which is MULTIXACT_MEMBERGROUP_SIZE / MULTIXACT_MEMBERS_PER_MEMBERGROUP, and then
multiply that by (new_offset - old_offset).

The doc example also seems to confirm that members_size is meant to be num_members * 5, without rounding for group alignment or accounting for the 12 bytes wasted per page:
```
<screen>
=# SELECT *, pg_size_pretty(members_size) members_size_pretty
     FROM pg_catalog.pg_get_multixact_stats();
 num_mxids | num_members | members_size | oldest_multixact | members_size_pretty
-----------+-------------+--------------+------------------+---------------------
 311740299 |  2785241176 |  13926205880 |                2 | 13 GB
(1 row)
</screen>
```

Where 2785241176 * 5 = 13926205880.

With the fix, the same test reports members_size as 10:
```
evantest=*# select * from pg_get_multixact_stats();
 num_mxids | num_members | members_size | oldest_multixact
-----------+-------------+--------------+------------------
         1 |           2 |           10 |                1
(1 row)
```

The attached patch also updates the existing isolation test to cover members_size.

Best regards,
--
Chao Li (Evan)
HighGo Software Co., Ltd.
https://www.highgo.com/






Attachments:

  [application/octet-stream] v1-0001-Fix-pg_get_multixact_stats-members_size-calculati.patch (9.6K, 2-v1-0001-Fix-pg_get_multixact_stats-members_size-calculati.patch)
  download | inline diff:
From 6353c2dc45d9cdc64c5206ec576c578a3d2edd08 Mon Sep 17 00:00:00 2001
From: "Chao Li (Evan)" <lic@highgo.com>
Date: Fri, 22 May 2026 14:47:21 +0800
Subject: [PATCH v1] Fix pg_get_multixact_stats() members_size calculation

pg_get_multixact_stats() reports members_size as the storage used by
the currently retained multixact member entries. However,
MultiXactOffsetStorageSize() divided the member count by the number of
members per storage group before multiplying by the group size, so it
rounded down and reported zero for 1-3 retained members.

This patch changes to compute the size from the member count directly.

Extend the multixact-stats isolation test to include members_size in its
snapshots and verify that it matches the reported member count.

Author: Chao Li <lic@higogo.com>
Reviewed-by:
Discussion: https://postgr.es/m/
---
 src/include/access/multixact_internal.h       |  9 +++--
 .../isolation/expected/multixact-stats.out    | 36 +++++++++++--------
 src/test/isolation/specs/multixact-stats.spec | 31 ++++++++++------
 3 files changed, 49 insertions(+), 27 deletions(-)

diff --git a/src/include/access/multixact_internal.h b/src/include/access/multixact_internal.h
index 82349ea0d32..09755511376 100644
--- a/src/include/access/multixact_internal.h
+++ b/src/include/access/multixact_internal.h
@@ -126,9 +126,14 @@ static inline uint64
 MultiXactOffsetStorageSize(MultiXactOffset new_offset,
 						   MultiXactOffset old_offset)
 {
+	uint64		size_per_member;
+
 	Assert(new_offset >= old_offset);
-	return (uint64) ((new_offset - old_offset) / MULTIXACT_MEMBERS_PER_MEMBERGROUP) *
-		MULTIXACT_MEMBERGROUP_SIZE;
+	Assert(MULTIXACT_MEMBERGROUP_SIZE % MULTIXACT_MEMBERS_PER_MEMBERGROUP == 0);
+
+	size_per_member = MULTIXACT_MEMBERGROUP_SIZE / MULTIXACT_MEMBERS_PER_MEMBERGROUP;
+
+	return (new_offset - old_offset) * size_per_member;
 }
 
 #endif							/* MULTIXACT_INTERNAL_H */
diff --git a/src/test/isolation/expected/multixact-stats.out b/src/test/isolation/expected/multixact-stats.out
index 27a6510c4ad..f170b04c3e4 100644
--- a/src/test/isolation/expected/multixact-stats.out
+++ b/src/test/isolation/expected/multixact-stats.out
@@ -3,7 +3,7 @@ Parsed test spec with 2 sessions
 starting permutation: snap0 s1_begin s1_lock snap1 s2_begin s2_lock snap2 check_while_pinned s1_commit s2_commit
 step snap0: 
   CREATE TEMP TABLE snap0 AS
-  SELECT num_mxids, num_members, oldest_multixact
+  SELECT num_mxids, num_members, members_size, oldest_multixact
   FROM pg_get_multixact_stats();
 
 step s1_begin: BEGIN;
@@ -15,7 +15,7 @@ step s1_lock: SELECT 1 FROM mxq WHERE id=1 FOR KEY SHARE;
 
 step snap1: 
   CREATE TEMP TABLE snap1 AS
-  SELECT num_mxids, num_members, oldest_multixact
+  SELECT num_mxids, num_members, members_size, oldest_multixact
   FROM pg_get_multixact_stats();
 
 step s2_begin: BEGIN;
@@ -27,7 +27,7 @@ step s2_lock: SELECT 1 FROM mxq WHERE id=1 FOR KEY SHARE;
 
 step snap2: 
   CREATE TEMP TABLE snap2 AS
-  SELECT num_mxids, num_members, oldest_multixact
+  SELECT num_mxids, num_members, members_size, oldest_multixact
   FROM pg_get_multixact_stats();
 
 step check_while_pinned: 
@@ -39,32 +39,37 @@ step check_while_pinned:
     ARRAY[
       'is_init_mxids',
       'is_init_members',
+      'is_init_members_size',
       'is_init_oldest_mxid',
-      'is_init_oldest_off',
       'is_oldest_mxid_nondec_01',
       'is_oldest_mxid_nondec_12',
-      'is_oldest_off_nondec_01',
-      'is_oldest_off_nondec_12',
       'is_members_increased_ge1',
+      'is_msize_matches_members',
       'is_mxids_nondec_01',
       'is_mxids_nondec_12',
       'is_members_nondec_01',
-      'is_members_nondec_12'
+      'is_members_nondec_12',
+      'is_msize_nondec_01',
+      'is_msize_nondec_12'
     ],
     ARRAY[
       (s2.num_mxids        IS NOT NULL),
       (s2.num_members      IS NOT NULL),
+      (s2.members_size     IS NOT NULL),
       (s2.oldest_multixact IS NOT NULL),
 
       (s1.oldest_multixact::text::bigint >= COALESCE(s0.oldest_multixact::text::bigint, 0)),
       (s2.oldest_multixact::text::bigint >= COALESCE(s1.oldest_multixact::text::bigint, 0)),
 
       (s2.num_members >= COALESCE(s1.num_members, 0) + 1),
+      (s2.members_size = s2.num_members * 5),
 
       (s1.num_mxids   >= COALESCE(s0.num_mxids,   0)),
       (s2.num_mxids   >= COALESCE(s1.num_mxids,   0)),
       (s1.num_members >= COALESCE(s0.num_members, 0)),
-      (s2.num_members >= COALESCE(s1.num_members, 0))
+      (s2.num_members >= COALESCE(s1.num_members, 0)),
+      (s1.members_size >= COALESCE(s0.members_size, 0)),
+      (s2.members_size >= COALESCE(s1.members_size, 0))
     ]
   ) AS r(assertion, ok);
 
@@ -72,18 +77,19 @@ assertion               |ok
 ------------------------+--
 is_init_mxids           |t 
 is_init_members         |t 
+is_init_members_size    |t 
 is_init_oldest_mxid     |t 
-is_init_oldest_off      |t 
 is_oldest_mxid_nondec_01|t 
 is_oldest_mxid_nondec_12|t 
-is_oldest_off_nondec_01 |t 
-is_oldest_off_nondec_12 |t 
 is_members_increased_ge1|t 
+is_msize_matches_members|t 
 is_mxids_nondec_01      |t 
-is_mxids_nondec_12      |  
-is_members_nondec_01    |  
-is_members_nondec_12    |  
-(13 rows)
+is_mxids_nondec_12      |t 
+is_members_nondec_01    |t 
+is_members_nondec_12    |t 
+is_msize_nondec_01      |t 
+is_msize_nondec_12      |t 
+(14 rows)
 
 step s1_commit: COMMIT;
 step s2_commit: COMMIT;
diff --git a/src/test/isolation/specs/multixact-stats.spec b/src/test/isolation/specs/multixact-stats.spec
index 07d4b11be6d..bc612e20818 100644
--- a/src/test/isolation/specs/multixact-stats.spec
+++ b/src/test/isolation/specs/multixact-stats.spec
@@ -4,8 +4,10 @@
 # is pinned by two open transactions, we check some patterns that VACUUM and
 # FREEZE cannot violate:
 # 1) "members" increased by at least 1 when the second session locked the row.
-# 2) (num_mxids / num_members) not decreased compared to earlier snapshots.
-# 3) "oldest_*" fields never decreased.
+# 2) "members_size" reflects the storage used by the member entries.
+# 3) (num_mxids / num_members / members_size) not decreased compared to
+#    earlier snapshots.
+# 4) "oldest_*" fields never decreased.
 #
 # This test does not run checks after releasing locks, as freezing and/or
 # truncation may shrink the multixact ranges calculated.
@@ -39,14 +41,14 @@ step s2_commit { COMMIT; }
 # multixacts have not initialized yet.
 step snap0 {
   CREATE TEMP TABLE snap0 AS
-  SELECT num_mxids, num_members, oldest_multixact
+  SELECT num_mxids, num_members, members_size, oldest_multixact
   FROM pg_get_multixact_stats();
 }
 
 # Save multixact state after s1 has locked the row.
 step snap1 {
   CREATE TEMP TABLE snap1 AS
-  SELECT num_mxids, num_members, oldest_multixact
+  SELECT num_mxids, num_members, members_size, oldest_multixact
   FROM pg_get_multixact_stats();
 }
 
@@ -54,21 +56,25 @@ step snap1 {
 # a multixact with at least 2 members.
 step snap2 {
   CREATE TEMP TABLE snap2 AS
-  SELECT num_mxids, num_members, oldest_multixact
+  SELECT num_mxids, num_members, members_size, oldest_multixact
   FROM pg_get_multixact_stats();
 }
 
 # Pretty, deterministic key/value outputs based of boolean checks:
 #   is_init_mxids            : num_mxids not NULL
 #   is_init_members          : num_members not NULL
+#   is_init_members_size     : members_size not NULL
 #   is_init_oldest_mxid      : oldest_multixact not NULL
 #   is_oldest_mxid_nondec_01 : oldest_multixact not decreased (snap0->snap1)
 #   is_oldest_mxid_nondec_12 : oldest_multixact did not decreased (snap1->snap2)
 #   is_members_increased_ge1 : members increased by at least 1 when s2 joined
+#   is_msize_matches_members : members_size matches the member count
 #   is_mxids_nondec_01       : num_mxids not decreased (snap0->snap1)
 #   is_mxids_nondec_12       : num_mxids not decreased (snap1->snap2)
 #   is_members_nondec_01     : num_members not decreased (snap0->snap1)
 #   is_members_nondec_12     : num_members not decreased (snap1->snap2)
+#   is_msize_nondec_01       : members_size not decreased (snap0->snap1)
+#   is_msize_nondec_12       : members_size not decreased (snap1->snap2)
 step check_while_pinned {
   SELECT r.assertion, r.ok
   FROM snap0 s0
@@ -78,32 +84,37 @@ step check_while_pinned {
     ARRAY[
       'is_init_mxids',
       'is_init_members',
+      'is_init_members_size',
       'is_init_oldest_mxid',
-      'is_init_oldest_off',
       'is_oldest_mxid_nondec_01',
       'is_oldest_mxid_nondec_12',
-      'is_oldest_off_nondec_01',
-      'is_oldest_off_nondec_12',
       'is_members_increased_ge1',
+      'is_msize_matches_members',
       'is_mxids_nondec_01',
       'is_mxids_nondec_12',
       'is_members_nondec_01',
-      'is_members_nondec_12'
+      'is_members_nondec_12',
+      'is_msize_nondec_01',
+      'is_msize_nondec_12'
     ],
     ARRAY[
       (s2.num_mxids        IS NOT NULL),
       (s2.num_members      IS NOT NULL),
+      (s2.members_size     IS NOT NULL),
       (s2.oldest_multixact IS NOT NULL),
 
       (s1.oldest_multixact::text::bigint >= COALESCE(s0.oldest_multixact::text::bigint, 0)),
       (s2.oldest_multixact::text::bigint >= COALESCE(s1.oldest_multixact::text::bigint, 0)),
 
       (s2.num_members >= COALESCE(s1.num_members, 0) + 1),
+      (s2.members_size = s2.num_members * 5),
 
       (s1.num_mxids   >= COALESCE(s0.num_mxids,   0)),
       (s2.num_mxids   >= COALESCE(s1.num_mxids,   0)),
       (s1.num_members >= COALESCE(s0.num_members, 0)),
-      (s2.num_members >= COALESCE(s1.num_members, 0))
+      (s2.num_members >= COALESCE(s1.num_members, 0)),
+      (s1.members_size >= COALESCE(s0.members_size, 0)),
+      (s2.members_size >= COALESCE(s1.members_size, 0))
     ]
   ) AS r(assertion, ok);
 }
-- 
2.50.1 (Apple Git-155)



reply

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Reply to all the recipients using the --to and --cc options:
  reply via email

  To: pgsql-hackers@postgresql.org
  Cc: li.evan.chao@gmail.com, michael.paquier@gmail.com, nagnrik@gmail.com
  Subject: Re: Fix pg_get_multixact_stats() members_size calculation
  In-Reply-To: <819AC1B2-1A71-4244-B081-3ADD85D1725D@gmail.com>

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox