From 4619a5ae4d62aade910e8d5f2a8cd6669372635b Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Fri, 23 Oct 2026 10:33:57 -0400
Subject: [PATCH v10 5/6] Abstract the dictionary codec behind a routine struct

The dictionary lifecycle in the previous patch (the pg_toast_dictionary
catalog, column ownership, containment, retirement, training) is
independent of which codec produces the compressed payload.  Only three
calls differ per codec: compress a value with a dictionary, decompress a
whole value, decompress a prefix.

Put those three behind a DictCompressRoutine struct and dispatch on the
datum's method byte, so a second dictionary-aware codec needs only its
own routine plus a case in dict_routine_for_method()/_for_id().  The
datum layout (long header, 4-byte dictid, codec payload) and the catalog
are already codec-agnostic; the method byte already says which codec
follows, so the dispatch reads no new on-disk state.

The three former zstd_dict_*_datum() entry points become static and are
reached only through the routine; callers in detoast.c,
toast_internals.c and toast_helper.c now use the generic
toast_dict_compress_datum()/_decompress_datum()/_decompress_datum_slice().
No behavior change for zstd; this only opens the seam.
---
 src/backend/access/common/detoast.c          |  8 +--
 src/backend/access/common/toast_dictionary.c | 74 +++++++++++++++++++-
 src/backend/access/common/toast_internals.c  |  5 +-
 src/backend/access/table/toast_helper.c      |  2 +-
 src/include/access/toast_compression.h       |  7 +-
 src/include/access/toast_dictionary.h        | 42 ++++++++---
 6 files changed, 116 insertions(+), 22 deletions(-)

diff --git a/src/backend/access/common/detoast.c b/src/backend/access/common/detoast.c
index 1d2e7c615f4..29dc36d7733 100644
--- a/src/backend/access/common/detoast.c
+++ b/src/backend/access/common/detoast.c
@@ -60,7 +60,7 @@ detoast_external_attr(varlena *attr)
 		{
 			varlena    *tmp = result;
 
-			result = zstd_dict_decompress_datum(tmp);
+			result = toast_dict_decompress_datum(tmp);
 			pfree(tmp);
 		}
 	}
@@ -109,7 +109,7 @@ detoast_external_attr(varlena *attr)
 		 * independent of its source it counts as external; see
 		 * toast_dictionary.c.
 		 */
-		result = zstd_dict_decompress_datum(attr);
+		result = toast_dict_decompress_datum(attr);
 	}
 	else
 	{
@@ -522,7 +522,7 @@ toast_decompress_datum(varlena *attr)
 		case TOAST_ZSTD_COMPRESSION_ID:
 			return zstd_decompress_datum(attr);
 		case TOAST_ZSTD_DICT_COMPRESSION_ID:
-			return zstd_dict_decompress_datum(attr);
+			return toast_dict_decompress_datum(attr);
 		default:
 			elog(ERROR, "invalid compression method id %d", cmid);
 			return NULL;		/* keep compiler quiet */
@@ -569,7 +569,7 @@ toast_decompress_datum_slice(varlena *attr, int32 slicelength)
 		case TOAST_ZSTD_COMPRESSION_ID:
 			return zstd_decompress_datum_slice(attr, slicelength);
 		case TOAST_ZSTD_DICT_COMPRESSION_ID:
-			return zstd_dict_decompress_datum_slice(attr, slicelength);
+			return toast_dict_decompress_datum_slice(attr, slicelength);
 		default:
 			elog(ERROR, "invalid compression method id %d", cmid);
 			return NULL;		/* keep compiler quiet */
diff --git a/src/backend/access/common/toast_dictionary.c b/src/backend/access/common/toast_dictionary.c
index 8ba586a0f9f..55c6a8e2269 100644
--- a/src/backend/access/common/toast_dictionary.c
+++ b/src/backend/access/common/toast_dictionary.c
@@ -318,7 +318,7 @@ dict_get_ddict(Oid dictid)
  * VARHDRSZ_COMPRESSED_DICT bytes of header; toast_compress_datum() fills in
  * va_tcinfo and the method byte.
  */
-varlena *
+static varlena *
 zstd_dict_compress_datum(const varlena *value, Oid dictid)
 {
 #ifndef USE_ZSTD
@@ -348,7 +348,7 @@ zstd_dict_compress_datum(const varlena *value, Oid dictid)
 #endif
 }
 
-varlena *
+static varlena *
 zstd_dict_decompress_datum(const varlena *value)
 {
 #ifndef USE_ZSTD
@@ -388,7 +388,7 @@ zstd_dict_decompress_datum(const varlena *value)
  * continues the previous frame.  The one-shot call used now starts a new
  * frame each time and needs no reset.
  */
-varlena *
+static varlena *
 zstd_dict_decompress_datum_slice(const varlena *value, int32 slicelength)
 {
 	varlena    *result = zstd_dict_decompress_datum(value);
@@ -398,6 +398,74 @@ zstd_dict_decompress_datum_slice(const varlena *value, int32 slicelength)
 	return result;
 }
 
+/*
+ * The zstd dictionary codec.  See DictCompressRoutine in
+ * toast_dictionary.h.
+ */
+static const DictCompressRoutine zstd_dict_routine = {
+	.compress = zstd_dict_compress_datum,
+	.decompress = zstd_dict_decompress_datum,
+	.decompress_slice = zstd_dict_decompress_datum_slice,
+};
+
+/*
+ * Pick the dictionary codec for a compression method char (for compress) or
+ * for the method byte already in a datum (for decompress).  Adding a codec is
+ * one case here plus its DictCompressRoutine.
+ */
+static const DictCompressRoutine *
+dict_routine_for_method(char cmethod)
+{
+	switch (cmethod)
+	{
+		case TOAST_ZSTD_DICT_COMPRESSION:
+			return &zstd_dict_routine;
+		default:
+			elog(ERROR, "invalid dictionary compression method %d", cmethod);
+			return NULL;		/* keep compiler quiet */
+	}
+}
+
+static const DictCompressRoutine *
+dict_routine_for_id(ToastCompressionId cmid)
+{
+	switch (cmid)
+	{
+		case TOAST_ZSTD_DICT_COMPRESSION_ID:
+			return &zstd_dict_routine;
+		default:
+			elog(ERROR, "invalid dictionary compression method id %d", cmid);
+			return NULL;		/* keep compiler quiet */
+	}
+}
+
+/*
+ * Compress value with dictid using the codec named by cmethod, laying out the
+ * dictionary header.  Returns NULL if the value did not get smaller.
+ */
+varlena *
+toast_dict_compress_datum(const varlena *value, char cmethod, Oid dictid)
+{
+	return dict_routine_for_method(cmethod)->compress(value, dictid);
+}
+
+/* Decompress a dictionary-compressed datum, dispatching on its method byte. */
+varlena *
+toast_dict_decompress_datum(const varlena *value)
+{
+	ToastCompressionId cmid = VARDATA_COMPRESSED_GET_COMPRESS_METHOD(value);
+
+	return dict_routine_for_id(cmid)->decompress(value);
+}
+
+varlena *
+toast_dict_decompress_datum_slice(const varlena *value, int32 slicelength)
+{
+	ToastCompressionId cmid = VARDATA_COMPRESSED_GET_COMPRESS_METHOD(value);
+
+	return dict_routine_for_id(cmid)->decompress_slice(value, slicelength);
+}
+
 /*
  * Delete the dictionaries of a relation (attnum == 0) or of one column, next
  * to RemoveStatistics().  The values that used them go away at the same time.
diff --git a/src/backend/access/common/toast_internals.c b/src/backend/access/common/toast_internals.c
index 7f48f9b5f06..f105c987fcf 100644
--- a/src/backend/access/common/toast_internals.c
+++ b/src/backend/access/common/toast_internals.c
@@ -133,8 +133,9 @@ toast_compress_datum_rel(Datum value, char cmethod, Relation rel,
 					dictid = toast_dictionary_current(rel, attnum);
 				if (OidIsValid(dictid))
 				{
-					tmp = zstd_dict_compress_datum((const varlena *) DatumGetPointer(value),
-												   dictid);
+					tmp = toast_dict_compress_datum((const varlena *) DatumGetPointer(value),
+													TOAST_ZSTD_DICT_COMPRESSION,
+												dictid);
 					cmid = TOAST_ZSTD_DICT_COMPRESSION_ID;
 				}
 				else
diff --git a/src/backend/access/table/toast_helper.c b/src/backend/access/table/toast_helper.c
index 4925841ae38..db5f1eb7c8b 100644
--- a/src/backend/access/table/toast_helper.c
+++ b/src/backend/access/table/toast_helper.c
@@ -162,7 +162,7 @@ toast_tuple_init(ToastTupleContext *ttc)
 			if (VARATT_IS_COMPRESSED_DICT(new_value) &&
 				!toast_dictionary_keep(ttc->ttc_rel, att->attnum, new_value))
 			{
-				varlena    *plain = zstd_dict_decompress_datum(new_value);
+				varlena    *plain = toast_dict_decompress_datum(new_value);
 
 				if ((ttc->ttc_attr[i].tai_colflags & TOASTCOL_NEEDS_FREE) != 0)
 					pfree(new_value);
diff --git a/src/include/access/toast_compression.h b/src/include/access/toast_compression.h
index fc66e88210c..26717b2533d 100644
--- a/src/include/access/toast_compression.h
+++ b/src/include/access/toast_compression.h
@@ -78,13 +78,14 @@ toast_compression_id_needs_cmid_byte(ToastCompressionId cmid)
  * Is this a compressed-in-line datum that needs a compression dictionary to
  * be read?  Such a datum depends on catalog state of the column it belongs
  * to, so code that makes values self-contained treats it like a TOAST
- * pointer (see toast_dictionary.c).
+ * pointer (see toast_dictionary.c).  True for every dictionary-aware codec.
  */
 static inline bool
 VARATT_IS_COMPRESSED_DICT(const void *PTR)
 {
-	return VARATT_IS_COMPRESSED(PTR) &&
-		VARDATA_COMPRESSED_GET_COMPRESS_METHOD(PTR) ==
+	if (!VARATT_IS_COMPRESSED(PTR))
+		return false;
+	return VARDATA_COMPRESSED_GET_COMPRESS_METHOD(PTR) ==
 		TOAST_ZSTD_DICT_COMPRESSION_ID;
 }
 
diff --git a/src/include/access/toast_dictionary.h b/src/include/access/toast_dictionary.h
index c2c16f5cf19..41b4fdf5c85 100644
--- a/src/include/access/toast_dictionary.h
+++ b/src/include/access/toast_dictionary.h
@@ -1,7 +1,7 @@
 /*-------------------------------------------------------------------------
  *
  * toast_dictionary.h
- *	  Lifecycle of zstd compression dictionaries for TOAST.
+ *	  Lifecycle of compression dictionaries for TOAST.
  *
  * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
  *
@@ -16,24 +16,48 @@
 #include "varatt.h"
 
 /*
- * A zstd_dict datum stores the dictionary OID right after the method byte
- * of the long compressed header:
+ * A dictionary-compressed datum stores the dictionary OID right after the
+ * method byte of the long compressed header, regardless of which codec
+ * produced the payload:
  *
- *	 va_header | va_tcinfo | va_cmid | dictid (4 bytes) | zstd frame
+ *	 va_header | va_tcinfo | va_cmid | dictid (4 bytes) | codec payload
+ *
+ * The method byte (va_cmid) says which codec's payload follows, so the same
+ * layout serves zstd, lz4, and any future dictionary-aware codec.
  */
 #define VARHDRSZ_COMPRESSED_DICT	(VARHDRSZ_COMPRESSED_LONG + sizeof(Oid))
 
+/*
+ * Per-codec dictionary routines.  The catalog, lifecycle, training, and the
+ * datum layout above are codec-independent and live in toast_dictionary.c;
+ * only these three calls differ per codec.  compress() lays out the header
+ * and writes the codec payload after VARHDRSZ_COMPRESSED_DICT; it returns
+ * NULL if the value did not get smaller.  decompress() and
+ * decompress_slice() read a datum this codec produced.
+ */
+typedef struct DictCompressRoutine
+{
+	varlena    *(*compress) (const varlena *value, Oid dictid);
+	varlena    *(*decompress) (const varlena *value);
+	varlena    *(*decompress_slice) (const varlena *value, int32 slicelength);
+} DictCompressRoutine;
+
 /* Current dictionary of rel.attnum, or InvalidOid if none */
 extern Oid	toast_dictionary_current(Relation rel, AttrNumber attnum);
 
-/* May this zstd_dict datum be stored as-is in rel.attnum? */
+/* May this dict-compressed datum be stored as-is in rel.attnum? */
 extern bool toast_dictionary_keep(Relation rel, AttrNumber attnum,
 								  const varlena *value);
 
-extern varlena *zstd_dict_compress_datum(const varlena *value, Oid dictid);
-extern varlena *zstd_dict_decompress_datum(const varlena *value);
-extern varlena *zstd_dict_decompress_datum_slice(const varlena *value,
-												 int32 slicelength);
+/*
+ * Generic dictionary compress/decompress.  The method byte in value (or, for
+ * compress, the compression method cmid) selects the codec's routine.
+ */
+extern varlena *toast_dict_compress_datum(const varlena *value, char cmethod,
+										  Oid dictid);
+extern varlena *toast_dict_decompress_datum(const varlena *value);
+extern varlena *toast_dict_decompress_datum_slice(const varlena *value,
+												  int32 slicelength);
 
 extern void RemoveToastDictionaries(Oid relid, AttrNumber attnum);
 extern void toast_dictionary_retire(Oid relid);
-- 
2.50.1

