From cd1cd0e668d6f7d7f315fd2e6aa41b81c9a4b724 Mon Sep 17 00:00:00 2001
From: Greg Lamberson <greg@lamco.io>
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/IA1PR07MB983072521EE7FDEE98902534A9592@IA1PR07MB9830.namprd07.prod.outlook.com
---
 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

