From b0eb3ddff8ee56bfe91c5099735b10f908284df9 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Sun, 11 Oct 2026 17:37:23 +0000
Subject: [PATCH v20261011 06/12] clock-sweep: bound the work one allocation
 can do

Spending a second chance is progress -- the buffer becomes reclaimable on the
next pass -- so StrategyGetBuffer() treats it as a reason to keep scanning and
resets its retry counter.  That is fine while the hand can outpace the
workload.  It is not fine when the pool is re-touched faster than the hand can
traverse it: chances are granted again before the hand returns, so the loop
spends them indefinitely and never finds a buffer that has none left.  Nothing
bounds the work a single buffer read can do, and freelist.c has carried a note
to that effect for years.

Count the chances one call has spent.  Past BUF_SWEEP_CLAIM_THRESHOLD, stop
looking for a buffer without one and take the next unpinned buffer instead.
This is safe because the bit is cleared when the victim is reused, so there is
no need to spend the chance first, and under no pressure the counter never
reaches the threshold and behaviour is byte-identical.

The threshold trades the bound against replacement quality; see the comment on
BUF_SWEEP_CLAIM_THRESHOLD for the measurements behind 128.  A control loop
that adapted the value was tried and was worse than a fixed one on both axes:
at the allocation rates where this matters it returns to its ceiling within
milliseconds and simply oscillates there.

StrategySweepClaims() exposes the count so the background writer can tell when
the sweep is starving; the next commit uses it.
---
 src/backend/storage/buffer/freelist.c | 81 ++++++++++++++++++++++++---
 src/include/storage/buf_internals.h   | 23 ++++++++
 2 files changed, 96 insertions(+), 8 deletions(-)

diff --git a/src/backend/storage/buffer/freelist.c b/src/backend/storage/buffer/freelist.c
index c58ed0c79eb..c0257b0eb34 100644
--- a/src/backend/storage/buffer/freelist.c
+++ b/src/backend/storage/buffer/freelist.c
@@ -57,6 +57,13 @@ typedef struct
 	uint32		completePasses; /* Complete cycles of the clock-sweep */
 	pg_atomic_uint32 numBufferAllocs;	/* Buffers allocated since last reset */
 
+	/*
+	 * Allocations that gave up on the second-chance rule and took a buffer
+	 * that still had its chance (see StrategyGetBuffer).  A rising value means
+	 * the pool is being re-touched faster than the hand can traverse it.
+	 */
+	pg_atomic_uint64 numSweepClaims;
+
 	/*
 	 * Bgworker process to be notified upon activity or -1 if none. See
 	 * StrategyNotifyBgWriter.
@@ -195,6 +202,7 @@ StrategyGetBuffer(BufferAccessStrategy strategy, uint64 *buf_state, bool *from_r
 	BufferDesc *buf;
 	int			bgwprocno;
 	int			trycounter;
+	int			spent = 0;		/* chances spent by this call */
 
 	*from_ring = false;
 
@@ -293,19 +301,61 @@ StrategyGetBuffer(BufferAccessStrategy strategy, uint64 *buf_state, bool *from_r
 			{
 				/*
 				 * The buffer has been used since the hand last passed it.
-				 * Spend its second chance and keep scanning: it becomes a
-				 * candidate only if it is still untouched when the hand comes
-				 * back around, which gives it a full revolution of grace.
-				 * Spending a chance is progress toward finding a victim, so
-				 * reset trycounter.
+				 * Normally we spend its second chance and keep scanning: it
+				 * becomes a candidate only if it is still untouched when the
+				 * hand comes back around, which gives it a full revolution of
+				 * grace.  Spending a chance is progress toward a victim, so it
+				 * resets trycounter.
+				 *
+				 * That is the right default, but it is not free.  A chance is
+				 * granted again by any access, and the hand needs a full
+				 * revolution to come back; on a large pool re-touched faster
+				 * than that, chances are replenished faster than the hand
+				 * spends them.  Since each one resets trycounter, this loop
+				 * can then spend chances indefinitely without finding a
+				 * victim: unbounded work for one allocation.
+				 *
+				 * So give up the rule once the evidence says it is
+				 * unaffordable.  Having spent BUF_SWEEP_CLAIM_THRESHOLD
+				 * chances without finding a single buffer that had none left,
+				 * take the next unpinned buffer instead.  Under no pressure
+				 * the counter never reaches the threshold and behaviour is
+				 * unchanged.
 				 */
-				local_buf_state &= ~BM_SECOND_CHANCE;
+				if (spent < BUF_SWEEP_CLAIM_THRESHOLD)
+				{
+					local_buf_state &= ~BM_SECOND_CHANCE;
+
+					if (pg_atomic_compare_exchange_u64(&buf->state,
+													   &old_buf_state,
+													   local_buf_state))
+					{
+						spent++;
+						trycounter = NBuffers;
+						break;
+					}
+					continue;
+				}
+
+				/*
+				 * Take this buffer even though it still had a chance.  The bit
+				 * is cleared when the victim is reused, so there is no need to
+				 * spend the chance first.
+				 */
+				pg_atomic_fetch_add_u64(&StrategyControl->numSweepClaims, 1);
+				local_buf_state += BUF_REFCOUNT_ONE;
 
 				if (pg_atomic_compare_exchange_u64(&buf->state, &old_buf_state,
 												   local_buf_state))
 				{
-					trycounter = NBuffers;
-					break;
+					/* Found a usable buffer */
+					if (strategy != NULL)
+						AddBufferToRing(strategy, buf);
+					*buf_state = local_buf_state;
+
+					TrackNewBufferPin(BufferDescriptorGetBuffer(buf));
+
+					return buf;
 				}
 			}
 			else
@@ -419,6 +469,7 @@ StrategyCtlShmemInit(void *arg)
 	/* Clear statistics */
 	StrategyControl->completePasses = 0;
 	pg_atomic_init_u32(&StrategyControl->numBufferAllocs, 0);
+	pg_atomic_init_u64(&StrategyControl->numSweepClaims, 0);
 
 	/* No pending notification */
 	StrategyControl->bgwprocno = -1;
@@ -787,3 +838,17 @@ StrategyRejectBuffer(BufferAccessStrategy strategy, BufferDesc *buf, bool from_r
 
 	return true;
 }
+
+/*
+ * StrategySweepClaims -- allocations that took a buffer that had a chance left
+ *
+ * Returns a monotonically increasing count of allocations that gave up on the
+ * second-chance rule because the sweep could not find a buffer without one.
+ * The background writer compares successive readings: a rising count means the
+ * foreground sweep is starving for candidates.
+ */
+uint64
+StrategySweepClaims(void)
+{
+	return pg_atomic_read_u64(&StrategyControl->numSweepClaims);
+}
diff --git a/src/include/storage/buf_internals.h b/src/include/storage/buf_internals.h
index ba640eb1001..92da301b878 100644
--- a/src/include/storage/buf_internals.h
+++ b/src/include/storage/buf_internals.h
@@ -30,6 +30,28 @@
 #include "utils/relcache.h"
 #include "utils/resowner.h"
 
+/*
+ * How many second chances a single StrategyGetBuffer() call will spend before
+ * it stops looking for a buffer that has none left and takes the next unpinned
+ * buffer it sees.
+ *
+ * Spending a chance is progress -- the buffer becomes reclaimable on the next
+ * pass -- so the sweep treats it as a reason to keep scanning.  When the pool
+ * is re-touched faster than the hand can traverse it, chances are granted
+ * again before the hand returns, and a single allocation can spend them
+ * indefinitely without ever finding a victim.  Giving up after a fixed number
+ * makes the work one allocation can do a constant instead.
+ *
+ * The value trades that bound against replacement quality, and both sides have
+ * been measured.  The worst case an allocation reaches is almost exactly this
+ * threshold plus two, so the bound is a direct function of it.  The cost is
+ * taking a buffer that still had a chance left: on a workload whose hot set
+ * most queries re-read, a threshold of 4 leaves 4,519 hot pages non-resident
+ * and costs 1.8 points of hot-set hit ratio, 32 leaves 11 pages and costs 0.03
+ * points, and at 128 nothing hot is displaced and the cost is not measurable.
+ */
+#define BUF_SWEEP_CLAIM_THRESHOLD	128
+
 /*
  * Buffer state is a single 64-bit variable where following data is combined.
  *
@@ -595,6 +617,7 @@ extern bool StrategyRejectBuffer(BufferAccessStrategy strategy,
 								 BufferDesc *buf, bool from_ring);
 
 extern int	StrategySyncStart(uint32 *complete_passes, uint32 *num_buf_alloc);
+extern uint64 StrategySweepClaims(void);
 extern void StrategyNotifyBgWriter(int bgwprocno);
 
 /* buf_table.c */
-- 
2.56.0

