Subject: Extensible sync handler registration (register_sync_handler)

From: Greg Lamberson <[email-masked]>
Date: Fri, 10 Apr 2026 21:46:44 +0000

Hackers,

Motivation:

I am working on Lamstore, an extent based storage backend whose data lives
at byte offsets inside a volume file rather than as md segment files.
Lamstore uses a custom storage manager to route backend I/O, which works
today against stock PostgreSQL and Percona Server for PostgreSQL 18. The
checkpoint fsync pipeline, however, is a dead end: syncsw[] in sync.c is
a static const SyncOps[] indexed by the SyncRequestHandler enum, and there
is no way for an extension to install its own dispatch entry. An extension
cannot reuse SYNC_HANDLER_MD because mdsyncfiletag() resolves paths via
relpathperm() plus OpenTransientFile() plus fsync(), which does not reach
Lamstore storage.

The attached two patch series adds a dynamic register_sync_handler() API
to sync.c, parallel to RegisterCustomRmgr and the smgr_register shape
being developed on CF 5616. Built in handlers (MD, CLOG, commit_ts,
multixact_offset, multixact_member) keep their existing enum indices 0
through 4. Extensions get IDs starting at a new SYNC_HANDLER_FIRST_DYNAMIC.
The patch is strictly additive: zero WAL format changes, zero FileTag
layout changes, zero shared memory changes, zero catalog involvement.

Background:

Thomas Munro's 2019 refactor of the checkpointer fsync queue (commit
3eb77eba5a5, PG12) explicitly anticipated non md consumers. The commit
message reads:

  "For now only md.c uses the new interface, but other users are being
  proposed. Since there may be use cases that are not strictly SMGR
  implementations, use a new function table for sync handlers rather
  than extending the traditional SMGR one."

Seven years later the framework is in place but the registration
mechanism has never been added. This patch closes that gap.

Two reviewers have already voiced needs that this API makes trivial. In
Tristan Partin's 2024-01-12 message on the CF 5616 thread
(CAEze2WgMySu2suO_TLvFyGY3URa4mAx22WeoEicnK=[email-masked]),
Heikki Linnakangas wrote:

  "[I would like] a debugging tool that checks that we're not missing
  any fsyncs. I bumped into a few missing fsync bugs with unlogged
  tables lately and a tool like that would've been very helpful."

And Andres Freund wrote:

  "I've been thinking that we need a validation layer for fsyncs, it's
  too hard to get right without testing. My thought was that we could
  keep a shared hash table of all files created / dirtied at the fd
  layer, with the filename as key and the value the current LSN. We'd
  delete files from it when they're fsynced."

Both needs become straightforward with register_sync_handler(): a
validation extension loads in shared_preload_libraries, registers its
own handler, and observes every sync request flowing through
ProcessSyncRequests() regardless of the underlying storage manager.
This is strictly stronger than the fsync_checker extension in CF 5616
v5+, which wraps md and can only observe md backed data.

What it does now:

syncsw[] is declared as:

  static const SyncOps syncsw[] = {
      [SYNC_HANDLER_MD]               = { mdsyncfiletag, ... },
      [SYNC_HANDLER_CLOG]             = { clogsyncfiletag, ... },
      [SYNC_HANDLER_COMMIT_TS]        = { committssyncfiletag, ... },
      [SYNC_HANDLER_MULTIXACT_OFFSET] = { multixactoffsetssyncfiletag, ... },
      [SYNC_HANDLER_MULTIXACT_MEMBER] = { multixactmemberssyncfiletag, ... },
  };

Adding a new handler requires patching core. No extension path exists.

What it will do:

The patch converts syncsw[] from static const SyncOps[] to a heap
allocated static SyncOps * grown via doubling repalloc, bounded by
shared_preload_libraries count. Built in handlers are pre-registered at
their existing enum indices by a new InitSyncHandlers() called from
PostmasterMain before process_shared_preload_libraries(). Extensions
call register_sync_handler() from _PG_init and receive a stable int16
handler ID, which they store in their FileTag.handler field at register
time:

  /* src/include/storage/sync.h */
  typedef struct SyncOps {
      int  (*sync_syncfiletag)   (const FileTag *ftag, char *path);
      int  (*sync_unlinkfiletag) (const FileTag *ftag, char *path);
      bool (*sync_filetagmatches)(const FileTag *ftag, const FileTag *candidate);
  } SyncOps;

  extern int16 register_sync_handler(const SyncOps *ops, const char *name);

  typedef enum SyncRequestHandler {
      SYNC_HANDLER_MD               = 0,
      SYNC_HANDLER_CLOG             = 1,
      SYNC_HANDLER_COMMIT_TS        = 2,
      SYNC_HANDLER_MULTIXACT_OFFSET = 3,
      SYNC_HANDLER_MULTIXACT_MEMBER = 4,
      SYNC_HANDLER_FIRST_DYNAMIC    = 5,
      SYNC_HANDLER_MAX              = INT16_MAX,
      SYNC_HANDLER_NONE             = -1,
  } SyncRequestHandler;

Dispatch is syncsw[idx].fn() both before and after. Each caller that
dispatches through the table (ProcessSyncRequests, SyncPostCheckpoint,
RememberSyncRequest) caches the base pointer in a local SyncOps *ops
at function entry so the compiler keeps it in a register for the
lifetime of the function, matching the register allocation the
pre-patch static const array received. Verified with objdump on GCC
14.2 at -O2 that the per-dispatch instruction sequence (movswq, mov,
mov, lea, call *(%r14,%rax,8)) is byte identical between stock and
patched builds. The only remaining delta is one additional memory
load at function entry to fetch the syncsw pointer (mov 0x0(%rip),%r14
versus stock's lea 0x0(%rip),%r14): a single L1 cache hit, paid once
per call to ProcessSyncRequests, not per dispatch.

The 0002 patch adds src/test/modules/test_sync_handler with a TAP test
verifying registration, dispatch, HASH_BLOBS coalescing, and cycle_ctr
skip semantics. Test layout mirrors fsync_checker in CF 5616 v5+.

Risk:

Strictly additive. Zero WAL format changes. Zero FileTag layout
changes. Zero shared memory changes. Zero catalog involvement.
Per-dispatch assembly is byte identical to stock (see above). Built
in enum values (SYNC_HANDLER_MD=0 et al) are preserved. ABI safe.
Recovery path unaffected: extensions cannot register during recovery
because InitSyncHandlers() runs in PostmasterMain before any child
process, and the built ins are all pre-registered at fixed IDs. No
behaviour change when no extension registers.

Pre-empting Andres Freund's 2023 objections to CF 4428 v1:

Your four review comments on the earlier smgr_register prototype
(postgrespro.com thread id 2654666, 2023-07-01) apply here by analogy.
Addressing them directly:

1. "Not a good place to initialize, we'll need it in multiple places
   that way. How about putting it into BaseInit()?"

   Our InitSyncHandlers() is called from a single site, PostmasterMain,
   immediately before process_shared_preload_libraries(). Built ins
   register via a private sync_handler_register_internal() from that
   one site. Child processes inherit the array via fork(), which is a
   full POSIX memory barrier. No per-process re-initialization. This
   is the same lifecycle pattern as RegisterCustomRmgr.

2. "This adds another level of indirection. I would rather limit the
   number of registerable smgrs than do that."

   Doubling repalloc is bounded by shared_preload_libraries count
   (small). Amortized O(1) cost at preload time. Registration happens
   exactly once per extension per postmaster startup, before any
   backend exists. Per-dispatch hot path cost is zero by construction:
   each caller hoists syncsw into a local SyncOps *ops at function
   entry, so the compiler keeps the base pointer in a register and
   the per-entry dispatch compiles to byte-identical assembly as the
   pre-patch static const array (verified with objdump, see Risk
   paragraph above). The only measurable delta is one additional
   memory load at function entry, paid once per ProcessSyncRequests
   call (not per dispatch). If you still prefer a hard cap I am happy
   to add SYNC_HANDLER_MAX_DYNAMIC as a compile-time constant.

3. "Huh, what's that about?" (on pg_compiler_barrier in registration)

   Not included. Registration is single threaded during preload. No
   barrier needed.

4. "It looked to me like you determined this globally, why do we need
   it in every entry then?" (on per entry size fields)

   Not applicable. SyncOps is pure function pointers. No per entry
   size tracking.

Relationship to CF 5616:

This patch is a narrow focused companion to CF 5616 (Extensible storage
manager API), not a dependency or replacement. CF 5616 makes smgrsw[]
dynamic; this patch does the same for syncsw[]. The two are orthogonal:
none of 5616 v6's six sub patches touch sync.c or sync.h. This patch
applies cleanly against master today without 5616, and introduces none
of 5616's unresolved design questions (hook-vs-registration, catalog
recovery, per tablespace vs per relation config, GUC chaining). If 5616
lands later the two compose naturally: extensions call smgr_register()
and register_sync_handler() back to back in their _PG_init.

Percona carryover:

The patch also applies cleanly to percona/postgres PSP_REL_18_STABLE
for our production deployment. sync.c and sync.h have no Percona
specific changes on that branch (verified via gh api), so the same
patch applies identically modulo mechanical offsets. Upstream master
is the primary submission venue. A companion PR against Percona's
branch will reference this thread.

Verified locally:

  * make check-world green on master (PG 19devel) with both patches
    applied
  * make -C src/test/modules/test_sync_handler check green on both
    upstream master and percona/postgres PSP_REL_18_STABLE

I will open a CF 59 (PG20-1) entry today and link this thread's
Message-Id.

Thanks for reading. Feedback, counterproposals, and design pushback
all welcome.

Greg Lamberson
Lamco Development
[email-masked]

---

From: Greg Lamberson <[email-masked]>
Date: Thu, 23 Apr 2026 19:44:08 -0500

v2 attached.  While running the v1 patches under CFBot (and
subsequently reproducing locally with CPPFLAGS=-DEXEC_BACKEND on
Linux), I discovered that v1 mis-handles processes that do not
inherit the postmaster's address space via fork().  v2 fixes this
and also addresses several unrelated items that I should have
caught in v1 self-review.

Changes in v2-0001:

* EXEC_BACKEND load-order fix.  On platforms without fork()
  (Windows, plus any build with CPPFLAGS=-DEXEC_BACKEND), each
  child process re-runs process_shared_preload_libraries() in its
  own fresh address space.  In v1, extension _PG_init() could
  reach register_sync_handler() before the child had called
  InitSync(), so the first dynamic registration landed at ID 0,
  colliding with SYNC_HANDLER_MD, and the idempotent guard in
  InitSyncHandlers() ("if NSyncHandlers != 0 return") then
  suppressed built-in registration entirely for the rest of that
  process's lifetime.  v2 calls InitSyncHandlers() at the top of
  register_sync_handler() and replaces the counter-based guard
  with an explicit builtin_sync_handlers_registered flag.  The
  v1 test that reported "got id=0" on Windows and FreeBSD CFBot
  now reports id=5 under both fork and EXEC_BACKEND.

* Documentation.  v1 shipped without SGML docs; v2 adds
  doc/src/sgml/custom-sync-handler.sgml (modeled on
  custom-rmgr.sgml) and registers it in filelist.sgml and
  postgres.sgml.  The doc build is clean.

* Error-message style.  The four errmsg() strings that embedded
  function names ("register_sync_handler: ...",
  "test_sync_handler: ...") are reworded per the error-message
  style guide.  The two developer-bug paths that used
  ERRCODE_NULL_VALUE_NOT_ALLOWED are changed to elog(FATAL, ...)
  since they cannot be triggered from SQL.

* Stale comment.  The v1 comment in
  sync_handler_register_internal() claiming "fork() is full
  POSIX barrier" was accurate only on fork-based platforms.
  Rewritten to cover both fork and EXEC_BACKEND paths.

* SYNC_HANDLER_NONE enum value.  Previously implicit 5 (after
  MULTIXACT_MEMBER=4), changed in v1 to explicit -1 so that a
  sentinel "no handler" value cannot be confused with a valid
  index.  I audited uses in master: the only references are
  != comparisons in slru.c at lines 1057, 1442, and 1558, which
  are value-agnostic.  Flagging explicitly here because it is
  an ABI-visible enum value change.

Changes in v2-0002:

* Error-message style fixes in the test module to match the
  core-side cleanups above.
* pgindent pass (required adding TshSharedState via
  --list-of-typedefs since it is a test-module-local type).

Verification for v2:

* make check-world under autoconf on Linux, fork-based: all PASS
* make check-world under autoconf on Linux,
  CPPFLAGS=-DEXEC_BACKEND: all PASS
* meson test under Linux with c_args='-DEXEC_BACKEND':
  344 OK / 34 SKIP / 0 FAIL
* test_sync_handler/001_basic: 5/5 under all four combinations
* src/test/recovery: 597/597 under -DEXEC_BACKEND
* test_slru: 18/18 under -DEXEC_BACKEND (SLRU is the main user
  of SYNC_HANDLER_NONE; the enum value change is safe)
* pgindent, pgperltidy (20230309), pgperlcritic: clean
* headerscheck on src/include/storage/sync.h in regular and
  --cplusplus modes: clean
* doc/src/sgml builds cleanly; new chapter renders as expected

I apologize for the v1 verification gap.  v1's "make check-world
green" claim was accurate on fork-based Linux only and did not
exercise EXEC_BACKEND; that is the single most important reason
the Windows failure slipped through.  My pre-submission checklist
now includes the -DEXEC_BACKEND path.

Thanks,
Greg
From cd1cd0e668d6f7d7f315fd2e6aa41b81c9a4b724 Mon Sep 17 00:00:00 2001
From: Greg Lamberson <[email-masked]>
Date: Fri, 10 Apr 2026 07:27:14 -0500
Subject: [PATCH v2 1/2] Make sync.c syncsw[] extensible via
 register_sync_handler()

Introduce a public extension API, register_sync_handler(), that lets
extensions install their own entries in the sync.c dispatch table.
This enables storage-related extensions to participate in the
checkpoint fsync pipeline without faking md.c segments or bypassing
sync.c's request coalescing and cancellation machinery.

The previously static syncsw[] array becomes a heap-allocated
dispatch table populated in two phases: the five built-in handlers
(MD, CLOG, commit_ts, multixact_offset, multixact_member) are
registered via InitSyncHandlers() before process_shared_preload_-
libraries(), and extension _PG_init() calls receive sequentially
assigned IDs starting at SYNC_HANDLER_FIRST_DYNAMIC.  Registration
is forbidden after process_shared_preload_libraries_done is set.

InitSyncHandlers() is called from both PostmasterMain() (for the
fork() path) and from register_sync_handler() itself (for the
EXEC_BACKEND path, where each child re-runs shared_preload_libraries
in its own address space and may reach an extension's registration
call before it has called InitSync()).  An explicit
builtin_sync_handlers_registered flag guards against repeated
built-in registration.

SYNC_HANDLER_NONE is changed from its previous implicit value of 5
to an explicit -1 so that the "no handler" sentinel cannot be
confused with a valid handler index.  The only consumers in
core are value-agnostic != comparisons in slru.c.

Documentation: doc/src/sgml/custom-sync-handler.sgml, modeled on
doc/src/sgml/custom-rmgr.sgml.

Discussion: https://postgr.es/m/[email-masked]
---
 doc/src/sgml/custom-sync-handler.sgml | 118 +++++++++++
 doc/src/sgml/filelist.sgml            |   1 +
 doc/src/sgml/postgres.sgml            |   1 +
 src/backend/postmaster/postmaster.c   |  11 +
 src/backend/storage/sync/sync.c       | 278 ++++++++++++++++++++++----
 src/include/storage/sync.h            |  64 +++++-
 6 files changed, 432 insertions(+), 41 deletions(-)
 create mode 100644 doc/src/sgml/custom-sync-handler.sgml

diff --git a/doc/src/sgml/custom-sync-handler.sgml b/doc/src/sgml/custom-sync-handler.sgml
new file mode 100644
index 00000000000..6d95efe7440
--- /dev/null
+++ b/doc/src/sgml/custom-sync-handler.sgml
@@ -0,0 +1,118 @@
+<!-- doc/src/sgml/custom-sync-handler.sgml -->
+
+<chapter id="custom-sync-handler">
+ <title>Custom Sync Handlers for Extensions</title>
+
+ <para>
+  This chapter explains the interface between the core
+  <productname>PostgreSQL</productname> system and custom sync handlers,
+  which enable extensions to participate in the checkpoint
+  <function>fsync</function> pipeline implemented in
+  <filename>src/backend/storage/sync/sync.c</filename>.
+ </para>
+
+ <para>
+  Extensions that manage storage outside the standard relation-file layout,
+  such as a <link linkend="tableam">Table Access Method</link> that stores
+  its data in a non-file format, may need their data to be
+  <function>fsync</function>ed at checkpoint time in the same manner as the
+  built-in handlers do for relation segments, <acronym>CLOG</acronym>,
+  <structname>commit_ts</structname>, and multixact data.  A custom sync
+  handler lets an extension register its own sync callback, participate in
+  the same request coalescing and cancellation mechanisms, and benefit from
+  the checkpointer's batching and <varname>cycle_ctr</varname> semantics
+  without reimplementing the machinery or faking its data as
+  <function>md.c</function> segments.
+ </para>
+
+ <para>
+  To create a custom sync handler, first define a
+  <structname>SyncOps</structname> structure containing the handler
+  callbacks.  The structure is defined in
+  <filename>src/include/storage/sync.h</filename>:
+<programlisting>
+typedef struct SyncOps
+{
+    int  (*sync_syncfiletag) (const FileTag *ftag, char *path);
+    int  (*sync_unlinkfiletag) (const FileTag *ftag, char *path);
+    bool (*sync_filetagmatches) (const FileTag *ftag,
+                                 const FileTag *candidate);
+} SyncOps;
+</programlisting>
+  Only <structfield>sync_syncfiletag</structfield> is required; the other
+  two pointers may be <literal>NULL</literal> if the handler does not
+  participate in <literal>SYNC_UNLINK_REQUEST</literal> or
+  <literal>SYNC_FILTER_REQUEST</literal> flows.  This mirrors the built-in
+  handlers for <acronym>CLOG</acronym>, <structname>commit_ts</structname>,
+  and multixact data, which only define
+  <structfield>sync_syncfiletag</structfield>.
+ </para>
+
+ <para>
+  Then, register the handler and record the returned handler ID:
+<programlisting>
+extern int16 register_sync_handler(const SyncOps *ops, const char *name);
+</programlisting>
+  <function>register_sync_handler</function> must be called from the
+  extension module's <link linkend="xfunc-c-dynload">_PG_init</link>
+  function while <varname>shared_preload_libraries</varname> is still being
+  loaded; calls made after that phase has completed raise
+  <literal>FATAL</literal>.  The extension must therefore be placed in
+  <xref linkend="guc-shared-preload-libraries"/>.
+ </para>
+
+ <para>
+  The returned <type>int16</type> handler ID is the value the extension
+  stores in <structfield>FileTag.handler</structfield> when queuing sync
+  requests via <function>RegisterSyncRequest</function>.  Extension handler
+  IDs are assigned sequentially starting at
+  <literal>SYNC_HANDLER_FIRST_DYNAMIC</literal>, which is the first value
+  after the built-in handler IDs <literal>SYNC_HANDLER_MD</literal>,
+  <literal>SYNC_HANDLER_CLOG</literal>,
+  <literal>SYNC_HANDLER_COMMIT_TS</literal>,
+  <literal>SYNC_HANDLER_MULTIXACT_OFFSET</literal>, and
+  <literal>SYNC_HANDLER_MULTIXACT_MEMBER</literal>.  The assigned ID is
+  stable for the lifetime of a given server configuration, that is, it
+  does not change between backends, the checkpointer, or auxiliary
+  processes within a single postmaster lifetime.  Because sync requests
+  live only in the checkpointer's in-memory pending-operations hash table
+  and are not persisted across server restarts, the assigned ID does not
+  need to be stable across restarts or across changes to
+  <varname>shared_preload_libraries</varname>.
+ </para>
+
+ <para>
+  The <structname>FileTag</structname> structure passed to the handler
+  callbacks has a small fixed layout that all handlers share.  Its
+  contents are opaque to <filename>sync.c</filename>; each handler
+  interprets the fields according to its own convention.  Because
+  <filename>sync.c</filename> deduplicates pending sync requests by
+  hashing the raw bytes of the <structname>FileTag</structname>
+  (<literal>HASH_BLOBS</literal>), every field including any padding
+  must be zeroed before the structure is populated, otherwise logically
+  identical tags with different padding bytes will not coalesce into a
+  single callback invocation.  A simple <function>memset</function> to
+  zero before assignment is sufficient.
+ </para>
+
+ <para>
+  The <filename>src/test/modules/test_sync_handler</filename> module
+  contains a minimal working example, which demonstrates registration
+  from <function>_PG_init</function>, the per-checkpoint callback
+  dispatch, request coalescing via <literal>HASH_BLOBS</literal>, and
+  the <varname>cycle_ctr</varname> skip behaviour on idle checkpoints.
+  The TAP test in that module also serves as a copy-paste starting point
+  for new sync-handler extensions.
+ </para>
+
+ <note>
+  <para>
+   The extension must remain in <varname>shared_preload_libraries</varname>
+   as long as any data managed by its sync handler may require
+   checkpointing.  If the extension is removed while such data exists,
+   <productname>PostgreSQL</productname> will not be able to dispatch
+   pending sync requests for that data, which may lead to durability
+   issues at the next checkpoint.
+  </para>
+ </note>
+</chapter>
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 25a85082759..c6a4f1745ae 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -113,6 +113,7 @@
 <!ENTITY wal-for-extensions SYSTEM "wal-for-extensions.sgml">
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
+<!ENTITY custom-sync-handler SYSTEM "custom-sync-handler.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
 <!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 2101442c90f..c91877c8dd8 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -259,6 +259,7 @@ break is not needed in a wider output rendering.
   &tableam;
   &indexam;
   &wal-for-extensions;
+  &custom-sync-handler;
   &indextypes;
   &storage;
   &transaction;
diff --git a/src/backend/postmaster/postmaster.c b/src/backend/postmaster/postmaster.c
index 6e0f41d2661..8d2ab37ce26 100644
--- a/src/backend/postmaster/postmaster.c
+++ b/src/backend/postmaster/postmaster.c
@@ -116,6 +116,7 @@
 #include "storage/pmsignal.h"
 #include "storage/proc.h"
 #include "storage/shmem_internal.h"
+#include "storage/sync.h"
 #include "tcop/backend_startup.h"
 #include "tcop/tcopprot.h"
 #include "utils/datetime.h"
@@ -929,6 +930,16 @@ PostmasterMain(int argc, char *argv[])
 	 */
 	RegisterBuiltinShmemCallbacks();
 
+	/*
+	 * Register the built-in sync handlers (md, CLOG, commit_ts,
+	 * multixact_offset, multixact_member).  This must happen before
+	 * process_shared_preload_libraries() so that extensions which
+	 * call register_sync_handler() from their _PG_init() receive IDs
+	 * starting at SYNC_HANDLER_FIRST_DYNAMIC instead of colliding
+	 * with the built-in slots.
+	 */
+	InitSyncHandlers();
+
 	/*
 	 * process any libraries that should be preloaded at postmaster start
 	 */
diff --git a/src/backend/storage/sync/sync.c b/src/backend/storage/sync/sync.c
index 2c964b6f3d9..ff6239680cf 100644
--- a/src/backend/storage/sync/sync.c
+++ b/src/backend/storage/sync/sync.c
@@ -80,50 +80,219 @@ static CycleCtr checkpoint_cycle_ctr = 0;
 #define UNLINKS_PER_ABSORB		10
 
 /*
- * Function pointers for handling sync and unlink requests.
+ * Sync handler dispatch table.
+ *
+ * Populated by InitSyncHandlers() for the five built-in handlers (MD,
+ * CLOG, commit_ts, multixact_offset, multixact_member) and by
+ * register_sync_handler() for handlers installed by extensions from
+ * their _PG_init() function.  After shared_preload_libraries has
+ * finished loading, syncsw[] is effectively immutable for the life
+ * of the process.
+ *
+ * Every process that can call into sync.c (postmaster, backends, and
+ * the checkpointer and other auxiliary processes) obtains its own
+ * populated syncsw[] either by inheriting it via fork() from the
+ * postmaster, or, on EXEC_BACKEND platforms where there is no fork(),
+ * by re-running InitSyncHandlers() and process_shared_preload_libraries()
+ * in its own address space during startup.
+ *
+ * SyncOps itself is defined in sync.h so that extensions can declare
+ * const SyncOps instances at file scope.
  */
-typedef struct SyncOps
-{
-	int			(*sync_syncfiletag) (const FileTag *ftag, char *path);
-	int			(*sync_unlinkfiletag) (const FileTag *ftag, char *path);
-	bool		(*sync_filetagmatches) (const FileTag *ftag,
-										const FileTag *candidate);
-} SyncOps;
+static SyncOps *syncsw = NULL;
+static const char **sync_handler_names = NULL;
+static int	NSyncHandlers = 0;
+static int	sync_handlers_capacity = 0;
+static bool builtin_sync_handlers_registered = false;
 
 /*
- * These indexes must correspond to the values of the SyncRequestHandler enum.
+ * Built-in SyncOps, registered in enum order during InitSync() so that
+ * SYNC_HANDLER_MD == 0, SYNC_HANDLER_CLOG == 1, etc.
  */
-static const SyncOps syncsw[] = {
-	/* magnetic disk */
-	[SYNC_HANDLER_MD] = {
-		.sync_syncfiletag = mdsyncfiletag,
-		.sync_unlinkfiletag = mdunlinkfiletag,
-		.sync_filetagmatches = mdfiletagmatches
-	},
-	/* pg_xact */
-	[SYNC_HANDLER_CLOG] = {
-		.sync_syncfiletag = clogsyncfiletag
-	},
-	/* pg_commit_ts */
-	[SYNC_HANDLER_COMMIT_TS] = {
-		.sync_syncfiletag = committssyncfiletag
-	},
-	/* pg_multixact/offsets */
-	[SYNC_HANDLER_MULTIXACT_OFFSET] = {
-		.sync_syncfiletag = multixactoffsetssyncfiletag
-	},
-	/* pg_multixact/members */
-	[SYNC_HANDLER_MULTIXACT_MEMBER] = {
-		.sync_syncfiletag = multixactmemberssyncfiletag
-	}
+static const SyncOps builtin_md_ops = {
+	.sync_syncfiletag = mdsyncfiletag,
+	.sync_unlinkfiletag = mdunlinkfiletag,
+	.sync_filetagmatches = mdfiletagmatches,
+};
+static const SyncOps builtin_clog_ops = {
+	.sync_syncfiletag = clogsyncfiletag,
+};
+static const SyncOps builtin_committs_ops = {
+	.sync_syncfiletag = committssyncfiletag,
+};
+static const SyncOps builtin_multixact_offset_ops = {
+	.sync_syncfiletag = multixactoffsetssyncfiletag,
+};
+static const SyncOps builtin_multixact_member_ops = {
+	.sync_syncfiletag = multixactmemberssyncfiletag,
 };
 
+/*
+ * Internal helper that adds an entry to syncsw[] without performing the
+ * preload-phase check.  Used by InitSync() to install the built-in
+ * handlers, which must be present in every process that calls into
+ * sync.c (including the checkpointer, which runs after
+ * shared_preload_libraries has finished loading).
+ */
+static int16
+sync_handler_register_internal(const SyncOps *ops, const char *name)
+{
+	int16		my_id;
+	MemoryContext old;
+
+	if (ops == NULL || ops->sync_syncfiletag == NULL)
+		elog(FATAL, "sync handler registration requires a non-NULL sync callback");
+
+	if (name == NULL || *name == '\0')
+		elog(FATAL, "sync handler name must not be empty");
+
+	if (NSyncHandlers >= SYNC_HANDLER_MAX)
+		ereport(FATAL,
+				(errcode(ERRCODE_CONFIGURATION_LIMIT_EXCEEDED),
+				 errmsg("too many sync handlers registered (maximum is %d)",
+						SYNC_HANDLER_MAX)));
+
+	old = MemoryContextSwitchTo(TopMemoryContext);
+
+	if (NSyncHandlers >= sync_handlers_capacity)
+	{
+		int			new_cap = (sync_handlers_capacity == 0)
+			? 8
+			: sync_handlers_capacity * 2;
+
+		if (new_cap > SYNC_HANDLER_MAX)
+			new_cap = SYNC_HANDLER_MAX;
+
+		if (syncsw == NULL)
+		{
+			syncsw = palloc(sizeof(SyncOps) * new_cap);
+			sync_handler_names = palloc(sizeof(char *) * new_cap);
+		}
+		else
+		{
+			syncsw = repalloc(syncsw, sizeof(SyncOps) * new_cap);
+			sync_handler_names = repalloc(sync_handler_names,
+										  sizeof(char *) * new_cap);
+		}
+		sync_handlers_capacity = new_cap;
+	}
+
+	my_id = (int16) NSyncHandlers++;
+	memcpy(&syncsw[my_id], ops, sizeof(SyncOps));
+	sync_handler_names[my_id] = pstrdup(name);
+
+	MemoryContextSwitchTo(old);
+
+	/*
+	 * No barrier needed: registration only happens during
+	 * shared_preload_libraries load, which is single-threaded.  On fork-based
+	 * platforms backends and auxiliary processes inherit the fully-populated
+	 * array from the postmaster via fork().  On EXEC_BACKEND platforms each
+	 * child repeats the single-threaded registration sequence in its own
+	 * address space during startup. In either case, the array is immutable by
+	 * the time any concurrent reader can observe it.
+	 */
+	return my_id;
+}
+
+/*
+ * Public registration entry point for extensions.  See sync.h for the
+ * contract.
+ *
+ * Extensions must call this from their _PG_init() while
+ * shared_preload_libraries is still being processed; later calls raise
+ * FATAL.  Built-in handlers bypass this guard via
+ * sync_handler_register_internal() because the checkpointer and other
+ * auxiliary processes call InitSync() after preload has finished, and
+ * the built-in dispatch table must still be populated in those
+ * processes.
+ *
+ * On EXEC_BACKEND platforms each child process repeats
+ * process_shared_preload_libraries() in its own fresh address space
+ * during startup, and an extension's _PG_init() can reach this
+ * function before the child has called InitSync().  Call
+ * InitSyncHandlers() here to ensure the five built-in handlers always
+ * occupy IDs 0..SYNC_HANDLER_FIRST_DYNAMIC-1 before any dynamic ID is
+ * assigned, which keeps handler IDs consistent across every process
+ * that dispatches sync requests.  InitSyncHandlers() is idempotent.
+ */
+int16
+register_sync_handler(const SyncOps *ops, const char *name)
+{
+	if (process_shared_preload_libraries_done)
+		ereport(FATAL,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("sync handler registration is only permitted while shared_preload_libraries is being loaded")));
+
+	InitSyncHandlers();
+
+	return sync_handler_register_internal(ops, name);
+}
+
+/*
+ * Register the built-in sync handlers.
+ *
+ * This MUST run before any call to register_sync_handler() from
+ * extension _PG_init() code, so that the built-in handlers occupy
+ * their canonical IDs (SYNC_HANDLER_MD = 0, SYNC_HANDLER_CLOG = 1,
+ * etc.) and extension handlers are assigned IDs >=
+ * SYNC_HANDLER_FIRST_DYNAMIC.
+ *
+ * Called from:
+ *   - PostmasterMain(), just before process_shared_preload_libraries()
+ *   - AuxiliaryProcessMain() (not currently needed because aux procs
+ *     fork from the postmaster with syncsw[] already populated, but
+ *     see the idempotent NSyncHandlers==0 guard below)
+ *   - Standalone backend init (via InitSync -> InitSyncHandlers)
+ *
+ * Idempotent: the NSyncHandlers == 0 guard ensures built-ins are
+ * registered exactly once per process. Safe to call from multiple
+ * init paths.
+ */
+void
+InitSyncHandlers(void)
+{
+	if (builtin_sync_handlers_registered)
+		return;
+
+	(void) sync_handler_register_internal(&builtin_md_ops, "md");
+	(void) sync_handler_register_internal(&builtin_clog_ops, "clog");
+	(void) sync_handler_register_internal(&builtin_committs_ops, "commit_ts");
+	(void) sync_handler_register_internal(&builtin_multixact_offset_ops,
+										  "multixact_offset");
+	(void) sync_handler_register_internal(&builtin_multixact_member_ops,
+										  "multixact_member");
+
+	builtin_sync_handlers_registered = true;
+
+	/*
+	 * Enforce the enum-to-count invariant: if a new built-in is added to the
+	 * SyncRequestHandler enum, the build will fail-fast at first boot until a
+	 * matching sync_handler_register_internal() call is added here.
+	 */
+	Assert(NSyncHandlers == SYNC_HANDLER_FIRST_DYNAMIC);
+}
+
 /*
  * Initialize data structures for the file sync tracking.
+ *
+ * This runs in processes that actually need the pendingOps hash table
+ * (standalone backends and the checkpointer). It also calls
+ * InitSyncHandlers() defensively in case this process reached here
+ * without the postmaster having done so, e.g., standalone mode.
  */
 void
 InitSync(void)
 {
+	/*
+	 * Make sure built-in handlers are registered. In the postmaster, this was
+	 * already called from PostmasterMain() before
+	 * process_shared_preload_libraries(); in standalone mode it is called
+	 * here for the first (and only) time. The NSyncHandlers guard inside
+	 * InitSyncHandlers() makes it idempotent.
+	 */
+	InitSyncHandlers();
+
 	/*
 	 * Create pending-operations hashtable if we need it.  Currently, we need
 	 * it if we are standalone (not under a postmaster) or if we are a
@@ -205,6 +374,19 @@ SyncPostCheckpoint(void)
 	int			absorb_counter;
 	ListCell   *lc;
 
+	/*
+	 * Cache the syncsw base pointer in a local for the duration of this
+	 * function. Without this, the compiler cannot hoist the load of the
+	 * mutable static pointer out of the dispatch loop, and each dispatch
+	 * costs an extra memory load plus an address-materialization LEA
+	 * (verified with objdump on GCC 14.2 -O2). With the local cached, the
+	 * per-entry dispatch compiles down to identical assembly as the pre-patch
+	 * static-const array. Safe because register_sync_handler() is forbidden
+	 * after process_shared_preload_libraries_done and syncsw is never mutated
+	 * outside registration.
+	 */
+	SyncOps    *ops = syncsw;
+
 	absorb_counter = UNLINKS_PER_ABSORB;
 	foreach(lc, pendingUnlinks)
 	{
@@ -227,9 +409,12 @@ SyncPostCheckpoint(void)
 		if (entry->cycle_ctr == checkpoint_cycle_ctr)
 			break;
 
+		Assert(entry->tag.handler >= 0 &&
+			   entry->tag.handler < NSyncHandlers);
+
 		/* Unlink the file */
-		if (syncsw[entry->tag.handler].sync_unlinkfiletag(&entry->tag,
-														  path) < 0)
+		if (ops[entry->tag.handler].sync_unlinkfiletag(&entry->tag,
+													   path) < 0)
 		{
 			/*
 			 * There's a race condition, when the database is dropped at the
@@ -301,6 +486,9 @@ ProcessSyncRequests(void)
 	uint64		longest = 0;
 	uint64		total_elapsed = 0;
 
+	/* See comment in SyncPostCheckpoint() above. */
+	SyncOps    *ops = syncsw;
+
 	/*
 	 * This is only called during checkpoints, and checkpoints should only
 	 * occur in processes that have created a pendingOps.
@@ -412,9 +600,12 @@ ProcessSyncRequests(void)
 			{
 				char		path[MAXPGPATH];
 
+				Assert(entry->tag.handler >= 0 &&
+					   entry->tag.handler < NSyncHandlers);
+
 				INSTR_TIME_SET_CURRENT(sync_start);
-				if (syncsw[entry->tag.handler].sync_syncfiletag(&entry->tag,
-																path) == 0)
+				if (ops[entry->tag.handler].sync_syncfiletag(&entry->tag,
+															 path) == 0)
 				{
 					/* Success; update statistics about sync timing */
 					INSTR_TIME_SET_CURRENT(sync_end);
@@ -506,13 +697,24 @@ RememberSyncRequest(const FileTag *ftag, SyncRequestType type)
 		HASH_SEQ_STATUS hstat;
 		PendingFsyncEntry *pfe;
 		ListCell   *cell;
+		bool		(*filetagmatches) (const FileTag *ftag,
+									   const FileTag *candidate);
+
+		Assert(ftag->handler >= 0 && ftag->handler < NSyncHandlers);
+
+		/*
+		 * Cache the per-handler filetagmatches function pointer once so both
+		 * match loops keep it in a register. See comment in
+		 * SyncPostCheckpoint().
+		 */
+		filetagmatches = syncsw[ftag->handler].sync_filetagmatches;
 
 		/* Cancel matching fsync requests */
 		hash_seq_init(&hstat, pendingOps);
 		while ((pfe = (PendingFsyncEntry *) hash_seq_search(&hstat)) != NULL)
 		{
 			if (pfe->tag.handler == ftag->handler &&
-				syncsw[ftag->handler].sync_filetagmatches(ftag, &pfe->tag))
+				filetagmatches(ftag, &pfe->tag))
 				pfe->canceled = true;
 		}
 
@@ -522,7 +724,7 @@ RememberSyncRequest(const FileTag *ftag, SyncRequestType type)
 			PendingUnlinkEntry *pue = (PendingUnlinkEntry *) lfirst(cell);
 
 			if (pue->tag.handler == ftag->handler &&
-				syncsw[ftag->handler].sync_filetagmatches(ftag, &pue->tag))
+				filetagmatches(ftag, &pue->tag))
 				pue->canceled = true;
 		}
 	}
diff --git a/src/include/storage/sync.h b/src/include/storage/sync.h
index 88290500bc9..959a4f72a52 100644
--- a/src/include/storage/sync.h
+++ b/src/include/storage/sync.h
@@ -29,8 +29,13 @@ typedef enum SyncRequestType
 } SyncRequestType;
 
 /*
- * Which set of functions to use to handle a given request.  The values of
- * the enumerators must match the indexes of the function table in sync.c.
+ * Which set of functions to use to handle a given request.  Built-in
+ * handlers occupy the fixed enum values below; extensions register
+ * additional handlers via register_sync_handler() during
+ * shared_preload_libraries initialization and receive IDs starting
+ * at SYNC_HANDLER_FIRST_DYNAMIC. The values of the built-in
+ * enumerators must match the order in which InitSync() pre-registers
+ * the corresponding SyncOps structs in sync.c.
  */
 typedef enum SyncRequestHandler
 {
@@ -39,9 +44,19 @@ typedef enum SyncRequestHandler
 	SYNC_HANDLER_COMMIT_TS,
 	SYNC_HANDLER_MULTIXACT_OFFSET,
 	SYNC_HANDLER_MULTIXACT_MEMBER,
-	SYNC_HANDLER_NONE,
+
+	/* Extensions' dynamic handler IDs start here. */
+	SYNC_HANDLER_FIRST_DYNAMIC,
+
+	/*
+	 * Sentinel for "no handler": fits in int16, outside the valid ID range so
+	 * it cannot be confused with any registered handler.
+	 */
+	SYNC_HANDLER_NONE = -1,
 } SyncRequestHandler;
 
+#define SYNC_HANDLER_MAX	INT16_MAX
+
 /*
  * A tag identifying a file.  Currently it has the members required for md.c's
  * usage, but sync.c has no knowledge of the internal structure, and it is
@@ -55,6 +70,25 @@ typedef struct FileTag
 	uint64		segno;
 } FileTag;
 
+/*
+ * Dispatch table entry for a sync handler.  Public so extensions can
+ * define their own SyncOps and pass them to register_sync_handler().
+ *
+ * sync_syncfiletag is required.  sync_unlinkfiletag and
+ * sync_filetagmatches may be NULL if the handler does not support
+ * SYNC_UNLINK_REQUEST or SYNC_FILTER_REQUEST respectively, matching
+ * the pattern of the built-in CLOG/commit_ts/multixact handlers which
+ * only define sync_syncfiletag.
+ */
+typedef struct SyncOps
+{
+	int			(*sync_syncfiletag) (const FileTag *ftag, char *path);
+	int			(*sync_unlinkfiletag) (const FileTag *ftag, char *path);
+	bool		(*sync_filetagmatches) (const FileTag *ftag,
+										const FileTag *candidate);
+} SyncOps;
+
+extern void InitSyncHandlers(void);
 extern void InitSync(void);
 extern void SyncPreCheckpoint(void);
 extern void SyncPostCheckpoint(void);
@@ -63,4 +97,28 @@ extern void RememberSyncRequest(const FileTag *ftag, SyncRequestType type);
 extern bool RegisterSyncRequest(const FileTag *ftag, SyncRequestType type,
 								bool retryOnError);
 
+/*
+ * Register a custom sync handler.  Returns the assigned handler ID
+ * which the extension stores in FileTag.handler when queueing sync
+ * requests via RegisterSyncRequest().
+ *
+ * MUST be called during shared_preload_libraries initialization
+ * (before process_shared_preload_libraries_done is set); later calls
+ * raise FATAL.  `name` is used for error messages and is pstrdup'd
+ * into TopMemoryContext by the caller; callers do not need to keep
+ * the buffer alive.
+ *
+ * `ops->sync_syncfiletag` is required; the other two pointers may
+ * be NULL if the handler does not participate in SYNC_UNLINK_REQUEST
+ * or SYNC_FILTER_REQUEST flows.
+ *
+ * The returned ID is stable for the lifetime of the postmaster.
+ * Sync requests live only in the checkpointer's in-memory pendingOps
+ * hash table (they are not persisted across restarts), so there is
+ * no cross-restart stability requirement beyond the same
+ * shared_preload_libraries order that smgr_register() already relies
+ * on.
+ */
+extern int16 register_sync_handler(const SyncOps *ops, const char *name);
+
 #endif							/* SYNC_H */
-- 
2.47.3

From a9566b9110b53491f792a61c83f5d76b01fb029f Mon Sep 17 00:00:00 2001
From: Greg Lamberson <[email-masked]>
Date: Fri, 10 Apr 2026 07:27:44 -0500
Subject: [PATCH v2 2/2] Add test module for sync handler registration

test_sync_handler exercises register_sync_handler() from _PG_init()
and verifies:

  - The registered handler ID is at least SYNC_HANDLER_FIRST_DYNAMIC.
  - Distinct FileTags produce distinct sync_syncfiletag callbacks
    at CHECKPOINT time.
  - Duplicate FileTags coalesce via HASH_BLOBS to a single dispatch.
  - Idle checkpoints do not re-dispatch already-processed entries
    (cycle_ctr skip).

Shared state between the backend and the checkpointer uses
GetNamedDSMSegment() so the dispatch counter is visible to the
backend that queries it.

Discussion: https://postgr.es/m/[email-masked]
---
 src/test/modules/Makefile                     |   1 +
 src/test/modules/meson.build                  |   1 +
 src/test/modules/test_sync_handler/.gitignore |   4 +
 src/test/modules/test_sync_handler/Makefile   |  27 +++
 .../modules/test_sync_handler/meson.build     |  33 ++++
 .../modules/test_sync_handler/t/001_basic.pl  |  96 +++++++++
 .../test_sync_handler--1.0.sql                |  13 ++
 .../test_sync_handler/test_sync_handler.c     | 187 ++++++++++++++++++
 .../test_sync_handler.control                 |   4 +
 9 files changed, 366 insertions(+)
 create mode 100644 src/test/modules/test_sync_handler/.gitignore
 create mode 100644 src/test/modules/test_sync_handler/Makefile
 create mode 100644 src/test/modules/test_sync_handler/meson.build
 create mode 100644 src/test/modules/test_sync_handler/t/001_basic.pl
 create mode 100644 src/test/modules/test_sync_handler/test_sync_handler--1.0.sql
 create mode 100644 src/test/modules/test_sync_handler/test_sync_handler.c
 create mode 100644 src/test/modules/test_sync_handler/test_sync_handler.control

diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 0a74ab5c86f..2a3334d7508 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -52,6 +52,7 @@ SUBDIRS = \
 		  test_shmem \
 		  test_shm_mq \
 		  test_slru \
+		  test_sync_handler \
 		  test_tidstore \
 		  unsafe_tests \
 		  worker_spi \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4bca42bb370..00bc7454cc8 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -53,6 +53,7 @@ subdir('test_saslprep')
 subdir('test_shmem')
 subdir('test_shm_mq')
 subdir('test_slru')
+subdir('test_sync_handler')
 subdir('test_tidstore')
 subdir('typcache')
 subdir('unsafe_tests')
diff --git a/src/test/modules/test_sync_handler/.gitignore b/src/test/modules/test_sync_handler/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/test_sync_handler/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/test_sync_handler/Makefile b/src/test/modules/test_sync_handler/Makefile
new file mode 100644
index 00000000000..22326a47e9c
--- /dev/null
+++ b/src/test/modules/test_sync_handler/Makefile
@@ -0,0 +1,27 @@
+# src/test/modules/test_sync_handler/Makefile
+
+MODULE_big = test_sync_handler
+OBJS = \
+	$(WIN32RES) \
+	test_sync_handler.o
+PGFILEDESC = "test_sync_handler - test module for sync handler registration"
+
+EXTENSION = test_sync_handler
+DATA = test_sync_handler--1.0.sql
+
+TAP_TESTS = 1
+
+# Tests require shared_preload_libraries=test_sync_handler which typical
+# installcheck users do not have. Match test_slru's convention.
+NO_INSTALLCHECK = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/test_sync_handler
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+endif
diff --git a/src/test/modules/test_sync_handler/meson.build b/src/test/modules/test_sync_handler/meson.build
new file mode 100644
index 00000000000..e7f03616ba0
--- /dev/null
+++ b/src/test/modules/test_sync_handler/meson.build
@@ -0,0 +1,33 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+
+test_sync_handler_sources = files(
+  'test_sync_handler.c',
+)
+
+if host_system == 'windows'
+  test_sync_handler_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'test_sync_handler',
+    '--FILEDESC', 'test_sync_handler - test module for sync handler registration',])
+endif
+
+test_sync_handler = shared_module('test_sync_handler',
+  test_sync_handler_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += test_sync_handler
+
+test_install_data += files(
+  'test_sync_handler.control',
+  'test_sync_handler--1.0.sql',
+)
+
+tests += {
+  'name': 'test_sync_handler',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_basic.pl',
+    ],
+  },
+}
diff --git a/src/test/modules/test_sync_handler/t/001_basic.pl b/src/test/modules/test_sync_handler/t/001_basic.pl
new file mode 100644
index 00000000000..29c0fc3c61e
--- /dev/null
+++ b/src/test/modules/test_sync_handler/t/001_basic.pl
@@ -0,0 +1,96 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+#
+# Basic test for register_sync_handler() dispatch.
+#
+# Verifies that a custom sync handler registered via register_sync_handler()
+# in _PG_init() receives callback invocations from ProcessSyncRequests() at
+# CHECKPOINT time, that identical FileTags coalesce via HASH_BLOBS
+# deduplication, that distinct FileTags produce distinct callbacks, and
+# that an idle checkpoint does not re-dispatch entries that were already
+# processed (cycle_ctr skip).
+
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+my $node = PostgreSQL::Test::Cluster->new('sync_handler');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', q{
+shared_preload_libraries = 'test_sync_handler'
+# TAP clusters set fsync = off by default for speed; re-enable here so
+# that ProcessSyncRequests actually dispatches our sync handler callback.
+fsync = on
+});
+$node->start;
+$node->safe_psql('postgres', 'CREATE EXTENSION test_sync_handler');
+
+# The handler ID must be >= SYNC_HANDLER_FIRST_DYNAMIC. Built-ins
+# currently occupy IDs 0..4, so the first extension handler should be
+# at least 5.
+my $id = $node->safe_psql('postgres', 'SELECT test_sync_handler_id()');
+ok($id >= 5,
+	"handler id $id is >= SYNC_HANDLER_FIRST_DYNAMIC (built-ins = 5)")
+  or diag("got id=$id");
+
+# Baseline: no dispatches before we queue anything.
+my $baseline =
+  $node->safe_psql('postgres', 'SELECT test_sync_handler_count()');
+is($baseline, '0', 'baseline dispatch count is zero');
+
+# Queue 5 distinct FileTags (differing in segno only) and checkpoint.
+# Expect 5 callback invocations since they are all distinct hash keys.
+$node->safe_psql(
+	'postgres', q{
+SELECT test_sync_handler_register(1);
+SELECT test_sync_handler_register(2);
+SELECT test_sync_handler_register(3);
+SELECT test_sync_handler_register(4);
+SELECT test_sync_handler_register(5);
+});
+$node->safe_psql('postgres', 'CHECKPOINT');
+my $after_distinct =
+  $node->safe_psql('postgres', 'SELECT test_sync_handler_count()');
+is($after_distinct, '5',
+	'5 distinct FileTags produce 5 sync_syncfiletag callbacks')
+  or diag("got $after_distinct");
+
+# Queue 10 duplicate FileTags (same segno 42) and checkpoint.
+# Expect exactly 1 additional callback because pendingOps uses HASH_BLOBS
+# and collapses identical FileTags into a single hash entry.
+$node->safe_psql(
+	'postgres', q{
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+SELECT test_sync_handler_register(42);
+});
+$node->safe_psql('postgres', 'CHECKPOINT');
+my $after_coalesce =
+  $node->safe_psql('postgres', 'SELECT test_sync_handler_count()');
+is($after_coalesce, '6',
+	'10 duplicate FileTags coalesce via HASH_BLOBS to 1 additional callback')
+  or diag("got $after_coalesce");
+
+# Second CHECKPOINT with no new requests. The count must stay the same:
+# every entry from the previous checkpoint was processed and removed
+# from pendingOps, and no new entries have been queued, so
+# ProcessSyncRequests has nothing to dispatch.
+$node->safe_psql('postgres', 'CHECKPOINT');
+my $after_idle =
+  $node->safe_psql('postgres', 'SELECT test_sync_handler_count()');
+is($after_idle, '6', 'idle checkpoint does not re-dispatch')
+  or diag("got $after_idle");
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/test_sync_handler/test_sync_handler--1.0.sql b/src/test/modules/test_sync_handler/test_sync_handler--1.0.sql
new file mode 100644
index 00000000000..07ea297f15f
--- /dev/null
+++ b/src/test/modules/test_sync_handler/test_sync_handler--1.0.sql
@@ -0,0 +1,13 @@
+/* src/test/modules/test_sync_handler/test_sync_handler--1.0.sql */
+
+-- complain if script is sourced in psql, rather than via CREATE EXTENSION
+\echo Use "CREATE EXTENSION test_sync_handler" to load this file. \quit
+
+CREATE FUNCTION test_sync_handler_id() RETURNS int4
+  AS 'MODULE_PATHNAME', 'test_sync_handler_id' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_sync_handler_register(seg bigint) RETURNS void
+  AS 'MODULE_PATHNAME', 'test_sync_handler_register' LANGUAGE C STRICT;
+
+CREATE FUNCTION test_sync_handler_count() RETURNS bigint
+  AS 'MODULE_PATHNAME', 'test_sync_handler_count' LANGUAGE C STRICT;
diff --git a/src/test/modules/test_sync_handler/test_sync_handler.c b/src/test/modules/test_sync_handler/test_sync_handler.c
new file mode 100644
index 00000000000..b2cd25cc18d
--- /dev/null
+++ b/src/test/modules/test_sync_handler/test_sync_handler.c
@@ -0,0 +1,187 @@
+/*--------------------------------------------------------------------------
+ *
+ * test_sync_handler.c
+ *		Minimal extension exercising register_sync_handler() + dispatch.
+ *
+ * This module demonstrates the sync.c extensibility API by registering a
+ * trivial SyncOps during _PG_init(), exposing SQL-callable helpers to
+ * queue FileTags for the registered handler, and tracking how many times
+ * the handler's sync_syncfiletag callback is invoked.
+ *
+ * Because sync_syncfiletag runs in the checkpointer process but
+ * test_sync_handler_count() runs in a regular backend, the call counter
+ * lives in shared memory via GetNamedDSMSegment().
+ *
+ * The TAP test in t/001_basic.pl uses this module to verify:
+ *   - register_sync_handler() returns an ID >= SYNC_HANDLER_FIRST_DYNAMIC
+ *   - Queued FileTags round-trip through the checkpointer and land in
+ *     the registered sync_syncfiletag callback at CHECKPOINT time
+ *   - Identical FileTags coalesce via HASH_BLOBS deduplication in
+ *     pendingOps (N duplicates -> 1 callback)
+ *   - Distinct FileTags produce distinct callbacks
+ *   - Idle checkpoints do not re-dispatch (cycle_ctr skip)
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *		src/test/modules/test_sync_handler/test_sync_handler.c
+ *
+ *--------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "miscadmin.h"
+#include "pg_config.h"
+#include "port/atomics.h"
+#include "storage/dsm_registry.h"
+#include "storage/sync.h"
+#include "utils/builtins.h"
+
+PG_MODULE_MAGIC;
+
+void		_PG_init(void);
+
+typedef struct TshSharedState
+{
+	pg_atomic_uint64 call_count;
+} TshSharedState;
+
+static int16 tsh_handler_id = -1;
+static TshSharedState *tsh_shared = NULL;
+
+/*
+ * GetNamedDSMSegment's init_callback signature gained an extra `arg`
+ * parameter in PG 19devel. Provide both shapes so the test module is
+ * buildable across 18 and 19.
+ */
+#if PG_VERSION_NUM >= 190000
+static void
+tsh_init_shmem(void *ptr, void *arg)
+#else
+static void
+tsh_init_shmem(void *ptr)
+#endif
+{
+	TshSharedState *state = (TshSharedState *) ptr;
+
+	pg_atomic_init_u64(&state->call_count, 0);
+}
+
+static void
+tsh_attach_shmem(void)
+{
+	bool		found;
+
+	if (tsh_shared != NULL)
+		return;
+#if PG_VERSION_NUM >= 190000
+	tsh_shared = GetNamedDSMSegment("test_sync_handler",
+									sizeof(TshSharedState),
+									tsh_init_shmem,
+									&found,
+									NULL);
+#else
+	tsh_shared = GetNamedDSMSegment("test_sync_handler",
+									sizeof(TshSharedState),
+									tsh_init_shmem,
+									&found);
+#endif
+}
+
+static int
+test_sync_syncfiletag(const FileTag *ftag, char *path)
+{
+	/*
+	 * This runs in the checkpointer process. Attach to the shared memory
+	 * segment the first time we're called so that counter increments are
+	 * visible to the backend that queries test_sync_handler_count().
+	 */
+	tsh_attach_shmem();
+	pg_atomic_fetch_add_u64(&tsh_shared->call_count, 1);
+
+	if (path != NULL)
+		snprintf(path, MAXPGPATH, "test_sync_handler:seg%llu",
+				 (unsigned long long) ftag->segno);
+	return 0;
+}
+
+static int
+test_sync_unlinkfiletag(const FileTag *ftag, char *path)
+{
+	if (path != NULL)
+		snprintf(path, MAXPGPATH, "test_sync_handler:unlink");
+	return 0;
+}
+
+static bool
+test_sync_filetagmatches(const FileTag *ftag, const FileTag *candidate)
+{
+	/*
+	 * Match on dbOid, mirroring md.c's DROP DATABASE semantics. The test
+	 * doesn't exercise the filter path today, but the callback is defined so
+	 * extensions can use this module as a copy-paste starting point.
+	 */
+	return ftag->rlocator.dbOid == candidate->rlocator.dbOid;
+}
+
+static const SyncOps test_sync_ops = {
+	.sync_syncfiletag = test_sync_syncfiletag,
+	.sync_unlinkfiletag = test_sync_unlinkfiletag,
+	.sync_filetagmatches = test_sync_filetagmatches,
+};
+
+void
+_PG_init(void)
+{
+	tsh_handler_id = register_sync_handler(&test_sync_ops, "test_sync_handler");
+	elog(LOG, "test_sync_handler: registered as id %d",
+		 (int) tsh_handler_id);
+}
+
+PG_FUNCTION_INFO_V1(test_sync_handler_id);
+Datum
+test_sync_handler_id(PG_FUNCTION_ARGS)
+{
+	PG_RETURN_INT32((int32) tsh_handler_id);
+}
+
+PG_FUNCTION_INFO_V1(test_sync_handler_register);
+Datum
+test_sync_handler_register(PG_FUNCTION_ARGS)
+{
+	int64		seg = PG_GETARG_INT64(0);
+	FileTag		tag;
+
+	if (tsh_handler_id < 0)
+		ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("sync handler was not registered during module initialization")));
+
+	/*
+	 * Mandatory memset: pendingOps uses HASH_BLOBS which hashes every byte of
+	 * the FileTag. Uninitialized padding would break coalescing.
+	 */
+	memset(&tag, 0, sizeof(FileTag));
+	tag.handler = tsh_handler_id;
+	tag.forknum = 0;
+	tag.rlocator.spcOid = 1;
+	tag.rlocator.dbOid = MyDatabaseId;
+	tag.rlocator.relNumber = 1;
+	tag.segno = (uint64) seg;
+
+	if (!RegisterSyncRequest(&tag, SYNC_REQUEST, true /* retryOnError */ ))
+		ereport(ERROR,
+				(errcode(ERRCODE_INTERNAL_ERROR),
+				 errmsg("could not register sync request")));
+
+	PG_RETURN_VOID();
+}
+
+PG_FUNCTION_INFO_V1(test_sync_handler_count);
+Datum
+test_sync_handler_count(PG_FUNCTION_ARGS)
+{
+	tsh_attach_shmem();
+	PG_RETURN_INT64((int64) pg_atomic_read_u64(&tsh_shared->call_count));
+}
diff --git a/src/test/modules/test_sync_handler/test_sync_handler.control b/src/test/modules/test_sync_handler/test_sync_handler.control
new file mode 100644
index 00000000000..3d528f7a866
--- /dev/null
+++ b/src/test/modules/test_sync_handler/test_sync_handler.control
@@ -0,0 +1,4 @@
+comment = 'Test module for sync handler registration'
+default_version = '1.0'
+module_pathname = '$libdir/test_sync_handler'
+relocatable = true
-- 
2.47.3