diff --git a/doc/src/sgml/ref/cluster.sgml b/doc/src/sgml/ref/cluster.sgml
index ffb3ff898c69a..2f162a875ebb1 100644
--- a/doc/src/sgml/ref/cluster.sgml
+++ b/doc/src/sgml/ref/cluster.sgml
@@ -109,6 +109,18 @@ CLUSTER [ ( option [, ...] ) ] [
+
+ When a table has a remembered clustered btree index, later row insertions
+ whose leading clustered key is available try to place new heap tuples near
+ existing heap pages found from that clustered key before falling back to the
+ normal free-space and relation-extension placement rules. Bulk inserts can
+ also use access-method-specific ordering support, such as GiST sort support,
+ when it is available. These heuristics reduce how quickly freshly inserted
+ rows drift away from the clustered order, but they are only best-effort;
+ updates, deletes, page-level free space, and concurrent writes can still
+ make periodic reclustering useful.
+
+
Each backend running CLUSTER will report its progress
in the pg_stat_progress_cluster view. See
diff --git a/src/backend/access/heap/heapam.c b/src/backend/access/heap/heapam.c
index abfd8e8970a60..856a66ebad288 100644
--- a/src/backend/access/heap/heapam.c
+++ b/src/backend/access/heap/heapam.c
@@ -31,6 +31,10 @@
*/
#include "postgres.h"
+#include "common/hashfn.h"
+#include "access/genam.h"
+#include "access/gist.h"
+#include "access/gist_private.h"
#include "access/heapam.h"
#include "access/heaptoast.h"
#include "access/hio.h"
@@ -40,8 +44,11 @@
#include "access/valid.h"
#include "access/visibilitymap.h"
#include "access/xloginsert.h"
+#include "catalog/pg_am_d.h"
#include "catalog/pg_database.h"
#include "catalog/pg_database_d.h"
+#include "catalog/pg_index.h"
+#include "catalog/pg_type_d.h"
#include "commands/vacuum.h"
#include "executor/instrument_node.h"
#include "pgstat.h"
@@ -53,12 +60,82 @@
#include "utils/datum.h"
#include "utils/injection_point.h"
#include "utils/inval.h"
+#include "utils/lsyscache.h"
+#include "utils/memutils.h"
+#include "utils/relcache.h"
+#include "utils/sortsupport.h"
#include "utils/spccache.h"
#include "utils/syscache.h"
+typedef struct HeapTupleClusteredWriteItem
+{
+ HeapTuple tuple;
+ BlockNumber targetBlock;
+ Datum clusterValues[INDEX_MAX_KEYS];
+ bool clusterIsNull[INDEX_MAX_KEYS];
+ bool hasClusterKey;
+ int inputIndex;
+} HeapTupleClusteredWriteItem;
+
+typedef struct HeapTupleClusteredSortContext
+{
+ int nkeys;
+ SortSupportData sortKeys[INDEX_MAX_KEYS];
+
+ /* GiST compressed keys must survive until qsort finishes. */
+ MemoryContext sortCxt;
+
+ /* Comparator support functions should not leak into the caller context. */
+ MemoryContext compareCxt;
+} HeapTupleClusteredSortContext;
+
+typedef struct HeapTupleClusteredTargetCacheEntry
+{
+ Datum prefixValue;
+ BlockNumber targetBlock;
+ bool occupied;
+} HeapTupleClusteredTargetCacheEntry;
+
+typedef struct HeapTupleClusteredPrefixCountEntry
+{
+ Datum prefixValue;
+ int ntuples;
+ bool occupied;
+} HeapTupleClusteredPrefixCountEntry;
+
+/*
+ * COPY currently feeds heap_multi_insert() in batches of up to 1000 tuples. A
+ * small direct-mapped hash table gives by-value leading keys, such as OSM tile
+ * ids, cheap repeated target lookups. Collisions only cause cache misses or
+ * evictions: every hit is still verified by the clustered index opfamily's
+ * equality function before reusing the target block.
+ */
+#define CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR 4
+
+/*
+ * Very hot leading keys are a bad fit for clustered probing: the batch will
+ * have to spill across many heap pages anyway, and repeatedly anchoring on the
+ * same old page can dominate insertion time. Keep normal OSM-style tile
+ * grouping clustered, but let very dense equal-prefix runs use the regular
+ * bulk/FSM path.
+ */
+#define CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES 16
+
static HeapTuple heap_prepare_insert(Relation relation, HeapTuple tup,
TransactionId xid, CommandId cid, uint32 options);
+static int heap_clustered_write_item_cmp(const void *a, const void *b, void *arg);
+static bool heap_clustered_write_prefix_can_compare_all_equal(Oid typeOid);
+static bool heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid);
+static bool heap_clustered_write_prefix_can_hash(Oid typeOid);
+static uint32 heap_clustered_write_prefix_hash(Datum value, Oid typeOid);
+static bool heap_clustered_write_index_can_sort(Relation relation,
+ Relation indexRelation);
+static int heap_prepare_clustered_write_sort(Relation relation,
+ Relation indexRelation,
+ HeapTupleClusteredWriteItem *items,
+ int ntuples,
+ HeapTupleClusteredSortContext *context);
static XLogRecPtr log_heap_update(Relation reln, Buffer oldbuf,
Buffer newbuf, HeapTuple oldtup,
HeapTuple newtup, HeapTuple old_key_tuple,
@@ -112,6 +189,254 @@ static XLogRecPtr log_heap_new_cid(Relation relation, HeapTuple tup);
static HeapTuple ExtractReplicaIdentity(Relation relation, HeapTuple tp, bool key_required,
bool *copy);
+static int
+heap_clustered_write_item_cmp(const void *a, const void *b, void *arg)
+{
+ const HeapTupleClusteredWriteItem *left = (const HeapTupleClusteredWriteItem *) a;
+ const HeapTupleClusteredWriteItem *right = (const HeapTupleClusteredWriteItem *) b;
+ HeapTupleClusteredSortContext *context = (HeapTupleClusteredSortContext *) arg;
+
+ if (context != NULL && context->nkeys > 0 &&
+ left->hasClusterKey && right->hasClusterKey)
+ {
+ for (int i = 0; i < context->nkeys; i++)
+ {
+ int compare;
+ MemoryContext oldcontext;
+
+ oldcontext = MemoryContextSwitchTo(context->compareCxt);
+ compare = ApplySortComparator(left->clusterValues[i],
+ left->clusterIsNull[i],
+ right->clusterValues[i],
+ right->clusterIsNull[i],
+ &context->sortKeys[i]);
+ MemoryContextSwitchTo(oldcontext);
+ MemoryContextReset(context->compareCxt);
+
+ if (compare != 0)
+ return compare;
+ }
+ }
+ else if (left->hasClusterKey && !right->hasClusterKey)
+ return -1;
+ else if (!left->hasClusterKey && right->hasClusterKey)
+ return 1;
+
+ if (left->targetBlock == InvalidBlockNumber &&
+ right->targetBlock != InvalidBlockNumber)
+ return 1;
+ if (left->targetBlock != InvalidBlockNumber &&
+ right->targetBlock == InvalidBlockNumber)
+ return -1;
+ if (left->targetBlock < right->targetBlock)
+ return -1;
+ if (left->targetBlock > right->targetBlock)
+ return 1;
+
+ if (left->inputIndex < right->inputIndex)
+ return -1;
+ if (left->inputIndex > right->inputIndex)
+ return 1;
+ return 0;
+}
+
+static bool
+heap_clustered_write_prefix_can_compare_all_equal(Oid typeOid)
+{
+ if (heap_clustered_write_prefix_can_compare_by_datum(typeOid))
+ return true;
+
+ switch (typeOid)
+ {
+ case TEXTOID:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool
+heap_clustered_write_prefix_can_compare_by_datum(Oid typeOid)
+{
+ if (!get_typbyval(typeOid))
+ return false;
+
+ switch (typeOid)
+ {
+ case INT2OID:
+ case INT4OID:
+ case INT8OID:
+ case OIDOID:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static bool
+heap_clustered_write_prefix_can_hash(Oid typeOid)
+{
+ if (get_typbyval(typeOid))
+ return true;
+
+ switch (typeOid)
+ {
+ case TEXTOID:
+ return true;
+ default:
+ return false;
+ }
+}
+
+static uint32
+heap_clustered_write_prefix_hash(Datum value, Oid typeOid)
+{
+ if (get_typbyval(typeOid))
+ return hash_bytes((unsigned char *) &value, sizeof(Datum));
+
+ switch (typeOid)
+ {
+ case TEXTOID:
+ {
+ struct varlena *text = (struct varlena *)
+ PG_DETOAST_DATUM_PACKED(value);
+ uint32 hash;
+
+ /*
+ * This hash only selects a direct-mapped cache slot. Callers
+ * still verify hits with the clustered index equality operator,
+ * so different payloads can only cause cache misses or evictions.
+ */
+ hash = hash_bytes((unsigned char *) VARDATA_ANY(text),
+ VARSIZE_ANY_EXHDR(text));
+ if ((Pointer) text != DatumGetPointer(value))
+ pfree(text);
+ return hash;
+ }
+ default:
+ pg_unreachable();
+ }
+}
+
+static bool
+heap_clustered_write_index_can_sort(Relation relation, Relation indexRelation)
+{
+ int natts;
+ int nkeys;
+
+ if (IsBootstrapProcessingMode() || indexRelation == NULL)
+ return false;
+
+ if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) ||
+ indexRelation->rd_rel->relam != GIST_AM_OID ||
+ !indexRelation->rd_indam->amclusterable ||
+ !indexRelation->rd_index->indisvalid ||
+ !indexRelation->rd_index->indisready ||
+ !heap_attisnull(indexRelation->rd_indextuple,
+ Anum_pg_index_indexprs, NULL) ||
+ !heap_attisnull(indexRelation->rd_indextuple,
+ Anum_pg_index_indpred, NULL))
+ return false;
+
+ natts = IndexRelationGetNumberOfAttributes(indexRelation);
+ nkeys = IndexRelationGetNumberOfKeyAttributes(indexRelation);
+ if (nkeys <= 0 || nkeys > natts || natts > INDEX_MAX_KEYS)
+ return false;
+
+ for (int i = 0; i < natts; i++)
+ {
+ if (indexRelation->rd_index->indkey.values[i] <= 0)
+ return false;
+
+ if (i < nkeys &&
+ !OidIsValid(index_getprocid(indexRelation, i + 1,
+ GIST_SORTSUPPORT_PROC)))
+ return false;
+ }
+
+ return true;
+}
+
+/*
+ * Prepare per-batch ordering by the remembered clustered key.
+ *
+ * GiST opclasses can participate when they provide the sort support function
+ * used by sorted GiST index builds; compress values first so the comparator
+ * sees the same representation as the GiST build tuplesort path. The
+ * target-block probe remains the fallback for btree and other AMs.
+ */
+static int
+heap_prepare_clustered_write_sort(Relation relation,
+ Relation indexRelation,
+ HeapTupleClusteredWriteItem *items,
+ int ntuples,
+ HeapTupleClusteredSortContext *context)
+{
+ GISTSTATE *giststate = NULL;
+ MemoryContext oldcontext;
+ int natts;
+ int nkeys;
+
+ context->nkeys = 0;
+ context->sortCxt = NULL;
+ context->compareCxt = NULL;
+
+ if (!heap_clustered_write_index_can_sort(relation, indexRelation))
+ return 0;
+
+ natts = IndexRelationGetNumberOfAttributes(indexRelation);
+ nkeys = IndexRelationGetNumberOfKeyAttributes(indexRelation);
+ context->sortCxt = AllocSetContextCreate(CurrentMemoryContext,
+ "heap clustered write sort",
+ ALLOCSET_DEFAULT_SIZES);
+ context->compareCxt = AllocSetContextCreate(context->sortCxt,
+ "heap clustered write compare",
+ ALLOCSET_DEFAULT_SIZES);
+ oldcontext = MemoryContextSwitchTo(context->sortCxt);
+
+ for (int i = 0; i < nkeys; i++)
+ {
+ SortSupport sortKey = &context->sortKeys[i];
+
+ memset(sortKey, 0, sizeof(SortSupportData));
+ sortKey->ssup_cxt = CurrentMemoryContext;
+ sortKey->ssup_collation = indexRelation->rd_indcollation[i];
+ sortKey->ssup_nulls_first = false;
+ sortKey->ssup_attno = i + 1;
+ sortKey->abbreviate = false;
+
+ PrepareSortSupportFromGistIndexRel(indexRelation, sortKey);
+ }
+
+ giststate = initGISTstate(indexRelation);
+ giststate->tempCxt = context->sortCxt;
+
+ for (int i = 0; i < ntuples; i++)
+ {
+ items[i].hasClusterKey = true;
+
+ for (int key = 0; key < natts; key++)
+ {
+ AttrNumber attnum = indexRelation->rd_index->indkey.values[key];
+
+ items[i].clusterValues[key] =
+ heap_getattr(items[i].tuple, attnum, relation->rd_att,
+ &items[i].clusterIsNull[key]);
+ }
+
+ gistCompressValues(giststate, indexRelation,
+ items[i].clusterValues,
+ items[i].clusterIsNull, true,
+ items[i].clusterValues);
+ }
+
+ context->nkeys = nkeys;
+ freeGISTstate(giststate);
+ MemoryContextSwitchTo(oldcontext);
+
+ return nkeys;
+}
+
/*
* This table lists the heavyweight lock mode that corresponds to each tuple
@@ -2030,6 +2355,8 @@ heap_insert(Relation relation, HeapTuple tup, CommandId cid,
* this will also pin the requisite visibility map page.
*/
buffer = RelationGetBufferForTuple(relation, heaptup->t_len,
+ heaptup,
+ InvalidBlockNumber,
InvalidBuffer, options, bistate,
&vmbuffer, NULL,
0);
@@ -2284,6 +2611,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
{
TransactionId xid = GetCurrentTransactionId();
HeapTuple *heaptuples;
+ int *heaptuple_slot_indexes = NULL;
+ BlockNumber *heaptuple_clustered_target_blocks = NULL;
+ bool heaptuple_skip_clustered_target_lookup = false;
int i;
int ndone;
PGAlignedBlock scratch;
@@ -2319,6 +2649,520 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
options);
}
+ /*
+ * Clustered multi-inserts work best when tuples targeting the same
+ * already-clustered heap area are adjacent. Compute candidate blocks
+ * before entering the critical insertion loop, then keep tuples without a
+ * clustered-index target in input order behind the clustered groups.
+ */
+ if ((options & HEAP_INSERT_SKIP_FSM) == 0 && ntuples > 1)
+ {
+ HeapTupleClusteredWriteItem *clustered;
+ HeapTupleClusteredSortContext sortContext = {0};
+ Oid clusteredIndexOid = InvalidOid;
+ Relation clusteredIndexRelation = NULL;
+ bool use_clustered_target_probe = false;
+ bool use_clustered_sort = false;
+ bool has_clustered_target = false;
+ bool has_clustered_sort = false;
+ bool has_skipped_clustered_target_probe = false;
+ AttrNumber prefixCacheAttnum = InvalidAttrNumber;
+ Oid prefixCacheCollation = InvalidOid;
+ RegProcedure prefixCacheEqProc = InvalidOid;
+ HeapTupleClusteredTargetCacheEntry *prefixTargetCache = NULL;
+ HeapTupleClusteredPrefixCountEntry *prefixCountCache = NULL;
+ int *prefixCountSlots = NULL;
+ MemoryContext prefixCacheCompareCxt = NULL;
+ bool usePrefixHashCache = false;
+ int prefixTargetCacheLimit = 0;
+ int prefixTargetCacheSize = 0;
+ int prefixCountCacheSize = 0;
+ int prefixTargetCacheMask = 0;
+ int hotPrefixTupleCount = 0;
+
+ if (!IsBootstrapProcessingMode())
+ {
+ clusteredIndexOid = RelationGetClusteredIndex(relation);
+ if (OidIsValid(clusteredIndexOid))
+ clusteredIndexRelation =
+ try_index_open(clusteredIndexOid, AccessShareLock);
+ }
+ if (RelationCanUseClusteredTargetProbe(relation,
+ clusteredIndexRelation))
+ use_clustered_target_probe = true;
+ else if (heap_clustered_write_index_can_sort(relation,
+ clusteredIndexRelation))
+ use_clustered_sort = true;
+
+ if (use_clustered_target_probe || use_clustered_sort)
+ {
+ /*
+ * Clustered btree indexes commonly group many batch tuples by
+ * their leading key. For single-column indexes this is the exact
+ * clustered key; for composite indexes the trailing key can be a
+ * new object id. A small per-batch cache avoids repeating the same
+ * anchored probe for every tuple in that group. The equality
+ * function may detoast or allocate, so comparisons run in a
+ * resettable context.
+ */
+ if (use_clustered_target_probe)
+ {
+ Oid eqOperator;
+
+ prefixCacheAttnum =
+ clusteredIndexRelation->rd_index->indkey.values[0];
+ prefixCacheCollation =
+ clusteredIndexRelation->rd_indcollation[0];
+ eqOperator =
+ get_opfamily_member(clusteredIndexRelation->rd_opfamily[0],
+ clusteredIndexRelation->rd_opcintype[0],
+ clusteredIndexRelation->rd_opcintype[0],
+ BTEqualStrategyNumber);
+ if (OidIsValid(eqOperator))
+ prefixCacheEqProc = get_opcode(eqOperator);
+ if (prefixCacheAttnum <= 0 ||
+ !RegProcedureIsValid(prefixCacheEqProc))
+ prefixCacheAttnum = InvalidAttrNumber;
+ else
+ {
+ /*
+ * Very hot simple prefixes do not need the full prefix
+ * cache machinery. If the whole COPY batch has the same
+ * simple key, skip clustered placement before allocating
+ * the cache and take the regular bulk/FSM path instead.
+ */
+ if (heap_clustered_write_prefix_can_compare_all_equal(
+ clusteredIndexRelation->rd_opcintype[0]))
+ {
+ Datum firstPrefixValue;
+ Datum lastPrefixValue;
+ bool firstPrefixIsNull;
+ bool lastPrefixIsNull;
+ bool allSamePrefix = false;
+ bool comparePrefixByDatum;
+
+ comparePrefixByDatum =
+ heap_clustered_write_prefix_can_compare_by_datum(
+ clusteredIndexRelation->rd_opcintype[0]);
+
+ firstPrefixValue =
+ heap_getattr(heaptuples[0],
+ prefixCacheAttnum,
+ relation->rd_att,
+ &firstPrefixIsNull);
+ lastPrefixValue =
+ heap_getattr(heaptuples[ntuples - 1],
+ prefixCacheAttnum,
+ relation->rd_att,
+ &lastPrefixIsNull);
+
+ if (!firstPrefixIsNull && !lastPrefixIsNull)
+ {
+ if (comparePrefixByDatum)
+ allSamePrefix =
+ firstPrefixValue == lastPrefixValue;
+ else
+ {
+ MemoryContext oldcontext;
+
+ prefixCacheCompareCxt =
+ AllocSetContextCreate(CurrentMemoryContext,
+ "clustered hot prefix compare",
+ ALLOCSET_DEFAULT_SIZES);
+ oldcontext =
+ MemoryContextSwitchTo(prefixCacheCompareCxt);
+ allSamePrefix =
+ DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc,
+ prefixCacheCollation,
+ firstPrefixValue,
+ lastPrefixValue));
+ MemoryContextSwitchTo(oldcontext);
+ MemoryContextReset(prefixCacheCompareCxt);
+ }
+
+ if (allSamePrefix)
+ {
+ for (i = 1; i < ntuples - 1; i++)
+ {
+ Datum prefixValue;
+ bool prefixIsNull;
+
+ prefixValue =
+ heap_getattr(heaptuples[i],
+ prefixCacheAttnum,
+ relation->rd_att,
+ &prefixIsNull);
+ if (prefixIsNull)
+ {
+ allSamePrefix = false;
+ break;
+ }
+
+ if (comparePrefixByDatum)
+ {
+ if (prefixValue != firstPrefixValue)
+ {
+ allSamePrefix = false;
+ break;
+ }
+ }
+ else
+ {
+ MemoryContext oldcontext;
+
+ oldcontext =
+ MemoryContextSwitchTo(prefixCacheCompareCxt);
+ allSamePrefix =
+ DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc,
+ prefixCacheCollation,
+ firstPrefixValue,
+ prefixValue));
+ MemoryContextSwitchTo(oldcontext);
+ MemoryContextReset(prefixCacheCompareCxt);
+
+ if (!allSamePrefix)
+ break;
+ }
+ }
+ }
+ }
+
+ if (allSamePrefix &&
+ ntuples >
+ CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES)
+ hotPrefixTupleCount = ntuples;
+ }
+
+ if (hotPrefixTupleCount == ntuples)
+ goto skip_clustered_prefix_cache;
+
+ prefixTargetCacheLimit =
+ pg_nextpower2_32(Max(16,
+ ntuples *
+ CLUSTERED_WRITE_PREFIX_TARGET_HASH_FACTOR));
+ usePrefixHashCache =
+ heap_clustered_write_prefix_can_hash(
+ clusteredIndexRelation->rd_opcintype[0]);
+
+ if (!usePrefixHashCache)
+ prefixTargetCacheLimit = ntuples;
+ prefixTargetCache =
+ palloc0_array(HeapTupleClusteredTargetCacheEntry,
+ prefixTargetCacheLimit);
+ prefixCountCache =
+ palloc0_array(HeapTupleClusteredPrefixCountEntry,
+ prefixTargetCacheLimit);
+ prefixCountSlots = palloc_array(int, ntuples);
+ for (i = 0; i < ntuples; i++)
+ prefixCountSlots[i] = -1;
+ prefixTargetCacheMask = prefixTargetCacheLimit - 1;
+ if (prefixCacheCompareCxt == NULL)
+ prefixCacheCompareCxt =
+ AllocSetContextCreate(CurrentMemoryContext,
+ "clustered target cache compare",
+ ALLOCSET_DEFAULT_SIZES);
+
+ for (i = 0; i < ntuples; i++)
+ {
+ Datum prefixValue;
+ bool prefixIsNull;
+ bool foundCount = false;
+ int prefixCacheSlot = -1;
+ int ncacheEntries;
+
+ prefixValue =
+ heap_getattr(heaptuples[i],
+ prefixCacheAttnum,
+ relation->rd_att,
+ &prefixIsNull);
+ if (prefixIsNull)
+ continue;
+
+ if (usePrefixHashCache)
+ {
+ uint32 prefixHash;
+
+ prefixHash =
+ heap_clustered_write_prefix_hash(prefixValue,
+ clusteredIndexRelation->rd_opcintype[0]);
+ prefixCacheSlot =
+ prefixHash & prefixTargetCacheMask;
+ ncacheEntries =
+ prefixCountCache[prefixCacheSlot].occupied ?
+ 1 : 0;
+ }
+ else
+ ncacheEntries = prefixCountCacheSize;
+
+ for (int j = 0; j < ncacheEntries; j++)
+ {
+ bool equal;
+ MemoryContext oldcontext;
+ int cacheSlot;
+
+ cacheSlot = usePrefixHashCache ?
+ prefixCacheSlot : j;
+
+ oldcontext =
+ MemoryContextSwitchTo(prefixCacheCompareCxt);
+ equal =
+ DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc,
+ prefixCacheCollation,
+ prefixCountCache[cacheSlot].prefixValue,
+ prefixValue));
+ MemoryContextSwitchTo(oldcontext);
+ MemoryContextReset(prefixCacheCompareCxt);
+
+ if (equal)
+ {
+ prefixCountCache[cacheSlot].ntuples++;
+ prefixCountSlots[i] = cacheSlot;
+ foundCount = true;
+ break;
+ }
+ }
+
+ if (!foundCount)
+ {
+ int cacheSlot;
+
+ if (usePrefixHashCache)
+ {
+ cacheSlot = prefixCacheSlot;
+ if (prefixCountCache[cacheSlot].occupied)
+ continue;
+ }
+ else
+ {
+ Assert(prefixCountCacheSize <
+ prefixTargetCacheLimit);
+ cacheSlot = prefixCountCacheSize++;
+ }
+
+ prefixCountCache[cacheSlot].prefixValue =
+ prefixValue;
+ prefixCountCache[cacheSlot].ntuples = 1;
+ prefixCountCache[cacheSlot].occupied = true;
+ prefixCountSlots[i] = cacheSlot;
+ }
+ }
+
+ if (prefixCountCache != NULL)
+ {
+ int ncountSlots = usePrefixHashCache ?
+ prefixTargetCacheLimit : prefixCountCacheSize;
+
+ for (i = 0; i < ncountSlots; i++)
+ {
+ if (prefixCountCache[i].occupied &&
+ prefixCountCache[i].ntuples >
+ CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES)
+ hotPrefixTupleCount +=
+ prefixCountCache[i].ntuples;
+ }
+ }
+ }
+ }
+
+skip_clustered_prefix_cache:
+
+ if (use_clustered_target_probe &&
+ hotPrefixTupleCount == ntuples)
+ {
+ heaptuple_skip_clustered_target_lookup = true;
+ clustered = NULL;
+ if (prefixCacheCompareCxt != NULL)
+ {
+ MemoryContextDelete(prefixCacheCompareCxt);
+ prefixCacheCompareCxt = NULL;
+ }
+ }
+ else
+ {
+ clustered = palloc_array(HeapTupleClusteredWriteItem, ntuples);
+ for (i = 0; i < ntuples; i++)
+ {
+ clustered[i].tuple = heaptuples[i];
+ clustered[i].hasClusterKey = false;
+ clustered[i].targetBlock = InvalidBlockNumber;
+ if (use_clustered_target_probe)
+ {
+ BlockNumber targetBlock;
+ Datum prefixValue = (Datum) 0;
+ bool prefixIsNull = true;
+ bool foundCachedTarget = false;
+ bool skipClusteredTargetProbe = false;
+ int prefixCacheSlot = -1;
+
+ if (prefixTargetCache != NULL)
+ {
+ if (prefixCountSlots != NULL &&
+ prefixCountSlots[i] >= 0)
+ {
+ int countSlot = prefixCountSlots[i];
+
+ skipClusteredTargetProbe =
+ prefixCountCache[countSlot].ntuples >
+ CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES;
+ }
+
+ if (!skipClusteredTargetProbe)
+ {
+ prefixValue =
+ heap_getattr(heaptuples[i],
+ prefixCacheAttnum,
+ relation->rd_att,
+ &prefixIsNull);
+ }
+ if (!prefixIsNull && !skipClusteredTargetProbe)
+ {
+ int ncacheEntries;
+
+ if (usePrefixHashCache)
+ {
+ uint32 prefixHash;
+
+ prefixHash =
+ heap_clustered_write_prefix_hash(prefixValue,
+ clusteredIndexRelation->rd_opcintype[0]);
+ prefixCacheSlot =
+ prefixHash & prefixTargetCacheMask;
+ ncacheEntries =
+ prefixTargetCache[prefixCacheSlot].occupied ?
+ 1 : 0;
+ }
+ else
+ ncacheEntries = prefixTargetCacheSize;
+
+ for (int j = 0; j < ncacheEntries; j++)
+ {
+ bool equal;
+ MemoryContext oldcontext;
+ int cacheSlot;
+
+ cacheSlot = usePrefixHashCache ?
+ prefixCacheSlot : j;
+
+ oldcontext =
+ MemoryContextSwitchTo(prefixCacheCompareCxt);
+ equal =
+ DatumGetBool(OidFunctionCall2Coll(prefixCacheEqProc,
+ prefixCacheCollation,
+ prefixTargetCache[cacheSlot].prefixValue,
+ prefixValue));
+ MemoryContextSwitchTo(oldcontext);
+ MemoryContextReset(prefixCacheCompareCxt);
+
+ if (equal)
+ {
+ clustered[i].targetBlock =
+ prefixTargetCache[cacheSlot].targetBlock;
+ foundCachedTarget = true;
+ break;
+ }
+ }
+ }
+ }
+ if (skipClusteredTargetProbe)
+ has_skipped_clustered_target_probe = true;
+
+ if (!foundCachedTarget && !skipClusteredTargetProbe)
+ {
+ if (RelationGetClusteredTargetBlocksFromIndex(relation,
+ clusteredIndexRelation,
+ heaptuples[i],
+ heaptuples[i]->t_len,
+ &targetBlock,
+ 1,
+ true,
+ NULL) > 0)
+ clustered[i].targetBlock = targetBlock;
+
+ if (prefixTargetCache != NULL && !prefixIsNull)
+ {
+ int cacheSlot;
+
+ if (usePrefixHashCache)
+ cacheSlot = prefixCacheSlot;
+ else
+ {
+ Assert(prefixTargetCacheSize <
+ prefixTargetCacheLimit);
+ cacheSlot = prefixTargetCacheSize++;
+ }
+
+ prefixTargetCache[cacheSlot].prefixValue =
+ prefixValue;
+ prefixTargetCache[cacheSlot].targetBlock =
+ clustered[i].targetBlock;
+ prefixTargetCache[cacheSlot].occupied = true;
+ }
+ }
+ }
+ clustered[i].inputIndex = i;
+ if (clustered[i].targetBlock != InvalidBlockNumber)
+ has_clustered_target = true;
+ }
+ }
+
+ if (use_clustered_sort && clustered != NULL)
+ has_clustered_sort =
+ heap_prepare_clustered_write_sort(relation,
+ clusteredIndexRelation,
+ clustered, ntuples,
+ &sortContext) > 0;
+
+ if (has_clustered_target || has_clustered_sort)
+ {
+ heaptuple_slot_indexes = palloc_array(int, ntuples);
+ heaptuple_clustered_target_blocks =
+ palloc_array(BlockNumber, ntuples);
+ qsort_arg(clustered, ntuples,
+ sizeof(HeapTupleClusteredWriteItem),
+ heap_clustered_write_item_cmp,
+ has_clustered_sort ? &sortContext : NULL);
+ for (i = 0; i < ntuples; i++)
+ {
+ heaptuples[i] = clustered[i].tuple;
+ heaptuple_slot_indexes[i] = clustered[i].inputIndex;
+ heaptuple_clustered_target_blocks[i] =
+ clustered[i].targetBlock;
+ }
+ if (sortContext.sortCxt != NULL)
+ MemoryContextDelete(sortContext.sortCxt);
+ }
+ else if (has_skipped_clustered_target_probe)
+ {
+ /*
+ * Dense equal-prefix batches intentionally skipped the
+ * precomputed clustered target probe. Keep the actual insert
+ * on the regular bulk/FSM path too, without materializing a
+ * per-tuple InvalidBlockNumber array solely to suppress lazy
+ * clustered lookups in RelationGetBufferForTuple().
+ */
+ heaptuple_skip_clustered_target_lookup = true;
+ if (sortContext.sortCxt != NULL)
+ MemoryContextDelete(sortContext.sortCxt);
+ }
+ else if (sortContext.sortCxt != NULL)
+ MemoryContextDelete(sortContext.sortCxt);
+
+ if (clustered != NULL)
+ pfree(clustered);
+ if (prefixTargetCache != NULL)
+ {
+ pfree(prefixCountSlots);
+ pfree(prefixCountCache);
+ pfree(prefixTargetCache);
+ if (prefixCacheCompareCxt != NULL)
+ MemoryContextDelete(prefixCacheCompareCxt);
+ }
+ }
+
+ if (clusteredIndexRelation != NULL)
+ index_close(clusteredIndexRelation, AccessShareLock);
+ }
+
/*
* We're about to do the actual inserts -- but check for conflict first,
* to minimize the possibility of having to roll back work we've just
@@ -2348,6 +3192,9 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
while (ndone < ntuples)
{
Buffer buffer;
+ HeapTuple clustered_target_tuple = heaptuples[ndone];
+ BlockNumber clustered_target_block = InvalidBlockNumber;
+ uint32 buffer_options = options;
bool all_visible_cleared = false;
bool all_frozen_set = false;
int nthispage;
@@ -2380,8 +3227,31 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
* Also pin visibility map page if COPY FREEZE inserts tuples into an
* empty page. See all_frozen_set below.
*/
+ if (heaptuple_skip_clustered_target_lookup)
+ {
+ clustered_target_tuple = NULL;
+
+ /*
+ * This COPY batch was already classified as too dense for
+ * clustered target probing. Do not ask the FSM for old pages
+ * either: those pages are likely fillfactor reserve that should
+ * stay available for future updates, and scanning them just adds
+ * work before the dense run appends anyway.
+ */
+ buffer_options |= HEAP_INSERT_SKIP_FSM;
+ }
+ else if (heaptuple_clustered_target_blocks != NULL)
+ {
+ clustered_target_block =
+ heaptuple_clustered_target_blocks[ndone];
+ if (clustered_target_block == InvalidBlockNumber)
+ clustered_target_tuple = NULL;
+ }
+
buffer = RelationGetBufferForTuple(relation, heaptuples[ndone]->t_len,
- InvalidBuffer, options, bistate,
+ clustered_target_tuple,
+ clustered_target_block,
+ InvalidBuffer, buffer_options, bistate,
&vmbuffer, NULL,
npages - npages_used);
page = BufferGetPage(buffer);
@@ -2415,6 +3285,35 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
{
HeapTuple heaptup = heaptuples[ndone + nthispage];
+ /*
+ * Clustered batch sorting groups tuples by a precomputed target
+ * block, but the normal multi-insert page packer would otherwise
+ * keep filling the first selected page with later tuples that were
+ * aiming at a different clustered neighborhood. Keep that
+ * high-throughput packing behavior for ordinary tuples, while
+ * preserving clustered placement boundaries when the batch has
+ * remembered targets.
+ */
+ if (heaptuple_clustered_target_blocks != NULL)
+ {
+ BlockNumber nextTargetBlock =
+ heaptuple_clustered_target_blocks[ndone + nthispage];
+
+ if (BlockNumberIsValid(clustered_target_block))
+ {
+ /*
+ * Compare against the remembered clustered target, not
+ * the actual buffer. A full clustered neighbour may
+ * redirect the run to the tail, where same-target tuples
+ * should still be packed together.
+ */
+ if (nextTargetBlock != clustered_target_block)
+ break;
+ }
+ else if (BlockNumberIsValid(nextTargetBlock))
+ break;
+ }
+
if (PageGetHeapFreeSpace(page) < MAXALIGN(heaptup->t_len) + saveFreeSpace)
break;
@@ -2640,8 +3539,18 @@ heap_multi_insert(Relation relation, TupleTableSlot **slots, int ntuples,
}
/* copy t_self fields back to the caller's slots */
- for (i = 0; i < ntuples; i++)
- slots[i]->tts_tid = heaptuples[i]->t_self;
+ if (heaptuple_slot_indexes != NULL)
+ {
+ for (i = 0; i < ntuples; i++)
+ slots[heaptuple_slot_indexes[i]]->tts_tid = heaptuples[i]->t_self;
+ pfree(heaptuple_slot_indexes);
+ pfree(heaptuple_clustered_target_blocks);
+ }
+ else
+ {
+ for (i = 0; i < ntuples; i++)
+ slots[i]->tts_tid = heaptuples[i]->t_self;
+ }
pgstat_count_heap_insert(relation, ntuples);
}
@@ -3906,6 +4815,8 @@ heap_update(Relation relation, const ItemPointerData *otid, HeapTuple newtup,
{
/* It doesn't fit, must use RelationGetBufferForTuple. */
newbuf = RelationGetBufferForTuple(relation, heaptup->t_len,
+ NULL,
+ InvalidBlockNumber,
buffer, 0, NULL,
&vmbuffer_new, &vmbuffer,
0);
diff --git a/src/backend/access/heap/hio.c b/src/backend/access/heap/hio.c
index e96e0f77d9264..a877fd90d6786 100644
--- a/src/backend/access/heap/hio.c
+++ b/src/backend/access/heap/hio.c
@@ -15,14 +15,150 @@
#include "postgres.h"
+#include "access/amapi.h"
+#include "access/genam.h"
#include "access/heapam.h"
#include "access/hio.h"
#include "access/htup_details.h"
#include "access/visibilitymap.h"
+#include "catalog/pg_am_d.h"
+#include "catalog/pg_index.h"
+#include "miscadmin.h"
#include "storage/bufmgr.h"
#include "storage/freespace.h"
#include "storage/lmgr.h"
+#include "utils/datum.h"
+#include "utils/lsyscache.h"
+#include "utils/memutils.h"
+#include "utils/relcache.h"
+#include "utils/snapmgr.h"
+/*
+ * Keep clustered-write placement bounded. Large duplicate-key ranges can
+ * span many heap pages; probing a small number of distinct candidate pages is
+ * enough to avoid the worst "first equal tuple is on a full page" case without
+ * turning every insert into a long index walk.
+ */
+#define CLUSTERED_WRITE_MAX_INDEX_TIDS 1024
+#define CLUSTERED_WRITE_MAX_HEAP_BLOCKS 32
+#define CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES 32
+#define CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES 1024
+#define CLUSTERED_WRITE_OVERFLOW_RESERVE_USES 8
+#define CLUSTERED_WRITE_CACHE_MAGIC 0x48435743 /* HCWC */
+
+typedef struct HeapClusteredWriteOverflowEntry
+{
+ bool active;
+ AttrNumber overflowAttnum;
+ Oid overflowCollation;
+ RegProcedure overflowEqProc;
+ Datum overflowValue;
+ Size overflowValueLen;
+ int16 overflowTypLen;
+ uint16 overflowReserveUses;
+ bool overflowTypByVal;
+ BlockNumber overflowTargetBlock;
+ char overflowValueStorage[CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES];
+} HeapClusteredWriteOverflowEntry;
+
+typedef struct HeapClusteredWriteCache
+{
+ uint32 magic;
+ uint32 overflowNextEntry;
+ HeapClusteredWriteOverflowEntry overflowEntries[CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES];
+} HeapClusteredWriteCache;
+
+static bool ClusteredWriteRememberCandidate(Relation relation,
+ BlockNumber nblocks,
+ ItemPointer tid,
+ BlockNumber *candidates,
+ Size *candidateFreeSpace,
+ int *ncandidates,
+ int maxCandidates);
+static bool ClusteredWriteHasFittingCandidate(Size *candidateFreeSpace,
+ int ncandidates,
+ Size len);
+static void ClusteredWriteRememberPrefixCandidates(Relation relation,
+ Relation indexRelation,
+ BlockNumber nblocks,
+ ScanKey skey,
+ int nscankeys,
+ ScanDirection direction,
+ BlockNumber *candidates,
+ Size *candidateFreeSpace,
+ int *ncandidates,
+ int *ntuples,
+ int tupleLimit,
+ int candidateLimit);
+static int RelationGetClusteredTargetBlocksForTuple(Relation relation,
+ HeapTuple tuple,
+ Size len,
+ BlockNumber *targetBlocks,
+ int maxTargetBlocks,
+ bool firstCandidateOnly,
+ bool *clusteredCandidatesExhausted);
+static bool ClusteredWriteGetCachedOverflowTarget(Relation relation,
+ HeapTuple tuple,
+ BlockNumber *targetBlock);
+static void ClusteredWriteRememberOverflowTarget(Relation relation,
+ HeapTuple tuple,
+ BlockNumber targetBlock);
+static void ClusteredWriteRememberReserveUse(Relation relation,
+ HeapTuple tuple,
+ BlockNumber targetBlock);
+static void ClusteredWriteUpdateCachedOverflowTarget(Relation relation,
+ HeapTuple tuple,
+ BlockNumber targetBlock);
+static bool ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry,
+ Datum value,
+ bool typByVal,
+ int16 typLen);
+
+/*
+ * RelationCanUseClusteredTargetProbe
+ *
+ * Return true when the remembered clustered index can support tuple-anchored
+ * target-block probes. Keep this in sync with the stronger per-tuple checks
+ * in RelationGetClusteredTargetBlocksFromIndex(), so callers can skip batch
+ * allocation and per-tuple work when the clustered index is not usable for
+ * heap page placement.
+ */
+bool
+RelationCanUseClusteredTargetProbe(Relation relation, Relation indexRelation)
+{
+ int nkeys;
+
+ if (IsBootstrapProcessingMode() || indexRelation == NULL)
+ return false;
+
+ if (relation->rd_rel->relkind != RELKIND_RELATION &&
+ relation->rd_rel->relkind != RELKIND_MATVIEW)
+ return false;
+
+ if (indexRelation->rd_index->indrelid != RelationGetRelid(relation) ||
+ indexRelation->rd_rel->relam != BTREE_AM_OID ||
+ !indexRelation->rd_indam->amclusterable ||
+ indexRelation->rd_indam->amgettuple == NULL ||
+ !indexRelation->rd_index->indisvalid ||
+ !indexRelation->rd_index->indisready ||
+ !heap_attisnull(indexRelation->rd_indextuple,
+ Anum_pg_index_indexprs, NULL) ||
+ !heap_attisnull(indexRelation->rd_indextuple,
+ Anum_pg_index_indpred, NULL))
+ return false;
+
+ nkeys = indexRelation->rd_index->indnkeyatts;
+ if (nkeys <= 0 || nkeys > INDEX_MAX_KEYS)
+ return false;
+
+ for (int i = 0; i < nkeys; i++)
+ {
+ if (indexRelation->rd_index->indkey.values[i] <= 0)
+ return false;
+ }
+
+ return true;
+}
/*
* RelationPutHeapTuple - place tuple at specified page
@@ -79,6 +215,739 @@ RelationPutHeapTuple(Relation relation,
}
}
+/*
+ * Remember a distinct heap block found through the clustered index. The
+ * caller still rechecks page space under the buffer lock before inserting.
+ */
+static bool
+ClusteredWriteRememberCandidate(Relation relation, BlockNumber nblocks,
+ ItemPointer tid, BlockNumber *candidates,
+ Size *candidateFreeSpace, int *ncandidates,
+ int maxCandidates)
+{
+ BlockNumber candidate;
+
+ Assert(maxCandidates > 0);
+ Assert(maxCandidates <= CLUSTERED_WRITE_MAX_HEAP_BLOCKS);
+
+ if (tid == NULL)
+ return false;
+
+ if (*ncandidates >= maxCandidates)
+ return false;
+
+ candidate = ItemPointerGetBlockNumber(tid);
+ if (candidate == InvalidBlockNumber || candidate >= nblocks)
+ return false;
+
+ for (int i = 0; i < *ncandidates; i++)
+ {
+ if (candidates[i] == candidate)
+ return false;
+ }
+
+ candidates[*ncandidates] = candidate;
+ if (candidateFreeSpace != NULL)
+ candidateFreeSpace[*ncandidates] = GetRecordedFreeSpace(relation,
+ candidate);
+ (*ncandidates)++;
+
+ return true;
+}
+
+static bool
+ClusteredWriteHasFittingCandidate(Size *candidateFreeSpace, int ncandidates,
+ Size len)
+{
+ for (int i = 0; i < ncandidates; i++)
+ {
+ if (candidateFreeSpace[i] >= len)
+ return true;
+ }
+
+ return false;
+}
+
+/*
+ * Remember candidate pages from one bounded scan of a btree equality-prefix
+ * range. Callers run this in both directions so large duplicate-key or
+ * prefix-key ranges contribute pages from each edge instead of only from the
+ * oldest/lowest TIDs.
+ */
+static void
+ClusteredWriteRememberPrefixCandidates(Relation relation,
+ Relation indexRelation,
+ BlockNumber nblocks,
+ ScanKey skey,
+ int nscankeys,
+ ScanDirection direction,
+ BlockNumber *candidates,
+ Size *candidateFreeSpace,
+ int *ncandidates,
+ int *ntuples,
+ int tupleLimit,
+ int candidateLimit)
+{
+ IndexScanDesc scan;
+ ItemPointer tid;
+
+ Assert(nscankeys > 0);
+ Assert(tupleLimit > 0);
+ Assert(candidateLimit > 0);
+ Assert(candidateLimit <= CLUSTERED_WRITE_MAX_HEAP_BLOCKS);
+
+ scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL,
+ nscankeys, 0, 0);
+ index_rescan(scan, skey, nscankeys, NULL, 0);
+
+ while ((tid = index_getnext_tid(scan, direction)) != NULL)
+ {
+ if (*ntuples >= tupleLimit)
+ break;
+ (*ntuples)++;
+
+ ClusteredWriteRememberCandidate(relation, nblocks, tid,
+ candidates, candidateFreeSpace,
+ ncandidates, candidateLimit);
+ if (*ncandidates >= candidateLimit)
+ break;
+ }
+
+ index_endscan(scan);
+}
+
+/*
+ * RelationGetClusteredTargetBlocksFromIndex
+ *
+ * For relations with CLUSTER metadata, try to use the clustered index to find
+ * heap blocks that are already close in the relation's clustered order. A
+ * btree key probe can find equal-key neighbours directly; if the full key has
+ * no matches, shorter left-prefix probes can still find the existing key
+ * group for composite indexes such as (tile_id, osm_id).
+ *
+ * firstCandidateOnly is used by heap_multi_insert batch preparation, where the
+ * caller only wants a cheap preferred block before the locked page-space
+ * recheck. If that preferred page is full, RelationGetBufferForTuple() falls
+ * back to the full bounded candidate search.
+ *
+ * Do not use an unqualified clustered-index scan as an insertion hint. For
+ * GiST, expression indexes, and btree rows whose leading key is NULL, such a
+ * scan is not anchored to the tuple being inserted and tends to find the same
+ * candidate pages for unrelated tuples. GiST can still improve multi-inserts
+ * through the batch sort path in heapam.c when the opclass exposes sort
+ * support.
+ *
+ * Partial clustered indexes are rejected by CLUSTER, but guard against them
+ * here anyway in case catalog state is transient or manually corrupted.
+ */
+int
+RelationGetClusteredTargetBlocksFromIndex(Relation relation,
+ Relation indexRelation,
+ HeapTuple tuple,
+ Size len,
+ BlockNumber *targetBlocks,
+ int maxTargetBlocks,
+ bool firstCandidateOnly,
+ bool *clusteredCandidatesExhausted)
+{
+ ScanKeyData skey[INDEX_MAX_KEYS];
+ IndexScanDesc scan;
+ ItemPointer tid;
+ BlockNumber candidates[CLUSTERED_WRITE_MAX_HEAP_BLOCKS];
+ Size candidateFreeSpace[CLUSTERED_WRITE_MAX_HEAP_BLOCKS];
+ Size *candidateFreeSpacePtr;
+ BlockNumber nblocks;
+ int nkeys;
+ int nscankeys = 0;
+ int ntuples = 0;
+ int ncandidates = 0;
+ int ntargets = 0;
+
+ Assert(maxTargetBlocks > 0);
+
+ if (clusteredCandidatesExhausted != NULL)
+ *clusteredCandidatesExhausted = false;
+
+ candidateFreeSpacePtr = firstCandidateOnly ? NULL : candidateFreeSpace;
+
+ if (IsBootstrapProcessingMode() || indexRelation == NULL ||
+ tuple == NULL || len > MaxHeapTupleSize)
+ return 0;
+
+ if (!RelationCanUseClusteredTargetProbe(relation, indexRelation))
+ return 0;
+
+ nkeys = indexRelation->rd_index->indnkeyatts;
+ nblocks = RelationGetNumberOfBlocks(relation);
+
+ for (int i = 0; i < nkeys; i++)
+ {
+ AttrNumber attnum = indexRelation->rd_index->indkey.values[i];
+ Datum value;
+ bool isnull;
+ Oid eqOperator;
+ RegProcedure eqProcedure;
+
+ Assert(attnum > 0);
+
+ value = heap_getattr(tuple, attnum, relation->rd_att, &isnull);
+ if (isnull)
+ break;
+
+ eqOperator = get_opfamily_member(indexRelation->rd_opfamily[i],
+ indexRelation->rd_opcintype[i],
+ indexRelation->rd_opcintype[i],
+ BTEqualStrategyNumber);
+ if (!OidIsValid(eqOperator))
+ break;
+
+ eqProcedure = get_opcode(eqOperator);
+ if (!RegProcedureIsValid(eqProcedure))
+ break;
+
+ ScanKeyEntryInitialize(&skey[i],
+ 0,
+ i + 1,
+ BTEqualStrategyNumber,
+ InvalidOid,
+ indexRelation->rd_indcollation[i],
+ eqProcedure,
+ value);
+ nscankeys++;
+ }
+
+ if (nscankeys == 0)
+ return 0;
+
+ for (int probeKeys = nscankeys; probeKeys > 0; probeKeys--)
+ {
+ int forwardCandidateLimit;
+ int remainingCandidates;
+ int remainingTuples;
+ int forwardLimit;
+
+ /*
+ * Plain btree keys can use equality scan keys and retry shorter left
+ * prefixes. Probe each matching range from both ends, so very large
+ * duplicate-key ranges do not only contribute their first, often-full
+ * heap pages. Unqualified scans are deliberately skipped because
+ * they are not anchored to the tuple being inserted.
+ */
+ remainingCandidates = CLUSTERED_WRITE_MAX_HEAP_BLOCKS - ncandidates;
+ remainingTuples = CLUSTERED_WRITE_MAX_INDEX_TIDS - ntuples;
+ if (remainingCandidates <= 0 || remainingTuples <= 0)
+ break;
+
+ /*
+ * Reserve roughly half the remaining TID and candidate-page budgets
+ * for the backward scan, so a very large range cannot spend all of
+ * either budget at its head.
+ */
+ if (firstCandidateOnly)
+ {
+ forwardCandidateLimit = ncandidates + 1;
+ forwardLimit = ntuples + 1;
+ }
+ else
+ {
+ forwardCandidateLimit = ncandidates +
+ Max(1, remainingCandidates / 2);
+ forwardLimit = ntuples + Max(1, remainingTuples / 2);
+ }
+ ClusteredWriteRememberPrefixCandidates(relation, indexRelation,
+ nblocks, skey, probeKeys,
+ ForwardScanDirection,
+ candidates,
+ candidateFreeSpacePtr,
+ &ncandidates, &ntuples,
+ forwardLimit,
+ forwardCandidateLimit);
+
+ if (firstCandidateOnly && ncandidates > 0)
+ break;
+
+ /*
+ * The backward edge is only needed when the forward edge did not find
+ * any candidate page that the FSM already considers large enough.
+ * This avoids doubling index probes for the common case while still
+ * helping large duplicate-key ranges whose first pages are full.
+ */
+ if (ntuples < CLUSTERED_WRITE_MAX_INDEX_TIDS &&
+ ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS &&
+ !firstCandidateOnly &&
+ !ClusteredWriteHasFittingCandidate(candidateFreeSpace,
+ ncandidates, len))
+ ClusteredWriteRememberPrefixCandidates(relation, indexRelation,
+ nblocks, skey, probeKeys,
+ BackwardScanDirection,
+ candidates,
+ candidateFreeSpacePtr,
+ &ncandidates, &ntuples,
+ CLUSTERED_WRITE_MAX_INDEX_TIDS,
+ CLUSTERED_WRITE_MAX_HEAP_BLOCKS);
+
+ if (ntuples >= CLUSTERED_WRITE_MAX_INDEX_TIDS ||
+ ncandidates >= CLUSTERED_WRITE_MAX_HEAP_BLOCKS)
+ break;
+ }
+
+ /*
+ * A clustered btree can have no equal-key or prefix-key neighbour yet,
+ * especially while an initially empty clustered index is being maintained
+ * during COPY. In that case, try the logical neighbourhood of the leading
+ * key: the first tuple at or after it, then the first tuple at or before
+ * it. That gives brand-new clustered keys a chance to land beside
+ * adjacent key ranges instead of being appended in input order.
+ */
+ if (ncandidates == 0 && nscankeys > 0)
+ {
+ ScanKeyData rangeKey;
+ Oid rangeOperator;
+ RegProcedure rangeProcedure;
+
+ rangeOperator = get_opfamily_member(indexRelation->rd_opfamily[0],
+ indexRelation->rd_opcintype[0],
+ indexRelation->rd_opcintype[0],
+ BTGreaterEqualStrategyNumber);
+ rangeProcedure = OidIsValid(rangeOperator) ?
+ get_opcode(rangeOperator) : InvalidOid;
+ if (RegProcedureIsValid(rangeProcedure))
+ {
+ ScanKeyEntryInitialize(&rangeKey,
+ 0,
+ 1,
+ BTGreaterEqualStrategyNumber,
+ InvalidOid,
+ indexRelation->rd_indcollation[0],
+ rangeProcedure,
+ skey[0].sk_argument);
+ scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL,
+ 1, 0, 0);
+ index_rescan(scan, &rangeKey, 1, NULL, 0);
+ tid = index_getnext_tid(scan, ForwardScanDirection);
+ if (tid != NULL)
+ {
+ ClusteredWriteRememberCandidate(relation, nblocks, tid,
+ candidates,
+ candidateFreeSpacePtr,
+ &ncandidates,
+ CLUSTERED_WRITE_MAX_HEAP_BLOCKS);
+ }
+ index_endscan(scan);
+ }
+
+ rangeOperator = get_opfamily_member(indexRelation->rd_opfamily[0],
+ indexRelation->rd_opcintype[0],
+ indexRelation->rd_opcintype[0],
+ BTLessEqualStrategyNumber);
+ rangeProcedure = OidIsValid(rangeOperator) ?
+ get_opcode(rangeOperator) : InvalidOid;
+ if (RegProcedureIsValid(rangeProcedure) &&
+ ncandidates < CLUSTERED_WRITE_MAX_HEAP_BLOCKS)
+ {
+ ScanKeyEntryInitialize(&rangeKey,
+ 0,
+ 1,
+ BTLessEqualStrategyNumber,
+ InvalidOid,
+ indexRelation->rd_indcollation[0],
+ rangeProcedure,
+ skey[0].sk_argument);
+ scan = index_beginscan(relation, indexRelation, SnapshotAny, NULL,
+ 1, 0, 0);
+ index_rescan(scan, &rangeKey, 1, NULL, 0);
+ tid = index_getnext_tid(scan, BackwardScanDirection);
+ if (tid != NULL)
+ {
+ ClusteredWriteRememberCandidate(relation, nblocks, tid,
+ candidates,
+ candidateFreeSpacePtr,
+ &ncandidates,
+ CLUSTERED_WRITE_MAX_HEAP_BLOCKS);
+ }
+ index_endscan(scan);
+ }
+ }
+
+ /*
+ * Prefer pages whose FSM entry already says they can fit the caller's
+ * target free-space requirement. When every clustered neighbor is below
+ * that normal insert threshold, let RelationGetBufferForTuple() fall back
+ * to the relation tail instead of consuming fillfactor reserve from many
+ * unrelated pages.
+ */
+ if (firstCandidateOnly)
+ {
+ if (ncandidates > 0)
+ targetBlocks[ntargets++] = candidates[0];
+ return ntargets;
+ }
+
+ for (int i = 0; i < ncandidates && ntargets < maxTargetBlocks; i++)
+ {
+ if (candidateFreeSpace[i] >= len)
+ targetBlocks[ntargets++] = candidates[i];
+ }
+ if (ntargets == 0 && ncandidates > 0 &&
+ clusteredCandidatesExhausted != NULL)
+ *clusteredCandidatesExhausted = true;
+
+ if (ntargets == 0)
+ {
+ /*
+ * CLUSTER can leave useful reserve space on heap pages before the FSM
+ * has a matching entry. Return the bounded neighbour list anyway and
+ * let RelationGetBufferForTuple() recheck the actual page space under
+ * lock; long equal-prefix runs are redirected through the overflow cache
+ * once they consume one below-fillfactor neighbour.
+ */
+ for (int i = 0; i < ncandidates && ntargets < maxTargetBlocks; i++)
+ targetBlocks[ntargets++] = candidates[i];
+ }
+
+ return ntargets;
+}
+
+static HeapClusteredWriteCache *
+ClusteredWriteGetCache(Relation relation)
+{
+ HeapClusteredWriteCache *cache;
+
+ cache = (HeapClusteredWriteCache *) relation->rd_amcache;
+ if (cache != NULL)
+ {
+ if (cache->magic == CLUSTERED_WRITE_CACHE_MAGIC)
+ return cache;
+
+ /*
+ * Heap owns rd_amcache for heap relations. If this ever fires, avoid
+ * interpreting another AM's private bytes as clustered-write state.
+ */
+ return NULL;
+ }
+
+ cache = MemoryContextAllocZero(CacheMemoryContext,
+ sizeof(HeapClusteredWriteCache));
+ cache->magic = CLUSTERED_WRITE_CACHE_MAGIC;
+ relation->rd_amcache = cache;
+ return cache;
+}
+
+static bool
+ClusteredWriteStoreOverflowValue(HeapClusteredWriteOverflowEntry *entry,
+ Datum value, bool typByVal, int16 typLen)
+{
+ entry->overflowTypByVal = typByVal;
+ entry->overflowTypLen = typLen;
+
+ if (typByVal)
+ {
+ entry->overflowValue = value;
+ entry->overflowValueLen = sizeof(Datum);
+ return true;
+ }
+
+ /*
+ * rd_amcache must be one palloc chunk. Keep small pass-by-reference
+ * prefixes inline and decline large values instead of allocating
+ * subsidiary chunks that relcache invalidation would not know to free.
+ */
+ entry->overflowValueLen = datumGetSize(value, typByVal, typLen);
+ if (entry->overflowValueLen > CLUSTERED_WRITE_OVERFLOW_VALUE_BYTES)
+ return false;
+
+ memcpy(entry->overflowValueStorage, DatumGetPointer(value),
+ entry->overflowValueLen);
+ entry->overflowValue = PointerGetDatum(entry->overflowValueStorage);
+ return true;
+}
+
+static bool
+ClusteredWriteGetCachedOverflowTarget(Relation relation, HeapTuple tuple,
+ BlockNumber *targetBlock)
+{
+ HeapClusteredWriteCache *cache;
+ Datum prefixValue;
+ bool prefixIsNull;
+
+ cache = (HeapClusteredWriteCache *) relation->rd_amcache;
+ if (cache == NULL ||
+ cache->magic != CLUSTERED_WRITE_CACHE_MAGIC ||
+ tuple == NULL)
+ return false;
+
+ for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++)
+ {
+ HeapClusteredWriteOverflowEntry *entry = &cache->overflowEntries[i];
+ bool equal;
+
+ if (!entry->active ||
+ !BlockNumberIsValid(entry->overflowTargetBlock))
+ continue;
+
+ prefixValue = heap_getattr(tuple, entry->overflowAttnum,
+ relation->rd_att, &prefixIsNull);
+ if (prefixIsNull)
+ continue;
+
+ equal = DatumGetBool(OidFunctionCall2Coll(entry->overflowEqProc,
+ entry->overflowCollation,
+ entry->overflowValue,
+ prefixValue));
+ if (!equal)
+ continue;
+
+ *targetBlock = entry->overflowTargetBlock;
+ return true;
+ }
+
+ return false;
+}
+
+static void
+ClusteredWriteRememberOverflowTargetInternal(Relation relation, HeapTuple tuple,
+ BlockNumber targetBlock,
+ bool countReserveUse)
+{
+ HeapClusteredWriteCache *cache;
+ Relation indexRelation;
+ Oid indexOid;
+ Oid eqOperator;
+ RegProcedure eqProc;
+ Oid typeOid;
+ AttrNumber attnum;
+ Datum prefixValue;
+ bool prefixIsNull;
+ bool typByVal;
+ int16 typLen;
+
+ if (tuple == NULL || !BlockNumberIsValid(targetBlock))
+ return;
+
+ indexOid = RelationGetClusteredIndex(relation);
+ if (!OidIsValid(indexOid))
+ return;
+
+ indexRelation = try_index_open(indexOid, AccessShareLock);
+ if (indexRelation == NULL)
+ return;
+
+ if (!RelationCanUseClusteredTargetProbe(relation, indexRelation))
+ {
+ index_close(indexRelation, AccessShareLock);
+ return;
+ }
+
+ attnum = indexRelation->rd_index->indkey.values[0];
+ typeOid = indexRelation->rd_opcintype[0];
+ get_typlenbyval(typeOid, &typLen, &typByVal);
+ if (attnum <= 0)
+ {
+ index_close(indexRelation, AccessShareLock);
+ return;
+ }
+
+ eqOperator = get_opfamily_member(indexRelation->rd_opfamily[0],
+ typeOid,
+ typeOid,
+ BTEqualStrategyNumber);
+ if (!OidIsValid(eqOperator))
+ {
+ index_close(indexRelation, AccessShareLock);
+ return;
+ }
+ eqProc = get_opcode(eqOperator);
+ if (!RegProcedureIsValid(eqProc))
+ {
+ index_close(indexRelation, AccessShareLock);
+ return;
+ }
+
+ prefixValue = heap_getattr(tuple, attnum, relation->rd_att, &prefixIsNull);
+ if (prefixIsNull)
+ {
+ index_close(indexRelation, AccessShareLock);
+ return;
+ }
+
+ cache = ClusteredWriteGetCache(relation);
+ if (cache != NULL)
+ {
+ HeapClusteredWriteOverflowEntry *entry = NULL;
+ bool matchedEntry = false;
+
+ for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++)
+ {
+ bool equal;
+
+ entry = &cache->overflowEntries[i];
+ if (!entry->active ||
+ entry->overflowAttnum != attnum ||
+ entry->overflowCollation != indexRelation->rd_indcollation[0] ||
+ entry->overflowEqProc != eqProc)
+ continue;
+
+ equal = DatumGetBool(OidFunctionCall2Coll(entry->overflowEqProc,
+ entry->overflowCollation,
+ entry->overflowValue,
+ prefixValue));
+ if (equal)
+ {
+ matchedEntry = true;
+ break;
+ }
+ entry = NULL;
+ }
+
+ if (entry == NULL)
+ {
+ for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++)
+ {
+ if (!cache->overflowEntries[i].active)
+ {
+ entry = &cache->overflowEntries[i];
+ break;
+ }
+ }
+ }
+
+ if (entry == NULL)
+ {
+ uint32 insertAt;
+
+ insertAt = cache->overflowNextEntry++ %
+ CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES;
+ entry = &cache->overflowEntries[insertAt];
+ }
+ if (!matchedEntry)
+ entry->overflowReserveUses = 0;
+
+ entry->overflowAttnum = attnum;
+ entry->overflowCollation = indexRelation->rd_indcollation[0];
+ entry->overflowEqProc = eqProc;
+ if (!ClusteredWriteStoreOverflowValue(entry, prefixValue,
+ typByVal, typLen))
+ {
+ entry->active = false;
+ index_close(indexRelation, AccessShareLock);
+ return;
+ }
+ if (countReserveUse)
+ {
+ if (entry->overflowReserveUses < UINT16_MAX)
+ entry->overflowReserveUses++;
+ if (entry->overflowReserveUses < CLUSTERED_WRITE_OVERFLOW_RESERVE_USES)
+ entry->overflowTargetBlock = InvalidBlockNumber;
+ else
+ entry->overflowTargetBlock = targetBlock;
+ }
+ else
+ {
+ entry->overflowReserveUses = CLUSTERED_WRITE_OVERFLOW_RESERVE_USES;
+ entry->overflowTargetBlock = targetBlock;
+ }
+ entry->active = true;
+ }
+
+ index_close(indexRelation, AccessShareLock);
+}
+
+static void
+ClusteredWriteRememberOverflowTarget(Relation relation, HeapTuple tuple,
+ BlockNumber targetBlock)
+{
+ ClusteredWriteRememberOverflowTargetInternal(relation, tuple, targetBlock,
+ false);
+}
+
+static void
+ClusteredWriteRememberReserveUse(Relation relation, HeapTuple tuple,
+ BlockNumber targetBlock)
+{
+ ClusteredWriteRememberOverflowTargetInternal(relation, tuple, targetBlock,
+ true);
+}
+
+static void
+ClusteredWriteUpdateCachedOverflowTarget(Relation relation, HeapTuple tuple,
+ BlockNumber targetBlock)
+{
+ HeapClusteredWriteCache *cache;
+ Datum prefixValue;
+ bool prefixIsNull;
+
+ cache = (HeapClusteredWriteCache *) relation->rd_amcache;
+ if (cache == NULL ||
+ cache->magic != CLUSTERED_WRITE_CACHE_MAGIC ||
+ tuple == NULL)
+ return;
+
+ for (int i = 0; i < CLUSTERED_WRITE_OVERFLOW_CACHE_ENTRIES; i++)
+ {
+ HeapClusteredWriteOverflowEntry *entry = &cache->overflowEntries[i];
+ bool equal;
+
+ if (!entry->active ||
+ !BlockNumberIsValid(entry->overflowTargetBlock))
+ continue;
+
+ prefixValue = heap_getattr(tuple, entry->overflowAttnum,
+ relation->rd_att, &prefixIsNull);
+ if (prefixIsNull)
+ continue;
+
+ equal = DatumGetBool(OidFunctionCall2Coll(entry->overflowEqProc,
+ entry->overflowCollation,
+ entry->overflowValue,
+ prefixValue));
+ if (equal)
+ {
+ entry->overflowTargetBlock = targetBlock;
+ return;
+ }
+ }
+}
+
+/*
+ * Open the relation's remembered clustered index for callers that only need a
+ * one-off candidate lookup.
+ */
+static int
+RelationGetClusteredTargetBlocksForTuple(Relation relation, HeapTuple tuple,
+ Size len,
+ BlockNumber *targetBlocks,
+ int maxTargetBlocks,
+ bool firstCandidateOnly,
+ bool *clusteredCandidatesExhausted)
+{
+ Oid indexOid;
+ Relation indexRelation;
+ int ntargets;
+
+ if (IsBootstrapProcessingMode() || tuple == NULL || len > MaxHeapTupleSize)
+ return 0;
+
+ indexOid = RelationGetClusteredIndex(relation);
+ if (!OidIsValid(indexOid))
+ return 0;
+
+ indexRelation = try_index_open(indexOid, AccessShareLock);
+ if (indexRelation == NULL)
+ return 0;
+
+ ntargets = RelationGetClusteredTargetBlocksFromIndex(relation,
+ indexRelation,
+ tuple, len,
+ targetBlocks,
+ maxTargetBlocks,
+ firstCandidateOnly,
+ clusteredCandidatesExhausted);
+
+ index_close(indexRelation, AccessShareLock);
+
+ return ntargets;
+}
+
/*
* Read in a buffer in mode, using bulk-insert strategy if bistate isn't NULL.
*/
@@ -488,6 +1357,12 @@ RelationAddBlocks(Relation relation, BulkInsertState bistate,
* BULKWRITE buffer selection strategy object to the buffer manager.
* Passing NULL for bistate selects the default behavior.
*
+ * preferredBlock can pass a clustered-index target that the caller already
+ * computed while preparing the batch. RelationGetBufferForTuple still owns
+ * the page-space validation and fallback path, so a stale or full target
+ * page is harmless. If the preferred page is full and the caller also
+ * passed tuple, we lazily probe the index for the remaining candidates.
+ *
* We don't fill existing pages further than the fillfactor, except for large
* tuples in nearly-empty pages. This is OK since this routine is not
* consulted when updating a tuple and keeping it on the same page, which is
@@ -498,6 +1373,8 @@ RelationAddBlocks(Relation relation, BulkInsertState bistate,
*/
Buffer
RelationGetBufferForTuple(Relation relation, Size len,
+ HeapTuple tuple,
+ BlockNumber preferredBlock,
Buffer otherBuffer, uint32 options,
BulkInsertState bistate,
Buffer *vmbuffer, Buffer *vmbuffer_other,
@@ -507,11 +1384,19 @@ RelationGetBufferForTuple(Relation relation, Size len,
Buffer buffer = InvalidBuffer;
Page page;
Size nearlyEmptyFreeSpace,
+ clusteredTargetFreeSpace = 0,
pageFreeSpace = 0,
saveFreeSpace = 0,
targetFreeSpace = 0;
+ BlockNumber clusteredTargetBlocks[CLUSTERED_WRITE_MAX_HEAP_BLOCKS];
BlockNumber targetBlock,
otherBlock;
+ int nclusteredTargetBlocks = 0,
+ clusteredTargetIndex = 0;
+ bool usingClusteredTarget = false;
+ bool usingClusteredOverflowTarget = false;
+ bool clusteredCandidatesExhausted = false;
+ bool usingPreferredBlock = false;
bool unlockedTargetBuffer;
bool recheckVmPins;
@@ -549,6 +1434,9 @@ RelationGetBufferForTuple(Relation relation, Size len,
targetFreeSpace = Max(len, nearlyEmptyFreeSpace);
else
targetFreeSpace = len + saveFreeSpace;
+ clusteredTargetFreeSpace =
+ (saveFreeSpace > 0 && targetFreeSpace == len + saveFreeSpace) ?
+ len : targetFreeSpace;
if (otherBuffer != InvalidBuffer)
otherBlock = BufferGetBlockNumber(otherBuffer);
@@ -568,7 +1456,72 @@ RelationGetBufferForTuple(Relation relation, Size len,
* When use_fsm is false, we either put the tuple onto the existing target
* page or extend the relation.
*/
- if (bistate && bistate->current_buf != InvalidBuffer)
+ targetBlock = InvalidBlockNumber;
+
+ /*
+ * Clustered target selection is an insertion policy. heap_update passes
+ * otherBuffer for the old tuple page and has its own placement constraints
+ * around HOT/fillfactor and buffer locking; keep moved update tuples on the
+ * regular FSM path.
+ */
+ if (use_fsm && otherBuffer == InvalidBuffer)
+ {
+ if (BlockNumberIsValid(preferredBlock))
+ {
+ clusteredTargetBlocks[0] = preferredBlock;
+ nclusteredTargetBlocks = 1;
+ targetBlock = preferredBlock;
+ usingPreferredBlock = true;
+ }
+ else if (tuple != NULL)
+ {
+ if (ClusteredWriteGetCachedOverflowTarget(relation, tuple,
+ &targetBlock))
+ usingClusteredOverflowTarget = true;
+ else
+ {
+ /*
+ * Start with the same cheap first-candidate probe that
+ * heap_multi_insert uses during batch preparation. If that
+ * page is full, the fallback below pays for the full bounded
+ * candidate window only when it is actually needed.
+ */
+ nclusteredTargetBlocks =
+ RelationGetClusteredTargetBlocksForTuple(relation, tuple,
+ clusteredTargetFreeSpace,
+ clusteredTargetBlocks,
+ lengthof(clusteredTargetBlocks),
+ true,
+ &clusteredCandidatesExhausted);
+ if (nclusteredTargetBlocks > 0)
+ {
+ targetBlock = clusteredTargetBlocks[clusteredTargetIndex];
+ usingPreferredBlock = true;
+ }
+ else if (clusteredCandidatesExhausted)
+ {
+ BlockNumber nblocks = RelationGetNumberOfBlocks(relation);
+
+ if (nblocks > 0)
+ {
+ targetBlock = nblocks - 1;
+ usingClusteredOverflowTarget = true;
+ ClusteredWriteRememberOverflowTarget(relation, tuple,
+ targetBlock);
+ }
+ }
+ }
+ }
+ usingClusteredTarget = (targetBlock != InvalidBlockNumber);
+ if (usingClusteredOverflowTarget)
+ usingClusteredTarget = false;
+ }
+
+ if (targetBlock != InvalidBlockNumber)
+ {
+ /* found a clustered-index neighbor */
+ }
+ else if (bistate && bistate->current_buf != InvalidBuffer)
targetBlock = BufferGetBlockNumber(bistate->current_buf);
else
targetBlock = RelationGetTargetBlock(relation);
@@ -698,10 +1651,29 @@ RelationGetBufferForTuple(Relation relation, Size len,
}
pageFreeSpace = PageGetHeapFreeSpace(page);
- if (targetFreeSpace <= pageFreeSpace)
+ if (targetFreeSpace <= pageFreeSpace ||
+ (usingClusteredTarget && len <= pageFreeSpace))
{
- /* use this page as future insert target, too */
- RelationSetTargetBlock(relation, targetBlock);
+ if (targetFreeSpace <= pageFreeSpace)
+ {
+ /* use this page as future insert target, too */
+ RelationSetTargetBlock(relation, targetBlock);
+ }
+ else
+ {
+ BlockNumber nblocks = RelationGetNumberOfBlocks(relation);
+
+ /*
+ * Let clustered writes use a small number of below-fillfactor
+ * neighbors before redirecting equal-prefix overflow to the
+ * relation tail. That keeps short grouped diffs local without
+ * spraying long duplicate-key runs through reserve space.
+ */
+ RelationSetTargetBlock(relation, InvalidBlockNumber);
+ if (tuple != NULL && nblocks > 0)
+ ClusteredWriteRememberReserveUse(relation, tuple,
+ nblocks - 1);
+ }
return buffer;
}
@@ -721,6 +1693,99 @@ RelationGetBufferForTuple(Relation relation, Size len,
else
LockBuffer(buffer, BUFFER_LOCK_UNLOCK);
+ if (usingClusteredTarget)
+ {
+ BlockNumber attemptedBlock = targetBlock;
+
+ if (use_fsm)
+ RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace);
+
+ clusteredTargetIndex++;
+ if (clusteredTargetIndex < nclusteredTargetBlocks)
+ {
+ targetBlock = clusteredTargetBlocks[clusteredTargetIndex];
+ continue;
+ }
+
+ if (usingPreferredBlock && tuple != NULL)
+ {
+ usingPreferredBlock = false;
+ nclusteredTargetBlocks =
+ RelationGetClusteredTargetBlocksForTuple(relation, tuple,
+ clusteredTargetFreeSpace,
+ clusteredTargetBlocks,
+ lengthof(clusteredTargetBlocks),
+ false,
+ &clusteredCandidatesExhausted);
+ for (clusteredTargetIndex = 0;
+ clusteredTargetIndex < nclusteredTargetBlocks;
+ clusteredTargetIndex++)
+ {
+ targetBlock = clusteredTargetBlocks[clusteredTargetIndex];
+ if (targetBlock != attemptedBlock)
+ break;
+ }
+ if (clusteredTargetIndex < nclusteredTargetBlocks)
+ continue;
+ if (clusteredCandidatesExhausted)
+ {
+ BlockNumber nblocks = RelationGetNumberOfBlocks(relation);
+
+ usingClusteredTarget = false;
+ if (nblocks > 0)
+ {
+ targetBlock = nblocks - 1;
+ usingClusteredOverflowTarget = true;
+ ClusteredWriteRememberOverflowTarget(relation, tuple,
+ targetBlock);
+ continue;
+ }
+ targetBlock = InvalidBlockNumber;
+ }
+ }
+
+ usingClusteredTarget = false;
+ if (bistate && bistate->next_free != InvalidBlockNumber)
+ {
+ Assert(bistate->next_free <= bistate->last_free);
+ targetBlock = bistate->next_free;
+ if (bistate->next_free >= bistate->last_free)
+ {
+ bistate->next_free = InvalidBlockNumber;
+ bistate->last_free = InvalidBlockNumber;
+ }
+ else
+ bistate->next_free++;
+ }
+ else
+ {
+ BlockNumber nblocks = RelationGetNumberOfBlocks(relation);
+
+ if (nblocks > 0)
+ {
+ /*
+ * Once every clustered neighbor is full, append the
+ * overflow run at the relation tail instead of scattering
+ * dense equal-key inserts through unrelated FSM pages.
+ */
+ targetBlock = nblocks - 1;
+ usingClusteredOverflowTarget = true;
+ }
+ else
+ targetBlock = InvalidBlockNumber;
+ }
+
+ continue;
+ }
+
+ if (usingClusteredOverflowTarget)
+ {
+ if (use_fsm)
+ RecordPageWithFreeSpace(relation, targetBlock,
+ pageFreeSpace);
+ break;
+ }
+
/* Is there an ongoing bulk extension? */
if (bistate && bistate->next_free != InvalidBlockNumber)
{
@@ -736,6 +1801,7 @@ RelationGetBufferForTuple(Relation relation, Size len,
RecordPageWithFreeSpace(relation, targetBlock, pageFreeSpace);
targetBlock = bistate->next_free;
+ usingClusteredTarget = false;
if (bistate->next_free >= bistate->last_free)
{
bistate->next_free = InvalidBlockNumber;
@@ -755,10 +1821,14 @@ RelationGetBufferForTuple(Relation relation, Size len,
* Update FSM as to condition of this page, and ask for another
* page to try.
*/
- targetBlock = RecordAndGetPageWithFreeSpace(relation,
- targetBlock,
- pageFreeSpace,
- targetFreeSpace);
+ if (targetBlock == InvalidBlockNumber)
+ targetBlock = GetPageWithFreeSpace(relation, targetFreeSpace);
+ else
+ targetBlock = RecordAndGetPageWithFreeSpace(relation,
+ targetBlock,
+ pageFreeSpace,
+ targetFreeSpace);
+ usingClusteredTarget = false;
}
}
@@ -879,6 +1949,8 @@ RelationGetBufferForTuple(Relation relation, Size len,
* good bet most of the time. So for now, don't add it to FSM yet.
*/
RelationSetTargetBlock(relation, targetBlock);
+ if (usingClusteredOverflowTarget)
+ ClusteredWriteUpdateCachedOverflowTarget(relation, tuple, targetBlock);
return buffer;
}
diff --git a/src/backend/commands/repack.c b/src/backend/commands/repack.c
index 9d162957bc35a..d3ef651a63b11 100644
--- a/src/backend/commands/repack.c
+++ b/src/backend/commands/repack.c
@@ -835,6 +835,7 @@ mark_index_clustered(Relation rel, Oid indexOid, bool is_internal)
foreach(index, RelationGetIndexList(rel))
{
Oid thisIndexOid = lfirst_oid(index);
+ bool dirty = false;
indexTuple = SearchSysCacheCopy1(INDEXRELID,
ObjectIdGetDatum(thisIndexOid));
@@ -849,7 +850,7 @@ mark_index_clustered(Relation rel, Oid indexOid, bool is_internal)
if (indexForm->indisclustered)
{
indexForm->indisclustered = false;
- CatalogTupleUpdate(pg_index, &indexTuple->t_self, indexTuple);
+ dirty = true;
}
else if (thisIndexOid == indexOid)
{
@@ -857,7 +858,19 @@ mark_index_clustered(Relation rel, Oid indexOid, bool is_internal)
if (!indexForm->indisvalid)
elog(ERROR, "cannot cluster on invalid index %u", indexOid);
indexForm->indisclustered = true;
+ dirty = true;
+ }
+
+ if (dirty)
+ {
CatalogTupleUpdate(pg_index, &indexTuple->t_self, indexTuple);
+
+ /*
+ * The table relcache remembers the clustered index OID, so all
+ * sessions must refresh it before later heap insertions consult
+ * clustered-write placement.
+ */
+ CacheInvalidateRelcache(rel);
}
InvokeObjectPostAlterHookArg(IndexRelationId, thisIndexOid, 0,
diff --git a/src/backend/utils/cache/relcache.c b/src/backend/utils/cache/relcache.c
index e19f0d3e51cf3..50057353f4b50 100644
--- a/src/backend/utils/cache/relcache.c
+++ b/src/backend/utils/cache/relcache.c
@@ -4819,9 +4819,11 @@ RelationGetFKeyList(Relation relation)
* indexes, and syscache lookup could cause SI messages to be processed!
*
* In exactly the same way, we update rd_pkindex, which is the OID of the
- * relation's primary key index if any, else InvalidOid; and rd_replidindex,
+ * relation's primary key index if any, else InvalidOid; rd_replidindex,
* which is the pg_class OID of an index to be used as the relation's
- * replication identity index, or InvalidOid if there is no such index.
+ * replication identity index, or InvalidOid if there is no such index; and
+ * rd_clusteredindex, which is the OID of the relation's clustered index if
+ * any, else InvalidOid.
*/
List *
RelationGetIndexList(Relation relation)
@@ -4835,6 +4837,7 @@ RelationGetIndexList(Relation relation)
char replident = relation->rd_rel->relreplident;
Oid pkeyIndex = InvalidOid;
Oid candidateIndex = InvalidOid;
+ Oid clusteredIndex = InvalidOid;
bool pkdeferrable = false;
MemoryContext oldcxt;
@@ -4876,6 +4879,9 @@ RelationGetIndexList(Relation relation)
/* add index's OID to result list */
result = lappend_oid(result, index->indexrelid);
+ if (index->indisclustered)
+ clusteredIndex = index->indexrelid;
+
/*
* Non-unique or predicate indexes aren't interesting for either oid
* indexes or replication identity indexes, so don't check them.
@@ -4928,6 +4934,7 @@ RelationGetIndexList(Relation relation)
relation->rd_indexlist = list_copy(result);
relation->rd_pkindex = pkeyIndex;
relation->rd_ispkdeferrable = pkdeferrable;
+ relation->rd_clusteredindex = clusteredIndex;
if (replident == REPLICA_IDENTITY_DEFAULT && OidIsValid(pkeyIndex) && !pkdeferrable)
relation->rd_replidindex = pkeyIndex;
else if (replident == REPLICA_IDENTITY_INDEX && OidIsValid(candidateIndex))
@@ -5075,6 +5082,27 @@ RelationGetReplicaIndex(Relation relation)
return relation->rd_replidindex;
}
+/*
+ * RelationGetClusteredIndex -- get OID of the relation's clustered index
+ *
+ * Returns InvalidOid if there is no such index.
+ */
+Oid
+RelationGetClusteredIndex(Relation relation)
+{
+ List *ilist;
+
+ if (!relation->rd_indexvalid)
+ {
+ /* RelationGetIndexList does the heavy lifting. */
+ ilist = RelationGetIndexList(relation);
+ list_free(ilist);
+ Assert(relation->rd_indexvalid);
+ }
+
+ return relation->rd_clusteredindex;
+}
+
/*
* RelationGetIndexExpressions -- get the index expressions for an index
*
@@ -5302,6 +5330,7 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
List *newindexoidlist;
Oid relpkindex;
Oid relreplindex;
+ Oid relclusteredindex;
ListCell *l;
MemoryContext oldcxt;
@@ -5340,14 +5369,15 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
return NULL;
/*
- * Copy the rd_pkindex and rd_replidindex values computed by
- * RelationGetIndexList before proceeding. This is needed because a
- * relcache flush could occur inside index_open below, resetting the
+ * Copy the rd_pkindex, rd_replidindex, and rd_clusteredindex values
+ * computed by RelationGetIndexList before proceeding. This is needed
+ * because a relcache flush could occur inside index_open below, resetting the
* fields managed by RelationGetIndexList. We need to do the work with
* stable values of these fields.
*/
relpkindex = relation->rd_pkindex;
relreplindex = relation->rd_replidindex;
+ relclusteredindex = relation->rd_clusteredindex;
/*
* For each index, add referenced attributes to indexattrs.
@@ -5480,7 +5510,8 @@ RelationGetIndexAttrBitmap(Relation relation, IndexAttrBitmapKind attrKind)
newindexoidlist = RelationGetIndexList(relation);
if (equal(indexoidlist, newindexoidlist) &&
relpkindex == relation->rd_pkindex &&
- relreplindex == relation->rd_replidindex)
+ relreplindex == relation->rd_replidindex &&
+ relclusteredindex == relation->rd_clusteredindex)
{
/* Still the same index set, so proceed */
list_free(newindexoidlist);
@@ -6498,6 +6529,7 @@ load_relcache_init_file(bool shared)
rel->rd_indexlist = NIL;
rel->rd_pkindex = InvalidOid;
rel->rd_replidindex = InvalidOid;
+ rel->rd_clusteredindex = InvalidOid;
rel->rd_attrsvalid = false;
rel->rd_keyattr = NULL;
rel->rd_pkattr = NULL;
diff --git a/src/include/access/hio.h b/src/include/access/hio.h
index 60cfc375fd523..16b658d730173 100644
--- a/src/include/access/hio.h
+++ b/src/include/access/hio.h
@@ -53,7 +53,19 @@ typedef struct BulkInsertStateData
extern void RelationPutHeapTuple(Relation relation, Buffer buffer,
HeapTuple tuple, bool token);
+extern bool RelationCanUseClusteredTargetProbe(Relation relation,
+ Relation indexRelation);
+extern int RelationGetClusteredTargetBlocksFromIndex(Relation relation,
+ Relation indexRelation,
+ HeapTuple tuple,
+ Size len,
+ BlockNumber *targetBlocks,
+ int maxTargetBlocks,
+ bool firstCandidateOnly,
+ bool *clusteredCandidatesExhausted);
extern Buffer RelationGetBufferForTuple(Relation relation, Size len,
+ HeapTuple tuple,
+ BlockNumber preferredBlock,
Buffer otherBuffer, uint32 options,
BulkInsertStateData *bistate,
Buffer *vmbuffer, Buffer *vmbuffer_other,
diff --git a/src/include/utils/rel.h b/src/include/utils/rel.h
index cd1e92f230258..3dbc19e127abe 100644
--- a/src/include/utils/rel.h
+++ b/src/include/utils/rel.h
@@ -61,8 +61,8 @@ typedef struct RelationData
bool rd_islocaltemp; /* rel is a temp rel of this session */
bool rd_isnailed; /* rel is nailed in cache */
bool rd_isvalid; /* relcache entry is valid */
- bool rd_indexvalid; /* is rd_indexlist valid? (also rd_pkindex and
- * rd_replidindex) */
+ bool rd_indexvalid; /* is rd_indexlist valid? (also rd_pkindex,
+ * rd_replidindex, and rd_clusteredindex) */
bool rd_statvalid; /* is rd_statlist valid? */
/*----------
@@ -153,6 +153,7 @@ typedef struct RelationData
Oid rd_pkindex; /* OID of (deferrable?) primary key, if any */
bool rd_ispkdeferrable; /* is rd_pkindex a deferrable PK? */
Oid rd_replidindex; /* OID of replica identity index, if any */
+ Oid rd_clusteredindex; /* OID of clustered index, if any */
/* data managed by RelationGetStatExtList: */
List *rd_statlist; /* list of OIDs of extended stats */
diff --git a/src/include/utils/relcache.h b/src/include/utils/relcache.h
index 2700224939a72..735ba21c3c5a0 100644
--- a/src/include/utils/relcache.h
+++ b/src/include/utils/relcache.h
@@ -56,6 +56,7 @@ extern List *RelationGetIndexList(Relation relation);
extern List *RelationGetStatExtList(Relation relation);
extern Oid RelationGetPrimaryKeyIndex(Relation relation, bool deferrable_ok);
extern Oid RelationGetReplicaIndex(Relation relation);
+extern Oid RelationGetClusteredIndex(Relation relation);
extern List *RelationGetIndexExpressions(Relation relation);
extern List *RelationGetDummyIndexExpressions(Relation relation);
extern List *RelationGetIndexPredicate(Relation relation);
diff --git a/src/test/regress/expected/cluster.out b/src/test/regress/expected/cluster.out
index 0317f2f801e08..fbe054540ac6e 100644
--- a/src/test/regress/expected/cluster.out
+++ b/src/test/regress/expected/cluster.out
@@ -306,10 +306,199 @@ WHERE pg_class.oid=indexrelid
---------
(0 rows)
+-- Verify that clustered writes may use a small number of fillfactor-reserve
+-- neighbors for a clustered key, but longer equal-prefix overflow is
+-- redirected to the tail.
+CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10);
+INSERT INTO clstr_write_btree
+SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id);
+CLUSTER clstr_write_btree USING clstr_write_btree_k_id;
+INSERT INTO clstr_write_btree
+SELECT 1000 + g, 1, repeat('y', 10)
+FROM generate_series(1, 9) AS g;
+SELECT id, tid_block(new_row.ctid) <=
+ (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <= 400)
+ AS used_clustered_key_reserve
+FROM clstr_write_btree AS new_row
+WHERE id BETWEEN 1001 AND 1009
+ORDER BY id;
+ id | used_clustered_key_reserve
+------+----------------------------
+ 1001 | t
+ 1002 | t
+ 1003 | t
+ 1004 | t
+ 1005 | t
+ 1006 | t
+ 1007 | t
+ 1008 | t
+ 1009 | f
+(9 rows)
+
+DROP TABLE clstr_write_btree;
+-- Verify that by-reference clustered keys use the same bounded overflow path.
+CREATE TABLE clstr_write_text_overflow (id int, k text COLLATE "C", filler text) WITH (fillfactor = 10);
+INSERT INTO clstr_write_text_overflow
+SELECT g, CASE WHEN g <= 200 THEN 'a' ELSE 'b' END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_text_overflow_k_id ON clstr_write_text_overflow (k, id);
+CLUSTER clstr_write_text_overflow USING clstr_write_text_overflow_k_id;
+INSERT INTO clstr_write_text_overflow
+SELECT 1000 + g, 'a', repeat('y', 10)
+FROM generate_series(1, 9) AS g;
+SELECT id, tid_block(new_row.ctid) <=
+ (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id <= 400)
+ AS used_clustered_key_reserve
+FROM clstr_write_text_overflow AS new_row
+WHERE id BETWEEN 1001 AND 1009
+ORDER BY id;
+ id | used_clustered_key_reserve
+------+----------------------------
+ 1001 | t
+ 1002 | t
+ 1003 | t
+ 1004 | t
+ 1005 | t
+ 1006 | t
+ 1007 | t
+ 1008 | t
+ 1009 | f
+(9 rows)
+
+DROP TABLE clstr_write_text_overflow;
+-- Verify that reordered clustered COPY batches keep each slot's TID.
+CREATE TABLE clstr_write_copy_tid (id int, k int, filler text) WITH (fillfactor = 10);
+INSERT INTO clstr_write_copy_tid
+SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_copy_tid_k_id ON clstr_write_copy_tid (k, id);
+CREATE INDEX clstr_write_copy_tid_id ON clstr_write_copy_tid (id);
+CLUSTER clstr_write_copy_tid USING clstr_write_copy_tid_k_id;
+COPY clstr_write_copy_tid (id, k, filler) FROM stdin;
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id FROM (
+ SELECT id FROM clstr_write_copy_tid WHERE id = 1001
+ UNION ALL
+ SELECT id FROM clstr_write_copy_tid WHERE id = 1002
+ UNION ALL
+ SELECT id FROM clstr_write_copy_tid WHERE id = 2001
+ UNION ALL
+ SELECT id FROM clstr_write_copy_tid WHERE id = 2002
+) AS indexed_lookup
+ORDER BY id;
+ id
+------
+ 1001
+ 1002
+ 2001
+ 2002
+(4 rows)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE clstr_write_copy_tid;
+-- Verify clustered COPY packing stops at clustered target boundaries.
+CREATE TABLE clstr_write_copy_boundaries (id int, k int, filler text) WITH (fillfactor = 90);
+INSERT INTO clstr_write_copy_boundaries
+SELECT g, ((g - 1) % 32) + 1, repeat('x', 64)
+FROM generate_series(1, 1600) AS g;
+CREATE INDEX clstr_write_copy_boundaries_k_id ON clstr_write_copy_boundaries (k, id);
+CLUSTER clstr_write_copy_boundaries USING clstr_write_copy_boundaries_k_id;
+CREATE TEMP TABLE clstr_write_copy_boundaries_base AS
+SELECT k, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block
+FROM clstr_write_copy_boundaries
+GROUP BY k;
+COPY clstr_write_copy_boundaries (id, k, filler) FROM stdin;
+SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside,
+ count(*) AS total,
+ bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside
+FROM clstr_write_copy_boundaries AS c
+JOIN clstr_write_copy_boundaries_base AS b USING (k)
+WHERE c.id >= 100001;
+ inside | total | all_inside
+--------+-------+------------
+ 16 | 16 | t
+(1 row)
+
+DROP TABLE clstr_write_copy_boundaries;
+DROP TABLE clstr_write_copy_boundaries_base;
+-- Verify mixed generated text clustered COPY batches use the prefix cache safely.
+CREATE TABLE clstr_write_text_copy_boundaries (
+ id int PRIMARY KEY,
+ tile_id int,
+ cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED,
+ filler text
+) WITH (fillfactor = 90);
+INSERT INTO clstr_write_text_copy_boundaries (id, tile_id, filler)
+SELECT g, ((g - 1) % 32) + 1, repeat('x', 64)
+FROM generate_series(1, 1600) AS g;
+CREATE INDEX clstr_write_text_copy_boundaries_cluster ON clstr_write_text_copy_boundaries (cluster_key);
+CLUSTER clstr_write_text_copy_boundaries USING clstr_write_text_copy_boundaries_cluster;
+CREATE TEMP TABLE clstr_write_text_copy_boundaries_base AS
+SELECT cluster_key, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block
+FROM clstr_write_text_copy_boundaries
+GROUP BY cluster_key;
+COPY clstr_write_text_copy_boundaries (id, tile_id, filler) FROM stdin;
+SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside,
+ count(*) AS total,
+ bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside
+FROM clstr_write_text_copy_boundaries AS c
+JOIN clstr_write_text_copy_boundaries_base AS b USING (cluster_key)
+WHERE c.id >= 100001;
+ inside | total | all_inside
+--------+-------+------------
+ 16 | 16 | t
+(1 row)
+
+DROP TABLE clstr_write_text_copy_boundaries;
+DROP TABLE clstr_write_text_copy_boundaries_base;
+-- Verify all-equal clustered COPY batches also work with generated text keys.
+CREATE TABLE clstr_write_text_key (
+ id int PRIMARY KEY,
+ tile_id int,
+ cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED,
+ filler text
+) WITH (fillfactor = 10);
+INSERT INTO clstr_write_text_key (id, tile_id, filler)
+SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_text_key_cluster ON clstr_write_text_key (cluster_key);
+CLUSTER clstr_write_text_key USING clstr_write_text_key_cluster;
+COPY clstr_write_text_key (id, tile_id, filler) FROM stdin;
+SELECT count(*) AS copied,
+ count(*) FILTER (WHERE cluster_key = 'g00000001') AS generated_keys
+FROM clstr_write_text_key
+WHERE id >= 1001;
+ copied | generated_keys
+--------+----------------
+ 20 | 20
+(1 row)
+
+DROP TABLE clstr_write_text_key;
+-- Verify that clustered writes do not break other clusterable AMs.
+CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = 50);
+INSERT INTO clstr_write_gist
+SELECT g, point(g, g), repeat('x', 1000)
+FROM generate_series(1, 20) AS g;
+CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p) INCLUDE (id);
+CLUSTER clstr_write_gist USING clstr_write_gist_p;
+INSERT INTO clstr_write_gist VALUES (1001, point(1.5, 1.5), repeat('y', 1000));
+SELECT count(*) AS rows, count(*) FILTER (WHERE p <@ box(point(1, 1), point(2, 2))) AS nearby
+FROM clstr_write_gist;
+ rows | nearby
+------+--------
+ 21 | 3
+(1 row)
+
+DROP TABLE clstr_write_gist;
-- Verify that toast tables are clusterable
CLUSTER pg_toast.pg_toast_826 USING pg_toast_826_index;
-- Verify that clustering all tables does in fact cluster the right ones
CREATE USER regress_clstr_user;
+GRANT CREATE ON SCHEMA public TO regress_clstr_user;
CREATE TABLE clstr_1 (a INT PRIMARY KEY);
CREATE TABLE clstr_2 (a INT PRIMARY KEY);
CREATE TABLE clstr_3 (a INT PRIMARY KEY);
@@ -857,4 +1046,5 @@ DROP TABLE clstr_3;
DROP TABLE clstr_4;
DROP TABLE clstr_expression;
DROP TABLE clstrpart;
+REVOKE CREATE ON SCHEMA public FROM regress_clstr_user;
DROP USER regress_clstr_user;
diff --git a/src/test/regress/sql/cluster.sql b/src/test/regress/sql/cluster.sql
index c2f329ecd1bbc..d5779f8fa2857 100644
--- a/src/test/regress/sql/cluster.sql
+++ b/src/test/regress/sql/cluster.sql
@@ -103,11 +103,213 @@ WHERE pg_class.oid=indexrelid
AND pg_class_2.relname = 'clstr_tst'
AND indisclustered;
+-- Verify that clustered writes may use a small number of fillfactor-reserve
+-- neighbors for a clustered key, but longer equal-prefix overflow is
+-- redirected to the tail.
+CREATE TABLE clstr_write_btree (id int, k int, filler text) WITH (fillfactor = 10);
+INSERT INTO clstr_write_btree
+SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_btree_k_id ON clstr_write_btree (k, id);
+CLUSTER clstr_write_btree USING clstr_write_btree_k_id;
+INSERT INTO clstr_write_btree
+SELECT 1000 + g, 1, repeat('y', 10)
+FROM generate_series(1, 9) AS g;
+SELECT id, tid_block(new_row.ctid) <=
+ (SELECT max(tid_block(ctid)) FROM clstr_write_btree WHERE k = 1 AND id <= 400)
+ AS used_clustered_key_reserve
+FROM clstr_write_btree AS new_row
+WHERE id BETWEEN 1001 AND 1009
+ORDER BY id;
+DROP TABLE clstr_write_btree;
+
+-- Verify that by-reference clustered keys use the same bounded overflow path.
+CREATE TABLE clstr_write_text_overflow (id int, k text COLLATE "C", filler text) WITH (fillfactor = 10);
+INSERT INTO clstr_write_text_overflow
+SELECT g, CASE WHEN g <= 200 THEN 'a' ELSE 'b' END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_text_overflow_k_id ON clstr_write_text_overflow (k, id);
+CLUSTER clstr_write_text_overflow USING clstr_write_text_overflow_k_id;
+INSERT INTO clstr_write_text_overflow
+SELECT 1000 + g, 'a', repeat('y', 10)
+FROM generate_series(1, 9) AS g;
+SELECT id, tid_block(new_row.ctid) <=
+ (SELECT max(tid_block(ctid)) FROM clstr_write_text_overflow WHERE k = 'a' AND id <= 400)
+ AS used_clustered_key_reserve
+FROM clstr_write_text_overflow AS new_row
+WHERE id BETWEEN 1001 AND 1009
+ORDER BY id;
+DROP TABLE clstr_write_text_overflow;
+
+-- Verify that reordered clustered COPY batches keep each slot's TID.
+CREATE TABLE clstr_write_copy_tid (id int, k int, filler text) WITH (fillfactor = 10);
+INSERT INTO clstr_write_copy_tid
+SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_copy_tid_k_id ON clstr_write_copy_tid (k, id);
+CREATE INDEX clstr_write_copy_tid_id ON clstr_write_copy_tid (id);
+CLUSTER clstr_write_copy_tid USING clstr_write_copy_tid_k_id;
+COPY clstr_write_copy_tid (id, k, filler) FROM stdin;
+2001 2 y
+1001 1 y
+2002 2 y
+1002 1 y
+\.
+SET enable_seqscan = off;
+SET enable_bitmapscan = off;
+SELECT id FROM (
+ SELECT id FROM clstr_write_copy_tid WHERE id = 1001
+ UNION ALL
+ SELECT id FROM clstr_write_copy_tid WHERE id = 1002
+ UNION ALL
+ SELECT id FROM clstr_write_copy_tid WHERE id = 2001
+ UNION ALL
+ SELECT id FROM clstr_write_copy_tid WHERE id = 2002
+) AS indexed_lookup
+ORDER BY id;
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE clstr_write_copy_tid;
+
+-- Verify clustered COPY packing stops at clustered target boundaries.
+CREATE TABLE clstr_write_copy_boundaries (id int, k int, filler text) WITH (fillfactor = 90);
+INSERT INTO clstr_write_copy_boundaries
+SELECT g, ((g - 1) % 32) + 1, repeat('x', 64)
+FROM generate_series(1, 1600) AS g;
+CREATE INDEX clstr_write_copy_boundaries_k_id ON clstr_write_copy_boundaries (k, id);
+CLUSTER clstr_write_copy_boundaries USING clstr_write_copy_boundaries_k_id;
+CREATE TEMP TABLE clstr_write_copy_boundaries_base AS
+SELECT k, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block
+FROM clstr_write_copy_boundaries
+GROUP BY k;
+COPY clstr_write_copy_boundaries (id, k, filler) FROM stdin;
+100001 1 y
+100002 2 y
+100003 3 y
+100004 4 y
+100005 5 y
+100006 6 y
+100007 7 y
+100008 8 y
+100009 9 y
+100010 10 y
+100011 11 y
+100012 12 y
+100013 13 y
+100014 14 y
+100015 15 y
+100016 16 y
+\.
+SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside,
+ count(*) AS total,
+ bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside
+FROM clstr_write_copy_boundaries AS c
+JOIN clstr_write_copy_boundaries_base AS b USING (k)
+WHERE c.id >= 100001;
+DROP TABLE clstr_write_copy_boundaries;
+DROP TABLE clstr_write_copy_boundaries_base;
+
+-- Verify mixed generated text clustered COPY batches use the prefix cache safely.
+CREATE TABLE clstr_write_text_copy_boundaries (
+ id int PRIMARY KEY,
+ tile_id int,
+ cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED,
+ filler text
+) WITH (fillfactor = 90);
+INSERT INTO clstr_write_text_copy_boundaries (id, tile_id, filler)
+SELECT g, ((g - 1) % 32) + 1, repeat('x', 64)
+FROM generate_series(1, 1600) AS g;
+CREATE INDEX clstr_write_text_copy_boundaries_cluster ON clstr_write_text_copy_boundaries (cluster_key);
+CLUSTER clstr_write_text_copy_boundaries USING clstr_write_text_copy_boundaries_cluster;
+CREATE TEMP TABLE clstr_write_text_copy_boundaries_base AS
+SELECT cluster_key, min(tid_block(ctid)) AS min_block, max(tid_block(ctid)) AS max_block
+FROM clstr_write_text_copy_boundaries
+GROUP BY cluster_key;
+COPY clstr_write_text_copy_boundaries (id, tile_id, filler) FROM stdin;
+100001 1 y
+100002 2 y
+100003 3 y
+100004 4 y
+100005 5 y
+100006 6 y
+100007 7 y
+100008 8 y
+100009 9 y
+100010 10 y
+100011 11 y
+100012 12 y
+100013 13 y
+100014 14 y
+100015 15 y
+100016 16 y
+\.
+SELECT count(*) FILTER (WHERE tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS inside,
+ count(*) AS total,
+ bool_and(tid_block(c.ctid) BETWEEN b.min_block AND b.max_block) AS all_inside
+FROM clstr_write_text_copy_boundaries AS c
+JOIN clstr_write_text_copy_boundaries_base AS b USING (cluster_key)
+WHERE c.id >= 100001;
+DROP TABLE clstr_write_text_copy_boundaries;
+DROP TABLE clstr_write_text_copy_boundaries_base;
+
+-- Verify all-equal clustered COPY batches also work with generated text keys.
+CREATE TABLE clstr_write_text_key (
+ id int PRIMARY KEY,
+ tile_id int,
+ cluster_key text COLLATE "C" GENERATED ALWAYS AS ('g' || lpad(tile_id::text, 8, '0')) STORED,
+ filler text
+) WITH (fillfactor = 10);
+INSERT INTO clstr_write_text_key (id, tile_id, filler)
+SELECT g, CASE WHEN g <= 200 THEN 1 ELSE 2 END, repeat('x', 10)
+FROM generate_series(1, 400) AS g;
+CREATE INDEX clstr_write_text_key_cluster ON clstr_write_text_key (cluster_key);
+CLUSTER clstr_write_text_key USING clstr_write_text_key_cluster;
+COPY clstr_write_text_key (id, tile_id, filler) FROM stdin;
+1001 1 y
+1002 1 y
+1003 1 y
+1004 1 y
+1005 1 y
+1006 1 y
+1007 1 y
+1008 1 y
+1009 1 y
+1010 1 y
+1011 1 y
+1012 1 y
+1013 1 y
+1014 1 y
+1015 1 y
+1016 1 y
+1017 1 y
+1018 1 y
+1019 1 y
+1020 1 y
+\.
+SELECT count(*) AS copied,
+ count(*) FILTER (WHERE cluster_key = 'g00000001') AS generated_keys
+FROM clstr_write_text_key
+WHERE id >= 1001;
+DROP TABLE clstr_write_text_key;
+
+-- Verify that clustered writes do not break other clusterable AMs.
+CREATE TABLE clstr_write_gist (id int, p point, filler text) WITH (fillfactor = 50);
+INSERT INTO clstr_write_gist
+SELECT g, point(g, g), repeat('x', 1000)
+FROM generate_series(1, 20) AS g;
+CREATE INDEX clstr_write_gist_p ON clstr_write_gist USING gist (p) INCLUDE (id);
+CLUSTER clstr_write_gist USING clstr_write_gist_p;
+INSERT INTO clstr_write_gist VALUES (1001, point(1.5, 1.5), repeat('y', 1000));
+SELECT count(*) AS rows, count(*) FILTER (WHERE p <@ box(point(1, 1), point(2, 2))) AS nearby
+FROM clstr_write_gist;
+DROP TABLE clstr_write_gist;
+
-- Verify that toast tables are clusterable
CLUSTER pg_toast.pg_toast_826 USING pg_toast_826_index;
-- Verify that clustering all tables does in fact cluster the right ones
CREATE USER regress_clstr_user;
+GRANT CREATE ON SCHEMA public TO regress_clstr_user;
CREATE TABLE clstr_1 (a INT PRIMARY KEY);
CREATE TABLE clstr_2 (a INT PRIMARY KEY);
CREATE TABLE clstr_3 (a INT PRIMARY KEY);
@@ -437,4 +639,5 @@ DROP TABLE clstr_4;
DROP TABLE clstr_expression;
DROP TABLE clstrpart;
+REVOKE CREATE ON SCHEMA public FROM regress_clstr_user;
DROP USER regress_clstr_user;
diff --git a/src/tools/clustered_write_bench/README b/src/tools/clustered_write_bench/README
new file mode 100644
index 0000000000000..ad9d8af4b6e0f
--- /dev/null
+++ b/src/tools/clustered_write_bench/README
@@ -0,0 +1,1149 @@
+clustered_write_bench
+=====================
+
+These scripts measure how well a remembered clustered index survives an
+osm2pgsql-like diff workload.
+
+Run the script against a build with clustered writes:
+
+ psql -v scale=1 -v use_brin=false -f src/tools/clustered_write_bench/osm2pgsql_diff.sql postgres
+
+For repeated runs, use the wrapper:
+
+ REPEATS=3 SCALE_VALUES="0.1 1" BRIN_VALUES="false true" \
+ src/tools/clustered_write_bench/run_synthetic_bench.sh
+
+To avoid accidentally benchmarking the system PostgreSQL instead of the build
+under test, the wrapper can create and stop a temporary instance itself:
+
+ USE_TEMP_INSTANCE=true PG_BINDIR=/path/to/patched/postgres/bin \
+ REPEATS=3 SCALE_VALUES=1 BRIN_VALUES=false \
+ src/tools/clustered_write_bench/run_synthetic_bench.sh
+
+Set `SINGLE_KEY_VALUES="false true"` to run both clustered index shapes:
+`false` uses the composite `(tile_id, osm_id)` key that stresses left-prefix
+placement for new object ids, while `true` uses a single-column `(tile_id)` key
+closer to the experimental osm2pgsql generated-geohash index.
+
+Set `TEXT_KEY_VALUES="false true"` to repeat the run with a stored generated
+`text` clustering key. This keeps the integer `tile_id` for locality
+measurement, but clusters on `cluster_key`, a deterministic text encoding of
+the same tile using `COLLATE "C"`, matching the experimental osm2pgsql
+generated-geohash key. It is a cheap guard for geohash-like osm2pgsql
+experiments and varlena btree-prefix changes before spending time on the full
+Georgia import. When `use_brin=true`, the optional BRIN index follows the
+active clustering key (`tile_id` for integer mode, `cluster_key` for text
+mode).
+
+Set `HEAP_FILLFACTOR_VALUES="90 70 50"` to compare how much reserved heap
+space a diff workload needs before clustered-write placement can keep repeated
+keys local. The default is `90`, matching the current experimental
+osm2pgsql clustered-import patch. Lower values increase initial heap size, but
+help distinguish a page-selection bug from the simpler case where there is no
+nearby page with enough free space.
+
+Set `HOT_TILE_FRACTION_VALUES="0 0.9"` to stress duplicate-key diffs. A value
+of `0.9` sends 90% of inserted diff rows to tile `1`, which exercises the case
+where the first equal-key pages are already full and later inserts must advance
+to newer nearby heap pages.
+Set `HOT_TILE_COUNT_VALUES="1 8 128"` together with a non-zero hot fraction to
+spread those forced duplicate-key rows across multiple hot tile keys. This
+keeps the original single-hot-key cliff visible while also exercising batches
+where several prefixes are hot at once, including the multi-hot cleanup path
+that is easy to miss with only tile `1`.
+Set `HOT_UPDATE_FRACTION_VALUES="0 0.9"` to also bias the enlarged update
+phase toward the configured hot tile range. This is separate from
+`HOT_TILE_FRACTION_VALUES` because real diffs can have hot inserts, hot
+updates, both, or neither. When the value is non-zero, locality output splits
+updates into `update_hot` and `update_rest`.
+Set `UPDATE_PAYLOAD_REPEAT_VALUES="8 16 32 64"` to change the size of the
+enlarged update payload. This is a reserve-capacity diagnostic: if smaller
+updates stay local but larger updates do not, the issue is page free space; if
+both fail after hot inserts, the insert/update phase interaction is consuming
+or bypassing the useful reserve.
+Set `UPDATES_BEFORE_INSERTS_VALUES="false true"` to test whether applying
+enlarging updates before new-row inserts preserves more clustered reserve
+space for existing rows. This is a workload-order diagnostic, not a PostgreSQL
+placement change.
+When `hot_tile_fraction` is greater than zero, locality output splits inserts
+into `insert_hot` for the forced duplicate-key rows and `insert_rest` for the
+remaining rows. This keeps a hot-key cliff from being hidden by a small
+well-placed non-hot tail.
+The current all-equal batch guard deliberately treats a fully hot simple prefix
+batch as "stop spending clustered-write work": at scale `1`, single-key
+integer/OID clustered insert improved from the previous clean-head
+`1330.10 ms` to `866.17 ms` at hot `0.9`, and from `1702.04 ms` to
+`725.13 ms` at hot `1.0` in the verified temp-instance run. The same guard is
+also allowed for generated `text COLLATE "C"` keys, where a follow-up
+repeat-run improved fully hot text-key clustered insert from `979.47 ms` to
+`810.16 ms`. The hot rows still do not become local; the win is avoiding
+wasted clustered-index work. For single-row `INSERT ... SELECT` paths, the
+current page policy also requires clustered candidates to satisfy the normal
+insert target free-space threshold; when the equal-key neighborhood is below
+that threshold, the overflow run moves to the relation tail instead of
+consuming fillfactor reserve from many old pages. A later reserve-restoration
+pass deliberately allowed short equal-prefix runs to use a small number of
+below-fillfactor clustered neighbors before the overflow cache activates. That
+keeps small grouped diffs local, but means the `insert_hot` span can still look
+large because a few hot rows remain in the old clustered range while the rest
+append at the tail. The split `insert_hot`/`insert_rest` metrics make that
+tradeoff visible instead of hiding it in one aggregate.
+The later outside-base span metric makes this sharper: in a focused scale `1`,
+hot `0.9`, repeat `2` run, fillfactor `90` clustered `insert_hot` had full
+`heap_block_span=3773`, but `outside_base_heap_block_span=384`, exactly
+matching the control tail span. At fillfactor `50`, the clustered outside-base
+span was `692` versus control `693`. The overflow run is therefore compact
+after it leaves the old range; the large full span means "reserve rows plus
+compact tail", not scattered tail placement.
+For COPY batches that are already classified as too-hot and skip clustered
+target probing, the insert path now also skips the FSM page search. This keeps
+those dense runs from spending time on old fillfactor-reserve pages that are
+more useful for future updates. In a scale `1`, repeat `3`, fillfactor `50`,
+`hot_tile_count=128`, `hot_update_fraction=0.9` run, clustered insert median
+fell from `204.08 ms` to `163.53 ms` with unchanged locality shape; in the
+single-hot-key fillfactor `90` check, clustered insert median was `49.96 ms`
+versus `38.81 ms` for the control. Raw gzip output is under
+`/home/kom/tmp/clustered-write-synthetic/skip-fsm-hot-prefix-proto-20260501-131256/`
+and
+`/home/kom/tmp/clustered-write-synthetic/skip-fsm-hot-prefix-single-20260501-131326/`.
+
+The multi-hot axis caught a COPY crash in the hot-prefix skip cleanup. When a
+batch contained several prefixes whose per-prefix counts all exceeded
+`CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES`, the count/target caches had already
+allocated a comparison memory context before the batch chose to skip clustered
+target lookups entirely. That skip path deleted the context immediately, and
+the common cache cleanup then tried to delete it again. The cleanup now checks
+the pointer before deleting it. A scale `1`, repeat `3`,
+`hot_tile_fraction=0.9`, `hot_tile_count=1 8`, COPY run now completes; clustered
+insert medians were `63.17 ms` for one hot key and `86.04 ms` for eight hot
+keys, with compressed raw output under
+`/home/kom/tmp/clustered-write-synthetic/multi-hot-cache-update-copy-fixed-20260501-033540/`.
+The pre-fix crash log is under
+`/home/kom/tmp/clustered-write-synthetic/multi-hot-cache-update-copy-20260501-033322/`.
+A `hot_tile_count=128` follow-up exercised the non-skip multi-hot overflow shape
+and completed without server errors; it measured clustered COPY insert at
+`187.79 ms` median versus `59.78 ms` control and kept the non-hot tail
+`96.85%` inside the base range.
+
+A current scale `1`, repeat `3`, single-key median run shows that the hot
+duplicate-key cost is no longer the main problem: integer fillfactor `90`
+`hot=0.9` clustered insert was `165.92 ms` median versus `120.71 ms` for the
+control, while fillfactor `50` was `216.97 ms` versus `151.09 ms`. Generated
+text keys stayed in the same class (`222.00 ms` versus `175.87 ms` at
+fillfactor `90`, `273.00 ms` versus `200.61 ms` at fillfactor `50`). After
+making single-row `INSERT ... SELECT` use a lazy first-candidate target probe,
+the ordinary non-hot single-key case is also in a better cost class while
+preserving the same locality: integer clustered insert medians were
+`227.90 ms` / `218.98 ms` at fillfactor `90` / `50` unordered and
+`329.28 ms` / `212.22 ms` ordered; generated text keys were `300.35 ms` /
+`446.83 ms` unordered and `397.13 ms` / `408.36 ms` ordered. The matching
+control medians were still lower (`83.67-199.65 ms`), but the previous
+`614-1205 ms` clustered range is gone without losing `83.97%` to `100%`
+inside-base locality. The focused raw run is under
+`/home/kom/tmp/clustered-write-synthetic/lazy-single-row-first-candidate-focused-20260501-023105/`.
+
+Set `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` to run the insert diff
+with an explicit `ORDER BY tile_id, osm_id`. This is a diagnostic upper bound
+for "buffer and micro-sort before insert" ideas. A focused scale `1`, hot `0`
+run caught an important interaction: immediate overflow-cache activation after
+the first below-fillfactor hit made sorted non-hot input fast but wrong,
+dropping clustered insert locality from `83.97-100%` to `20.48%` inside the
+base range. Delaying tail overflow until after a small number of reserve hits
+restored sorted locality to `84.75%` at fillfactor `90` and `100%` at
+fillfactor `50`. In the current hot synthetic shape, ordering alone still does
+not make the forced hot rows local; it mainly checks whether a grouped input
+shape accidentally trips the overflow policy too early.
+As a non-hot cost fix, ordered input is also not enough: a later scale `1`,
+repeat `3`, single-key hot `0` run had mixed clustered insert medians, from a
+small fillfactor `90` integer improvement (`970.20 ms` unordered to
+`804.86 ms` ordered in that noisy run) to fillfactor `50` and text-key
+regressions (`850.61 ms` to `917.17 ms` for integer fillfactor `50`,
+`738.09 ms` to `812.56 ms` for text fillfactor `50`). Locality stayed in the
+same shape, so ordering the input by itself is not a reliable substitute for
+reducing target-probe cost.
+For hot insert+update COPY pressure, though, ordered input is now a promising
+profile rather than a dead end. In a scale `1`, repeat `3`, fillfactor `50`,
+`hot_tile_count=128`, `hot_update_fraction=0.9` run, ordering by clustered key
+changed the workload shape from "reserve consumed in many small per-batch
+prefixes" to "dense prefixes overflow compactly". Payload `8` recovered
+clustered `update_hot` from `0%` to `86.88%` inside base, cut clustered insert
+median from `350.89 ms` to `107.58 ms`, and improved broad hot reads
+(`read_hot` median `22.02 ms` to `14.77 ms`). Payload `64` recovered
+`update_hot` from `0%` to `15.50%` and cut clustered insert median from
+`249.40 ms` to `129.00 ms`. The tradeoff is explicit: `insert_hot` mostly
+moves to a compact tail (`0.21%` inside base), so this is an osm2pgsql diff
+ordering strategy to benchmark against real append/read workloads, not a claim
+that new duplicate-key rows stay in the original clustered range. Raw gzip
+output is under
+`/home/kom/tmp/clustered-write-synthetic/hot-update-order-copy-ab-20260501-133942/`.
+A follow-up generated text-key run, matching the osm2pgsql geohash-key shape
+more closely, kept the same locality result. At scale `1`, repeat `2`,
+fillfactor `50`, `text_cluster_key=true`, payload `8`, ordered COPY recovered
+clustered `update_hot` from `0%` to `86.88%`, cut clustered insert median from
+`370.74 ms` to `134.47 ms`, and improved `read_updated_hot` median from
+`48.67 ms` to `27.56 ms`; payload `64` recovered `update_hot` from `0%` to
+`15.50%` and cut insert median from `347.72 ms` to `135.43 ms`. Broad
+`read_hot` was noisier for text payload `64`, so the next real-osm2pgsql run
+should measure both append write time and read slices, but the update-locality
+and write-cost direction transfers from integer keys to generated text keys.
+Raw gzip output is under
+`/home/kom/tmp/clustered-write-synthetic/hot-update-order-text-copy-ab-20260501-134352/`.
+
+Set `COPY_DIFF_FROM_FILE_VALUES="false true"` to compare the default
+`INSERT ... SELECT` diff path with a server-side `COPY ... FROM` path. The
+wrapper first writes the generated diff rows to a per-run tab-separated file
+with `COPY (SELECT ...) TO`, then measures only the target-table
+`COPY ... FROM`. This keeps the timed section closer to an osm2pgsql-style
+bulk load and lets the benchmark exercise `heap_multi_insert` instead of only
+row-at-a-time `heap_insert`. The intermediate file path must be writable by
+the PostgreSQL server process; the wrapper removes it after each run, including
+failed `psql` runs.
+
+A focused scale `1`, repeat `3`, single-key hot `0` comparison showed why this
+needs its own axis. Integer COPY was usually faster than `INSERT ... SELECT`
+(`ff90` unordered median `155.30 ms` versus `267.16 ms`, `ff50` ordered
+`176.89 ms` versus `439.86 ms`), but at fillfactor `90` it also reduced
+clustered insert locality from `83.97-84.75%` inside the base range to
+`45.65-58.60%`. Fillfactor `50` kept `100%` locality for both INSERT and COPY.
+Generated text keys were more expensive and noisier: unordered COPY regressed
+badly (`ff90` median `909.14 ms` versus `392.73 ms`, `ff50` `1672.26 ms`
+versus `807.77 ms`), while ordered COPY improved over ordered INSERT but still
+did not make the text-key path cheap (`ff90` `469.63 ms` versus `557.44 ms`,
+`ff50` `547.09 ms` versus `859.63 ms`). Treat COPY/multi-insert as a separate
+cost and locality problem; it is not just a faster version of the row-at-a-time
+path. The raw gzip output is under
+`/home/kom/tmp/clustered-write-synthetic/copy-vs-insert-single-key-20260501-020036/`.
+
+That COPY locality loss was fixed by stopping the multi-insert page packer at
+clustered target boundaries. The pre-fix batch sort grouped tuples by their
+precomputed clustered target block, but once the first tuple selected a page,
+the normal packer could still fill that page with later tuples that were aiming
+at a different clustered neighborhood. A scale `0.02` smoke moved COPY
+fillfactor `90` locality back to `84-85%` inside the base range. A focused
+scale `1`, repeat `3`, integer-key run restored fillfactor `90` COPY locality
+to `83.97%` unordered and `84.75%` ordered, matching the `INSERT ... SELECT`
+shape; fillfactor `50` stayed `100%`. The write-time tradeoff is visible:
+after the lazy single-row target-probe fix, a scale `1`, repeat `2`,
+single-key matrix still showed COPY as a separate cost problem. Integer COPY
+clustered insert medians ranged from `228.15 ms` to `520.25 ms`; generated
+text-key COPY ranged from `183.49 ms` to `546.30 ms`, with unchanged
+`83.97-100%` insert locality. The raw gzip run is under
+`/home/kom/tmp/clustered-write-synthetic/current-insert-copy-after-lazy-single-row-20260501-023819/`.
+The next fix keeps same-target COPY runs packed even when a full clustered
+neighbour redirects the insertion to the relation tail. The previous boundary
+guard compared later tuples against the actual selected buffer; when the first
+tuple in a group fell back from its remembered clustered page to the tail, the
+rest of the same-target group stopped packing with it and paid the multi-insert
+page-selection cost row by row. A scale `1`, repeat `3`, copy-only matrix kept
+the same locality and improved most clustered COPY medians: integer `ff50`
+unordered/ordered moved from `520.25/291.35 ms` to `376.68/163.09 ms`, text
+`ff90` unordered/ordered moved from `546.30/488.15 ms` to
+`324.91/257.94 ms`, and text `ff50` unordered moved from `530.62 ms` to
+`414.22 ms`. Integer `ff90` improved modestly (`228.15/259.51 ms` to
+`212.44/220.67 ms`). The only initially suspicious case, text `ff50` ordered,
+was rechecked with repeat `6` and had a `190.97 ms` median while preserving
+`100%` inside-base locality. Raw gzip outputs are under
+`/home/kom/tmp/clustered-write-synthetic/copy-pack-same-target-20260501-024919/`
+and
+`/home/kom/tmp/clustered-write-synthetic/copy-pack-text-ff50-ordered-rerun-20260501-025142/`.
+unordered integer COPY remained faster than INSERT (`ff90` median `311.79 ms`
+versus `522.57 ms`, `ff50` `387.20 ms` versus `908.74 ms` in that run), while
+ordered `ff90` COPY regressed (`483.56 ms` versus `296.00 ms`). A repeat `2`
+text-key COPY check kept the same locality shape (`83.97-84.75%` at
+fillfactor `90`, `100%` at `50`) but remained CPU-heavy (`ff90` medians
+`853.26 ms` unordered, `507.18 ms` ordered). Treat the boundary guard as a
+locality correctness fix for COPY; it does not by itself solve text-key probe
+cost or every ordered-input timing case. Raw gzip output is under
+`/home/kom/tmp/clustered-write-synthetic/copy-boundary-integer-focused-20260501-021034/`
+and
+`/home/kom/tmp/clustered-write-synthetic/copy-boundary-text-focused-20260501-021405/`.
+
+After `analyze`, the synthetic workload also records
+`clustered_write_read_hot` and `without_cluster_metadata_read_hot`. These
+steps materialize a simple indexed read over the configured hot tile range:
+integer-key runs filter by `tile_id`, while generated text-key runs filter by
+the stored `cluster_key`. This keeps read cost visible next to write cost when
+testing micro-sort or overflow-placement ideas; locality summaries alone cannot
+show whether a more compact tail is actually worth extra insert work.
+The companion `clustered_write_read_updated_hot` and
+`without_cluster_metadata_read_updated_hot` steps apply the same hot-range
+read shape to rows whose diff update bumped `version`, making moved-update
+read cost visible separately from the broader hot-tile read.
+
+A scale `1`, repeat `3`, single-key COPY run with
+`hot_tile_fraction=0.9`, `hot_tile_count=128`, and
+`HOT_UPDATE_FRACTION_VALUES="0 0.9"` showed why update-hot needs its own axis.
+At fillfactor `50`, ordinary updates stayed mostly local (`95.13%` inside the
+base range for clustered-write, `98.20%` for the control), while hot-biased
+updates split the result: `update_rest` stayed local (`99.97%`), but
+`update_hot` did not (`0%` clustered-write, `15.63%` control). The clustered
+insert path still kept the non-hot inserted tail local (`96.90%`) while forced
+hot inserts remained a compact overflow/tail tradeoff. Raw gzip output is
+under
+`/home/kom/tmp/clustered-write-synthetic/hot-update-fillfactor-20260501-041447/`.
+
+The wrapper runs the same SQL against `$DBNAME` (`postgres` by default), stores
+the raw `psql` output under `$OUTDIR/raw`, and writes `timings.tsv`,
+`locality.tsv`, `timing_summary.tsv`, and `locality_summary.tsv`. This is the
+preferred way to compare small hot-path changes because single runs can be
+noisy even with `fsync=off`. It also writes `server_version.txt`; check that
+file before comparing runs that were not started with `USE_TEMP_INSTANCE=true`.
+`timing_summary.tsv` reports average, median, minimum, and maximum elapsed
+milliseconds for each step, so a single scheduler hiccup is visible instead of
+silently moving the headline number.
+Each run also writes `run_environment.txt` with the git head, benchmark
+settings, uptime/load, filesystem space, and selected PostgreSQL bindir, so
+noisy timing comparisons can be traced back to the build and host state.
+`locality.tsv` reports `heap_blocks_touched`, `heap_block_span`, and
+`outside_base_heap_block_span`. The full span is useful for duplicate-key
+overflow experiments, but it can overstate a mixed shape where a few rows still
+fit inside the old clustered range and the rest append compactly at the tail.
+The outside-base span measures only rows outside the original key range, making
+the actual overflow tail compactness visible.
+Raw `psql` outputs are compressed with gzip by default; set
+`COMPRESS_RAW=false` to leave them as plain `.out` files.
+
+When `USE_TEMP_INSTANCE=true`, the temporary data directory and socket
+directory are removed after the run by default, leaving the logs and summaries
+behind. The socket directory is created under `${TMPDIR:-/tmp}` rather than
+inside `OUTDIR`, because PostgreSQL's Unix-domain socket path limit is short
+and long benchmark output paths otherwise fail before the run starts. Set
+`KEEP_TEMP_INSTANCE_DATA=true` only when you need to inspect the finished
+cluster.
+
+Larger scale values increase table and diff sizes. The workload:
+
+* loads a synthetic table keyed by an OSM-like object id and a tile-like
+ clustering key, either integer or a generated text key over the same tile
+ domain;
+* creates two physically clustered copies of the heap;
+* leaves one copy with remembered clustered-index metadata and clears that
+ metadata on the other copy with `ALTER TABLE ... SET WITHOUT CLUSTER`;
+* records the original heap block range for every tile key;
+* applies a diff with new objects spread across existing tile keys;
+* applies updates to existing objects that enlarge rows enough to force moved
+ heap tuples on a full-ish table, as a regression/stress metric;
+* reports how far inserted and moved tuples drifted from their original tile
+ ranges.
+
+The `clustered_write` variant shows the new placement heuristic. The
+`without_cluster_metadata` variant is the in-script control: it starts from a
+clustered heap but disables remembered-index placement before the diff.
+Current clustered-write placement is aimed at row insertions; the update rows
+are included to make any accidental moved-update regression obvious.
+If updates look worse for `clustered_write`, that usually means the inserted
+diff rows consumed per-page free space that the later enlarged updates would
+otherwise have used. That is a real tradeoff to inspect rather than a success
+condition.
+
+The optional `use_brin=true` variable also creates a BRIN index on the tile key.
+Current clustered-write placement does not use BRIN directly: BRIN exposes
+`amgetbitmap`, not `amgettuple`, so it needs a separate bitmap/range candidate
+path. The option is present to make before/after experiments easy when that
+path is added.
+
+A scale `1` fillfactor sweep shows what reserve space can and cannot solve.
+With `HOT_TILE_FRACTION_VALUES=0.9`, lowering `HEAP_FILLFACTOR_VALUES` from
+`90` to `50` improved moved-update locality from `7.71%` to `99.28%` inside
+the original key range, but forced-hot inserts only moved from `0.03%` to
+`0.29%` inside. That is expected for this shape: each tile starts with only
+about dozens of base rows, so even a large per-page reserve cannot absorb
+thousands of new duplicate-key rows. Fillfactor helps update pressure and
+small follow-up inserts; it is not a substitute for a real overflow/range
+policy for massive equal-key diffs.
+
+Real osm2pgsql benchmark
+------------------------
+
+`run_osm2pgsql_georgia_bench.sh` runs a larger end-to-end workload against
+PostGIS and osm2pgsql:
+
+ src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh
+
+The script downloads the Geofabrik Georgia extract and the current planet daily
+replication diff, simplifies that daily change file with `osmium merge-changes
+-s` so osm2pgsql sees only the last change for each object id, then measures
+three variants:
+
+* `baseline_stock`: system PostgreSQL without this patch, stock osm2pgsql.
+* `patched_stock`: patched PostgreSQL, stock osm2pgsql.
+* `patched_clustered_import`: patched PostgreSQL and an experimental
+ osm2pgsql build with `--cluster-during-import`.
+
+The write side records `/usr/bin/time -v` output for the initial import and the
+daily diff append. The read side runs spatial bbox and exact-intersection
+queries from `osm2pgsql_georgia_read.sql`, with `EXPLAIN (ANALYZE, BUFFERS)`,
+and also reports a geohash-to-heap-block locality summary. All output goes
+under `$WORKDIR/logs` (`$HOME/tmp/clustered-write-osm2pgsql/logs` by default).
+The real-workload harness now mirrors the synthetic wrapper's disk hygiene:
+`run_environment.txt` records the selected git head, binaries, benchmark
+settings, host load, and filesystem space; per-variant logs are gzip-compressed
+by default; and each variant's `pgdata` is removed after the server stops so a
+failed long run does not quietly fill the disk.
+
+Useful overrides:
+
+* `BASELINE_PG_BIN=/path/to/bin` selects the unpatched PostgreSQL build.
+* `PATCHED_PG_BIN=/path/to/bin` selects the patched PostgreSQL build.
+* `STOCK_OSM2PGSQL=/path/to/osm2pgsql` selects stock osm2pgsql.
+* `CLUSTERED_OSM2PGSQL=/path/to/osm2pgsql` selects the experimental osm2pgsql.
+* `OSM2PGSQL_STYLE=/path/to/default.style` selects the pgsql style file.
+* `OSC_URL=https://...osc.gz` pins a specific planet daily diff.
+* `BENCH_VARIANTS=baseline_stock,patched_stock` runs only selected variants.
+* `PG_WORK_MEM=256MB` changes the benchmark cluster `work_mem`. The default is
+ `64MB`, matching the earlier runs.
+* `READ_REPEATS=3` runs the read benchmark several times against the same
+ imported database. This is much cheaper than repeating the full import and
+ makes cache/scheduler outliers visible in the read numbers. Set
+ `READ_REPEATS=0` for append-only diagnostic runs.
+* `COMPRESS_LOGS=false` leaves per-variant logs uncompressed.
+* `KEEP_PGDATA=true` keeps finished or failed PostgreSQL data directories for
+ manual inspection. The default is to delete them; use a fresh `$WORKDIR` for
+ repeat runs if you keep data around.
+* `PG_LOG_MIN_DURATION_MS=500` enables PostgreSQL statement-duration logging
+ for slow-statement diagnosis in the per-variant `*-postgres.log`.
+
+Only selected variants need their PostgreSQL and osm2pgsql binaries to exist.
+Common input-preparation tools such as curl, osmium, awk, sed, and the
+osm2pgsql style file are still required because the extract and simplified diff
+are shared across variants.
+
+The script copies PostGIS extension SQL and shared libraries into non-system
+PostgreSQL installs before creating the test database. This keeps the patched
+build usable without installing it globally. If `POSTGIS_SHARE` and
+`POSTGIS_LIB` already point at the selected non-system PostgreSQL install, the
+copy step is skipped so the benchmark can also use a locally built PostGIS
+extension in-place.
+
+The simplified daily diff is written through a temporary `.osc.gz` file with an
+explicit `osmium` output format. That keeps the preparation step independent
+of temporary suffixes, while preserving a gzip-compressed input for repeated
+append runs.
+
+One verified run used planet daily diff `000/004/978.osc.gz`, Geofabrik
+Georgia, stock osm2pgsql `2.2.0`, and `OSM2PGSQL_CACHE_MB=1024`
+`OSM2PGSQL_PROCS=2`. The `baseline_stock` row was PostgreSQL `18.3` with
+PostGIS `3.6`; the patched rows used this branch's PostgreSQL `19devel` build
+with locally built PostGIS `3.7.0dev`.
+
+| variant | create elapsed | append elapsed | database size |
+| --- | ---: | ---: | ---: |
+| baseline_stock | 2:13.29 | 2:43.53 | 2726 MB |
+| patched_stock | 2:54.12 | 2:41.17 | 2728 MB |
+| patched_clustered_import, no heap fillfactor reserve | 2:10.09 | 2:22.13 | 2776 MB |
+| patched_clustered_import, heap fillfactor 90 | 1:21.37 | 2:00.01 | 2780 MB |
+| patched_clustered_import, generated-preserving rewrite by key | 1:36.92 | 2:29.90 | 2829 MB |
+
+Read/locality summary from the same run:
+
+| variant | bbox point | bbox line | bbox polygon | roads exact | polygon exact | locality summary | line p95 blocks/span | point p95 blocks/span | polygon p95 blocks/span |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| baseline_stock | 213.205 ms | 608.002 ms | 1289.247 ms | 584.712 ms | 2784.592 ms | 20463.716 ms | 7 / 2381 | 6 / 1002 | 32 / 1208 |
+| patched_stock | 267.111 ms | 2381.184 ms | 1144.408 ms | 99.757 ms | 2215.158 ms | 9645.548 ms | 7 / 2381 | 6 / 1002 | 32 / 1208 |
+| patched_clustered_import, no heap fillfactor reserve | 332.595 ms | 631.841 ms | 972.157 ms | 98.546 ms | 1576.143 ms | 6123.307 ms | 13 / 15394 | 16 / 2207 | 10 / 20402 |
+| patched_clustered_import, heap fillfactor 90 | 226.063 ms | 619.446 ms | 1040.034 ms | 135.411 ms | 1716.965 ms | 6396.165 ms | 9 / 15396.5 | 13 / 2212 | 9 / 20314.5 |
+| patched_clustered_import, generated-preserving rewrite by key | 208.438 ms | 666.939 ms | 1336.187 ms | 266.315 ms | 4152.334 ms | 7671.920 ms | 4 / 2362.25 | 4 / 1097 | 9 / 1289 |
+
+The current result is mixed rather than a victory lap: the patched run keeps
+append time roughly flat and improves the exact polygon/roads reads in this
+run, but initial import and bbox line reads are worse. The locality aggregate
+is identical for the two stock-osm2pgsql variants, which means this run mainly
+validates the end-to-end harness and PostgreSQL/PostGIS compatibility; it does
+not yet prove that stock osm2pgsql diff imports preserve clustered geometry
+layout better.
+
+The experimental clustered-import path is more promising on write time and
+spatial reads in this run. Adding heap `fillfactor=90` to those import tables
+left real reserve space (`reloptions` reported
+`{autovacuum_enabled=off,fillfactor=90}`) and reduced the compactness metric
+from `13/16/10` p95 blocks to `9/13/9` for line/point/polygon. It did not fix
+the long p95 heap span while the initial COPY stream stayed unordered, and
+`pg_stats.correlation` for `osm2pgsql_cluster_key` stayed low or negative
+(`line=-0.0757`, `point=-0.2435`, `polygon=-0.6679`, `roads=-0.0230`).
+
+Rewriting the final heap by `osm2pgsql_cluster_key, osm_id` fixed the heap-span
+diagnosis, but the first `CREATE TABLE AS SELECT *` prototype silently turned
+the generated key into a plain text column. Rebuilding the final heap with
+`CREATE TABLE ... LIKE ... INCLUDING GENERATED` and ordered `INSERT` preserved
+the generated key after append, but it was still a full post-import rewrite.
+
+The current osm2pgsql experiment moves that ordered step into the COPY path.
+Rows are copied into per-target temporary tables created `LIKE` the real target
+`INCLUDING GENERATED`; on sync, each target drains into the real heap with
+`ORDER BY osm2pgsql_cluster_key, osm_id`. A first attempt that used a single
+temporary staging table was rejected because target-table switches drained
+many small ordered fragments, leaving poor key correlation and long spans
+(`6:06` create, `6:08` append, p95 spans `15301/1776/20334.5` for
+line/point/polygon).
+
+The per-target staging run on Georgia + planet daily diff `978` is more
+useful, but still not a win over the previous rewrite target. With patched
+PostgreSQL `19devel`, PostGIS `3.7`, osm2pgsql `2.2.0`,
+`OSM2PGSQL_CACHE_MB=1024`, and `OSM2PGSQL_PROCS=2`, it measured `3:15.36`
+create, `3:10.19` append, and `2793 MB` database size. Read timings were
+`329.192 ms` point bbox, `1140.398 ms` line bbox, `1849.375 ms` polygon bbox,
+`182.531 ms` roads exact, and `3151.490 ms` polygon exact. Locality improved
+over fragment staging to p95 block/span `3/2647.75` line, `3/1069` point, and
+`3/1657` polygon, but generated-key correlations remained mixed
+(`line=0.4769`, `point=-0.0581`, `polygon=-0.5369`, `roads=0.4759`). This is
+a working external-sort-shaped prototype, not yet the desired production
+shape: write time is still too high, and bbox reads regressed versus the
+generated-preserving rewrite run. Small bounded in-memory COPY sort windows
+were tested before this point and rejected: they lowered or raised initial
+import time depending on the window, but left `osm2pgsql_cluster_key`
+correlation poor and made diff append much slower.
+
+The next kept staging improvement defers the clustered btree index until after
+the initial staged drain. The initial load does not need that index while rows
+are already draining in generated-key order; it is only needed afterwards so
+future append runs remember the clustered order. On the same Georgia + planet
+daily diff `978` setup this measured `2:10.01` create, `2:45.90` append, and
+`2837 MB` database size. Read timings were `190.026 ms` point bbox,
+`666.042 ms` line bbox, `1106.330 ms` polygon bbox, `1890.598 ms` roads exact,
+and `1976.231 ms` polygon exact. Locality stayed in the same compact-block
+shape with p95 block/span `3/2367.75` line, `3/1024` point, and `3/1643`
+polygon; generated-key correlations stayed mixed (`line=0.4716`,
+`point=-0.0678`, `polygon=-0.5425`, `roads=0.4918`). This is enough to keep
+the deferred-index change in the osm2pgsql experiment: it removes obvious
+btree-maintenance cost from initial import, improves bbox reads and polygon
+exact reads versus the previous staged run, but regresses roads exact. It still
+does not beat the earlier
+generated-preserving rewrite target on write time, so the open problem remains
+the append/drain cost rather than correctness or heap compactness. Raw gzip
+logs are under
+`/home/kom/tmp/clustered-write-osm2pgsql-ordered-deferred-index-20260501-143958/`.
+
+The current staging shape keeps DELETEs from fragmenting that ordered run. When
+append-mode osm2pgsql receives deletes for a target table, the copy thread now
+removes matching ids from the target's temporary staging table instead of
+draining the whole staging table before the DELETE. Append-mode staging tables
+create a temporary btree index on the delete key (`osm_id`, or the existing
+type/id key) lazily, just before the first staged DELETE for that target, so
+those temp-table deletes do not become full scans as the staged batch grows.
+In a same-environment A/B against stock osm2pgsql on patched
+PostgreSQL, stock measured `1:58.33` create and `2:05.22` append, while the
+clustered import measured `1:30.30` create and `2:13.45` append. The clustered
+run had a larger heap (`2840 MB` versus `2727 MB`) but much tighter locality:
+p95 block/span was `2/2` line, `2/1` point, and `3/2` polygon, compared with
+stock `7/2381`, `6/1002`, and `32/1208`. Most read timings improved or stayed
+close (`627.029 ms` line bbox, `954.597 ms` polygon bbox, `184.525 ms` roads
+exact, `1251.008 ms` polygon exact), while one point bbox timing spiked to
+`3163.663 ms` in that run and should be treated as read noise until repeated.
+The rejected intermediate variant without the temporary delete-key index proved
+why the index is needed: it preserved the same tight locality but append
+regressed to `3:24.06` because deletes scanned the growing temp staging tables.
+Raw gzip logs are under
+`/home/kom/tmp/clustered-write-osm2pgsql-current-ab-20260501-151937/`,
+`/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-20260501-153348/`, and
+`/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-index-20260501-154406/`.
+
+A repeat of only `patched_clustered_import` with the same Georgia `978` inputs
+confirmed the point bbox spike was not stable: point bbox read was
+`978.990 ms`, not `3163.663 ms`. The heap shape stayed tight (`2/2` line,
+`2/1` point, `3/2` polygon p95 block/span), database size stayed `2840 MB`,
+and reads were `1146.925 ms` line bbox, `1912.456 ms` polygon bbox,
+`293.356 ms` roads exact, and `2053.134 ms` polygon exact. Write timings were
+noisier (`1:07.54` create, `2:51.11` append), so the honest conclusion is that
+the staged-delete-index shape is a real locality win and a plausible append
+shape, but final read/write claims need repeat medians rather than a single
+host-load-sensitive run. This repeat also verified the real-workload harness
+cleanup path: per-variant logs were gzip-compressed and `pgdata` was removed
+with `KEEP_PGDATA=false`. Raw gzip logs and `run_environment.txt` are under
+`/home/kom/tmp/clustered-write-osm2pgsql-staged-delete-index-rerun-20260501-155455/`.
+
+Adding `READ_REPEATS=3` made the read comparison less dependent on one cold
+query pass. A same-input patched PostgreSQL run compared stock osm2pgsql with
+the staged clustered import while keeping the generated `pgdata` cleanup and
+gzip compression enabled:
+
+| variant | create elapsed | append elapsed | size | point bbox median | line bbox median | polygon bbox median | roads exact median | polygon exact median | locality summary median | p95 block/span |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
+| patched_stock | 1:47.47 | 2:20.95 | 2728 MB | 130.545 ms | 193.198 ms | 330.994 ms | 76.100 ms | 1852.406 ms | 8123.631 ms | line 7/2381, point 6/1002, polygon 32/1208 |
+| patched_clustered_import | 1:21.41 | 3:05.01 | 2841 MB | 110.095 ms | 200.126 ms | 345.462 ms | 46.813 ms | 1695.422 ms | 6832.766 ms | line 2/2, point 2/1, polygon 3/2 |
+
+This is a useful but mixed result: the clustered import is faster to create,
+has far tighter heap locality, and improves point bbox plus exact reads in the
+warm median; line/polygon bbox are roughly tied or slightly worse, and append
+is still slower in this run. Raw gzip logs are under
+`/home/kom/tmp/clustered-write-osm2pgsql-read-repeats-20260501-160342/` and
+`/home/kom/tmp/clustered-write-osm2pgsql-stock-read-repeats-20260501-160927/`.
+
+The staged delete-key index is now created lazily rather than at temporary
+table creation time. This keeps the protection against full temp-table scans
+when append DELETEs arrive, but avoids paying btree maintenance for targets
+that only receive staged inserts. A same-input `READ_REPEATS=3` Georgia `978`
+run with the lazy index measured `1:42.58` create, `2:46.88` append,
+`2841 MB` size, warm read medians of `78.699 ms` point bbox, `163.190 ms` line
+bbox, `281.257 ms` polygon bbox, `40.450 ms` roads exact, and `1382.557 ms`
+polygon exact, with locality still tight at p95 block/span `2/2` line, `2/1`
+point, and `3/2` polygon. The append `Reading input files` phase took `163s`,
+down from the previous repeated clustered run's `181s` but still above the
+stock run's `131s`; treat this as a modest cleanup, not the final append-cost
+fix. The harness removed the per-variant data directory with
+`KEEP_PGDATA=false`; only the empty `$WORKDIR/pgdata` parent remains. Raw gzip
+logs are under
+`/home/kom/tmp/clustered-write-osm2pgsql-lazy-delete-index-20260501-162417/`.
+
+An append-only slow-statement run showed the remaining clustered append cost is
+mostly the ordered staging path, especially the final `INSERT ... SELECT ...
+ORDER BY` drain into the real heap. Raising the benchmark `work_mem` from
+`64MB` to `256MB` is a useful near-term knob for that drain. With the same
+kept staged/lazy-index shape, an append-only Georgia `978` run measured
+`1:23.89` create, `2:10.95` append, `2840 MB` size, and reduced slow logged
+staged-drain time above `1s` from `59.6s` to `43.3s`. A follow-up single-read
+run measured `1:18.84` create, `2:13.68` append, `2840 MB`, read timings
+`205.257 ms` point bbox, `611.452 ms` line bbox, `1418.884 ms` polygon bbox,
+`210.900 ms` roads exact, and `1116.870 ms` polygon exact, while preserving
+p95 block/span `2/2` line, `2/1` point, and `3/2` polygon. This is not a new
+algorithmic fix, but it is a practical benchmark and deployment knob for the
+current ordered-drain cost center. Raw gzip logs are under
+`/home/kom/tmp/clustered-write-osm2pgsql-workmem256-20260501-165334/` and
+`/home/kom/tmp/clustered-write-osm2pgsql-workmem256-read-20260501-165743/`.
+
+The experimental osm2pgsql variant is not part of PostgreSQL. For local
+testing, apply `osm2pgsql_cluster_during_import.patch` to a fresh osm2pgsql
+checkout and point `CLUSTERED_OSM2PGSQL` at the resulting binary. The patch
+adds `--cluster-during-import`, creates a generated geometry-derived geohash key
+and a heap with `fillfactor=90`, stages COPY rows in per-target temporary
+tables, removes matching staged rows on append DELETEs using a lazily-created
+temporary delete-key index when needed, drains those staged rows ordered by the
+generated key, then creates and marks the clustered btree index before final
+GiST/osm_id index creation. Append runs keep using the existing generated-key
+clustered index.
+
+Directly clustering the import tables with a pre-COPY GiST index on `way` was
+also tested as a negative control. It worked functionally, but maintaining the
+GiST index during ingest made the Georgia import and daily append much slower
+than the generated-key path, so the checked-in osm2pgsql experiment keeps the
+btree key while PostgreSQL keeps GiST batch-sort support available for opclasses
+where it is a better fit.
+
+Rejected experiments
+--------------------
+
+This branch intentionally keeps a record of negative results. Clustered heap
+placement has several tempting local optimizations that look obvious in code
+review but lose either locality, write time, or both when replayed through the
+synthetic and osm2pgsql workloads.
+
+Do not repeat these paths blindly:
+
+* **Eager all-candidates precomputation per tuple:** rejected because the
+ multi-insert preparation path only needs a preferred first block. Computing
+ the full bounded target-page list before the insertion loop moved too much
+ index work onto every tuple. The kept design does a cheap first-candidate
+ probe for both batch preparation and single-row insert setup, then leaves the
+ full bounded candidate window for the lazy full-page fallback in
+ `RelationGetBufferForTuple()`.
+* **Repointing the rest of a batch after a preferred page is full:** rejected
+ on hot duplicate-key smokes. It did not produce a useful locality gain and
+ made scale `0.1` hot-tile insert timings worse.
+* **Taking the backward btree edge during cheap first-candidate probes:**
+ rejected for the same reason. The backward edge is still useful for the
+ lazy full bounded search, but doing it during the cheap batch precompute
+ made the common path pay for duplicate-key defence too early.
+* **Target-run skipping and prefix-hit caps:** rejected while attacking the hot
+ duplicate-key cliff. Both variants were slower on hot-prefix scale `0.1`
+ smokes without fixing the poor forced-hot-row locality.
+* **Bounded FIFO prefix cache:** rejected because it destroyed the synthetic
+ composite-prefix locality. Clustered insert locality fell to `0%` inside the
+ base range, so the cache must not evict the exact prefix information needed
+ by the current COPY batch.
+* **Removing skip-only target arrays / relying on lazy clustered lookup:**
+ rejected because it reintroduced expensive per-tuple clustered-index probes
+ in fully hot batches. In the scale `1`, hot `1.0` check this path was around
+ `2016 ms`, far worse than the kept skip-only suppression path.
+* **Target-block-only skip without preserving slot remapping:** rejected after
+ scale `1`, hot `1.0` checks stayed above `2100 ms`. The slot remapping is
+ also correctness-sensitive for COPY index maintenance and triggers.
+* **Early leading-prefix whole-batch skip:** rejected because the quick
+ shortcut looked attractive for duplicate keys but made hot `1.0` scale `0.1`
+ slower in practice.
+* **Generic early all-equal hot-prefix shortcut:** rejected after the
+ disk-pressure recovery run. The broad version increased hot `0.9`/`1.0`
+ scale `0.1` insert timings in smoke runs without changing locality. The
+ kept variant is narrower: it handles all-equal by-value integer/OID prefixes
+ and generated `text COLLATE "C"` prefixes, both accepted only after targeted
+ scale `1` temp-instance runs.
+* **Per-tuple Datum equality fast-path for integer/OID prefixes:** rejected for
+ now. Even though integer `Datum` equality is semantically safe for the
+ tested built-in types, adding the branch to ordinary prefix-cache hits did
+ not help the workload; hot `0.9` and `1.0` smoke timings got worse while
+ locality stayed unchanged.
+* **Near-FSM fallback after clustered candidate exhaustion:** rejected. Using
+ `RecordAndGetPageWithFreeSpace()` near the last attempted clustered page
+ looked like a cheap way to keep duplicate-key overflow physically close, but
+ the scale `1` single-key run made hot `0.9`/`1.0` inserts about `2.2s` with
+ no hot-row locality gain.
+* **Skipping the whole batch when one hot prefix dominates:** rejected. A
+ `hotPrefixTupleCount > ntuples / 2` variant avoided preserving the small
+ non-hot tail, but it did not beat the kept all-hot-only guard and did not
+ improve hot-row locality.
+* **Seeding a leading hot prefix run before the count cache:** rejected. This
+ tried to skip equality work for COPY batches shaped like one long duplicate
+ prefix followed by a small useful tail, but the scale `1` single-key run made
+ hot `0.9` and `1.0` slower while locality stayed unchanged.
+* **Open-addressed by-value prefix cache:** rejected. Handling hash collisions
+ precisely looked cleaner than the direct-mapped cache, but the scale `1`
+ temp-instance run made single-key insert timings worse (`780.29 ms`,
+ `1677.95 ms`, and `1294.90 ms` for hot `0`, `0.9`, and `1.0`) while the
+ locality summary stayed unchanged.
+* **Btree batch sorting before target probes:** rejected on the real Georgia
+ osm2pgsql run (`3:14.39` create, `4:08.59` append). For this workload,
+ btree target-page probing beats sorting the batch first.
+* **Pre-COPY clustered GiST index on `way`:** rejected as an osm2pgsql import
+ strategy. It worked functionally, but GiST maintenance during ingest was
+ too expensive (`4:53.68` create, `5:30.27` append in the Georgia run).
+* **Bounded in-memory pre-COPY geohash sort in osm2pgsql:** rejected as a
+ replacement for the final generated-key rewrite. An `8192`-row table-local
+ buffer had `1:35.15` create and `4:33.82` append, with poor key correlation
+ (`line=-0.0787`, `point=-0.2400`, `polygon=-0.6597`, `roads=0.1146`) and
+ long p95 spans (`15412`, `2187`, `20392.5`). A larger `65536`-row buffer was
+ worse on write time (`2:21.27` create, `5:11.02` append) and still did not
+ repair correlation (`line≈0`, `point=-0.1958`, `polygon=-0.6488`,
+ `roads=0.4155`). The copy stream needs a global/external sort, not this
+ small micro-sort.
+* **Single temporary table for ordered staged COPY:** rejected. It was
+ functionally correct, but every target-table switch closed and drained the
+ staging table, so the real heap was a sequence of small ordered fragments
+ instead of one ordered run per target. Georgia `978` measured `6:06` create,
+ `6:08` append, worse reads, and long p95 spans (`15301`, `1776`,
+ `20334.5` for line/point/polygon). The current experiment keeps separate
+ temporary staging tables per target and drains them on sync.
+* **Deleting from staged COPY tables without a temp delete-key index:** rejected
+ as a standalone fix. It avoided the correctness-preserving drain before each
+ DELETE and achieved the desired heap shape (`2/2`, `2/1`, `3/2` p95
+ block/span for line/point/polygon), but each DELETE scanned the growing
+ staging table. Georgia `978` regressed to `3:24.06` append. The kept
+ variant adds a temporary delete-key index for append staging tables and brings
+ append back to `2:13.45` while keeping the tight heap shape.
+* **Direct append into clustered real tables:** rejected. The idea was to use
+ ordered staging only for initial import, then let append COPY directly into
+ the already-clustered real heap and rely on PostgreSQL clustered-write
+ placement. This removed the expensive staged `INSERT ... SELECT ... ORDER
+ BY` drain and improved Georgia `978` append to `2:05.19`, with `Reading input
+ files` down to `121s`, but it destroyed the storage/read shape: database size
+ grew to `3370 MB`, p95 spans became `8249` line, `7526` point, and `6653.5`
+ polygon, and read timings regressed to `224.320 ms` point bbox,
+ `1005.170 ms` line bbox, `1612.676 ms` polygon bbox, `1224.795 ms` roads
+ exact, and `3732.839 ms` polygon exact. The kept append path still needs the
+ ordered drain, even though it is now the obvious remaining cost center. Raw
+ gzip logs are under
+ `/home/kom/tmp/clustered-write-osm2pgsql-direct-append-read-20260501-164635/`.
+* **Plain real-table geohash key with generated staging key:** rejected. This
+ tried to avoid computing `ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7)`
+ twice by making the real table's `osm2pgsql_cluster_key` a plain text column,
+ while the temporary staging table computed the generated key and copied it
+ into the real heap during the ordered drain. Correctness was fine after
+ append (`0` NULL keys and `0` mismatches on point/line/polygon/roads; the
+ real columns were plain, not generated), but the Georgia `978` run regressed
+ badly to `3:42.88` create and `4:18.57` append with unchanged locality
+ (`3/2367.75`, `3/1024`, `3/1643` p95 block/span). Read timings were mixed:
+ roads exact improved to `462.948 ms`, but polygon exact regressed to
+ `3509.093 ms`, and bbox reads were slower than the deferred-index generated
+ key run. The extra real-column/staging plumbing is therefore not the missing
+ cost center. Raw gzip logs are under
+ `/home/kom/tmp/clustered-write-osm2pgsql-plain-key-stage-20260501-145922/`.
+* **CTAS final rewrite for the generated-key osm2pgsql experiment:** rejected
+ as a correctness shape even though it had the best single-run append timing
+ (`1:54.96`). `CREATE TABLE AS SELECT *` does not preserve the generated
+ column, so later append rows would stop computing `osm2pgsql_cluster_key`.
+ Later experiments use `LIKE ... INCLUDING GENERATED` plus ordered `INSERT`
+ semantics instead.
+* **`datum_image_hash()` prefix cache for varlena leading keys:** rejected.
+ This tried to make the clustered btree prefix cache direct-mapped for the
+ generated text geohash key instead of doing linear opfamily equality checks.
+ On the Georgia `978` generated-preserving run it did not improve writes:
+ create regressed to `2:10.79` and append to `2:37.58`, while locality stayed
+ the same (`line 4/2362.25`, `point 4/1097`, `polygon 9/1289` p95
+ blocks/span). Exact polygon reads were faster in that single run, but the
+ write path got worse, so the code change was reverted.
+* **Normalized text-payload hashing for clustered COPY prefix caches:** fixed
+ after the safer page-boundary guard exposed the generated-text COPY cost.
+ Unlike the rejected `datum_image_hash()` prototype, this path hashes only
+ normalized `text` payload bytes (`VARDATA_ANY` / `VARSIZE_ANY_EXHDR`) and
+ uses the hash only to choose a direct-mapped cache slot; every cache hit is
+ still verified by the clustered index equality operator. In the focused
+ scale `1`, repeat `3`, generated text-key COPY run, median clustered COPY
+ insert time fell from `1040.17 ms` to `435.25 ms` at fillfactor `50`
+ unordered, from `400.92 ms` to `275.26 ms` at fillfactor `50` ordered, from
+ `853.26 ms` to `455.36 ms` at fillfactor `90` unordered, and from
+ `507.18 ms` to `366.62 ms` at fillfactor `90` ordered. Locality stayed at
+ `100.00%` inside the base range for fillfactor `50` and `83.97%` /
+ `84.75%` for unordered / ordered fillfactor `90`. The raw runs are
+ `/home/kom/tmp/clustered-write-synthetic/copy-boundary-text-focused-20260501-021405/`
+ before the fix and
+ `/home/kom/tmp/clustered-write-synthetic/text-prefix-payload-hash-focused-20260501-022213/`
+ after it.
+* **One-opened-index-per-`heap_multi_insert` batch as a simplification:** kept
+ out as a performance change because the single Georgia rerun did not beat
+ the prior generated-geohash result (`1:59.29` create, `1:56.57` append).
+* **Append instead of FSM only after clustered candidates are exhausted:**
+ rejected as a standalone tweak. The synthetic span metric showed the real
+ hot-key problem earlier: duplicate-key rows consumed fillfactor reserve across
+ many clustered-neighbour pages before the exhausted-candidate fallback
+ mattered. A local prototype that appended only after candidate exhaustion,
+ plus an all-hot multi-insert append variant, left scale `1`, hot `0.9`
+ `insert_hot` spans unchanged (`3737` blocks at heap fillfactor `90`, `6596`
+ at fillfactor `50`). The kept fix pairs the tail fallback with a stricter
+ target-free-space check for clustered candidates.
+* **Per-row scans of already-exhausted hot prefixes:** fixed with a small
+ heap-AM overflow cache for leading clustered keys. After the
+ target-free-space fix made the hot-key span compact, `insert into ... select`
+ still paid an index probe for every row in the same exhausted `tile_id`.
+ Remembering the exhausted prefix and current tail block brought the scale `1`,
+ hot `0.9` integer clustered insert from `1194.27 ms` / `1079.02 ms` at
+ fillfactor `90` / `50` to `70.85 ms` / `82.54 ms`. A later reserve
+ restoration allows a few below-fillfactor clustered neighbours before that
+ cache activates, so current hot spans are intentionally reported as a
+ tradeoff rather than as the old compact-span claim.
+* **By-reference clustered prefixes in the overflow cache:** fixed after the
+ by-value cache left generated text keys on the slow path. The first
+ implementation copied varlena prefixes into separate `CacheMemoryContext`
+ chunks, which was fast but violated the `rd_amcache` single-chunk lifetime
+ rule. The kept implementation stores small by-reference prefixes inline in
+ the `32`-entry cache and skips caching oversized values. Before this path,
+ scale `1` text hot inserts still took roughly `770-805 ms`; with bounded
+ cached text prefixes, the text-key matrix falls to `63.07-112.31 ms` in the
+ latest inline-cache sweep, depending on fillfactor, single-key mode, and hot
+ fraction. A same-backend overwrite smoke held `CacheMemoryContext` steady,
+ and a relcache-invalidation stress (`30` insert/invalidation cycles) dropped
+ from `1082680` total / `787256` used bytes after warmup to `1048576` total /
+ `755272` used bytes after stress instead of growing.
+* **Single-row target-block cache for non-exhausted prefixes:** rejected. This
+ tried to remember ordinary successful leading-prefix target pages for
+ `INSERT ... SELECT`, not just exhausted-prefix overflow pages. The idea was
+ to avoid repeated btree target probes in the non-hot synthetic matrix, but it
+ added enough extra per-prefix metadata work that scale `1`, hot `0` got
+ slower without improving locality. Representative clustered insert timings
+ regressed from the inline-overflow-cache sweep's `313.52 ms` to `348.92 ms`
+ for composite integer fillfactor `90`, from `417.75 ms` to `465.84 ms` for
+ composite integer fillfactor `50`, from `284.92 ms` to `377.66 ms` for
+ composite text fillfactor `90`, and from `317.57 ms` to `398.11 ms` for
+ single text fillfactor `90`. The raw focused run is under
+ `/home/kom/tmp/clustered-write-synthetic/single-row-target-cache-focused-20260430-230337/`.
+* **Lazy first-candidate single-row target probes:** fixed the later non-hot
+ single-row cost problem without adding persistent target-block cache state.
+ `RelationGetBufferForTuple()` now starts `INSERT ... SELECT` clustered
+ placement with one cheap candidate, just like `heap_multi_insert` batch
+ preparation, and pays for the full bounded forward/backward candidate window
+ only if that first page is actually full. In the focused scale `1`, repeat
+ `3`, single-key hot `0` matrix, clustered insert medians fell into the
+ `212.22-446.83 ms` range while preserving the same `83.97-100%` locality.
+ A hot `0.9/1.0` repeat `2` follow-up kept insert medians near the control
+ (`90.14-147.79 ms` clustered versus `62.39-115.53 ms` control) and retained
+ the previous compact-tail shape for rows outside the base range. Raw gzip
+ outputs are under
+ `/home/kom/tmp/clustered-write-synthetic/lazy-single-row-first-candidate-focused-20260501-023105/`
+ and
+ `/home/kom/tmp/clustered-write-synthetic/lazy-single-row-first-candidate-hot-20260501-023331/`.
+* **Same-target COPY packing after tail fallback:** fixed. The first COPY
+ boundary guard preserved locality by stopping a multi-insert page when the
+ next tuple targeted a different clustered neighbourhood, but it compared
+ later tuples with the actual selected buffer. When the remembered clustered
+ page was full and `RelationGetBufferForTuple()` redirected the run to the
+ relation tail, tuples that shared the same remembered target stopped packing
+ together. The fix compares the next tuple with the remembered clustered
+ target instead, so same-target groups still use normal multi-insert packing
+ on the fallback page. A scale `1`, repeat `3`, copy-only matrix preserved
+ locality and improved most clustered COPY medians, including integer `ff50`
+ unordered/ordered (`520.25/291.35 ms` to `376.68/163.09 ms`) and text `ff90`
+ unordered/ordered (`546.30/488.15 ms` to `324.91/257.94 ms`). The suspected
+ text `ff50` ordered regression was re-run with repeat `6` and held a
+ `190.97 ms` median with `100%` inside-base locality. Raw gzip output is
+ under
+ `/home/kom/tmp/clustered-write-synthetic/copy-pack-same-target-20260501-024919/`
+ and
+ `/home/kom/tmp/clustered-write-synthetic/copy-pack-text-ff50-ordered-rerun-20260501-025142/`.
+* **Reusing `prefixCountSlots` directly for the COPY target cache:** rejected.
+ The counting pass already verifies leading-key equality and records a
+ per-tuple count-cache slot, so a prototype reused that slot for target-cache
+ lookups instead of extracting the prefix and comparing against the target
+ cache again. That helped most generated text-key COPY medians in a focused
+ scale `1`, repeat `3` matrix (`ff90` unordered/ordered moved from
+ `324.91/257.94 ms` to `205.70/179.89 ms`, and `ff50` ordered moved from
+ `220.71 ms` to `190.23 ms`), but it regressed the `ff50` unordered text-key
+ case. A repeat `6` recheck confirmed that corner at `483.62 ms` median
+ versus the previous `414.22 ms`, with unchanged `100%` inside-base locality.
+ The code experiment was reverted; raw gzip outputs are under
+ `/home/kom/tmp/clustered-write-synthetic/direct-prefix-target-cache-text-copy-20260501-025740/`
+ and
+ `/home/kom/tmp/clustered-write-synthetic/direct-prefix-target-cache-text-ff50-unordered-rerun-20260501-025849/`.
+* **Lazy prefix-count allocation based on input-order runs:** rejected. The
+ idea was to avoid the full prefix-count pass for ordinary non-hot COPY
+ batches, allocating it only when adjacent input tuples already showed a
+ same-prefix run longer than `CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES`. The
+ prototype first needed extra compile fixes around the moved comparator and an
+ uninitialized comparator warning, then crashed with SIGSEGV on the first
+ scale `1` non-hot integer fillfactor `90` unordered COPY case. Since the
+ run detector added control-flow risk before proving any timing win, the code
+ experiment was reverted and `tmp_install` was rebuilt from the clean branch.
+ The crash log and compressed raw output are under
+ `/home/kom/tmp/clustered-write-synthetic/lazy-prefix-count-nonhot-copy-20260501-030413/`.
+* **Reusing the prefix-count hash slot for COPY target-cache lookup:**
+ rejected. This was a narrower follow-up to the direct `prefixCountSlots`
+ target-cache shortcut: it reused only the already computed direct-mapped hash
+ slot, while still extracting the tuple prefix and verifying every target-cache
+ hit with the clustered btree equality function. That kept locality unchanged
+ and looked promising in a scale `1`, repeat `3` matrix for some text cases
+ (`ff50` unordered `414.22 ms` to `319.11 ms`, `ff90` ordered `257.94 ms` to
+ `198.32 ms`), but it regressed several integer cases and did not survive the
+ focused text unordered recheck. A repeat `6` text-key run measured `ff50`
+ unordered at `405.01 ms`, effectively the old shape, and `ff90` unordered at
+ `410.60 ms` versus the kept `324.91 ms`. The code experiment was reverted
+ and `tmp_install` was rebuilt from the clean branch; raw gzip outputs are
+ under
+ `/home/kom/tmp/clustered-write-synthetic/reuse-prefix-hash-slot-copy-20260501-031244/`
+ and
+ `/home/kom/tmp/clustered-write-synthetic/reuse-prefix-hash-slot-text-unordered-rerun-20260501-031521/`.
+* **Skipping the clustered COPY qsort when target blocks are already ordered:**
+ rejected. The prototype detected whether the precomputed btree target blocks
+ were already in the same order as the clustered multi-insert comparator, and
+ skipped `qsort_arg()` in that case while keeping invalid targets after valid
+ ones. A scale `1`, repeat `3` COPY matrix looked broadly promising, with
+ seven of eight clustered COPY medians improving or staying near the kept
+ same-target-packing result (`ff50` integer unordered `376.68 ms` to
+ `306.69 ms`, `ff90` text unordered `324.91 ms` to `229.77 ms`), but
+ fillfactor `90` integer unordered already regressed. The focused repeat `6`
+ fillfactor `90` recheck confirmed the shortcut was not stable: integer
+ unordered/ordered regressed to `287.08/277.57 ms` from `212.44/220.67 ms`,
+ and text ordered regressed to `350.14 ms` from `257.94 ms`; only text
+ unordered improved (`324.91 ms` to `287.65 ms`). The code experiment was
+ reverted, `tmp_install` was rebuilt from the clean branch, and a tiny
+ post-revert COPY smoke passed. Raw gzip outputs are under
+ `/home/kom/tmp/clustered-write-synthetic/skip-presorted-target-qsort-copy-20260501-032018/`,
+ `/home/kom/tmp/clustered-write-synthetic/skip-presorted-target-qsort-ff90-rerun-20260501-032243/`,
+ and
+ `/home/kom/tmp/clustered-write-synthetic/post-qsort-skip-clean-copy-smoke-20260501-032659/`.
+* **Raising COPY's buffered byte limit to reach the tuple limit:** rejected.
+ The idea was to let clustered COPY batches get closer to the existing
+ `1000`-tuple cap instead of flushing around `65535` bytes, giving the
+ clustered batch sort more rows to group. A local `MAX_BUFFERED_BYTES =
+ 262144` prototype preserved locality but produced mixed or worse insert
+ medians versus the current clean head: integer fillfactor `90` unordered COPY
+ regressed from `228.15 ms` to `423.06 ms`, ordered from `259.51 ms` to
+ `410.31 ms`; generated text fillfactor `90` ordered regressed from
+ `488.15 ms` to `556.99 ms`. A few cases improved, such as generated text
+ fillfactor `90` unordered (`546.30 ms` to `375.72 ms`), but the matrix was
+ not reliable enough to justify a broader memory-behaviour change. The code
+ experiment was reverted; raw gzip output is under
+ `/home/kom/tmp/clustered-write-synthetic/copy-buffer-256k-experiment-20260501-024252/`.
+* **Cached overflow leading-key metadata in `rd_amcache`:** rejected as a
+ follow-up optimization to the overflow cache. The prototype kept the leading
+ clustered-index attribute, collation, equality procedure, and type storage
+ metadata beside the overflow entries so reserve-hit accounting would not open
+ the clustered index and look up opfamily equality on every remembered target.
+ It preserved locality, but the focused scale `1`, hot `0`, repeat `3`
+ ordered/non-ordered matrix was mixed and often slower while control timings
+ were also noisy. Representative clustered insert averages regressed from
+ `418.44 ms` to `614.21 ms` for composite integer fillfactor `50` unordered,
+ from `353.75 ms` to `548.84 ms` for composite text fillfactor `50` ordered,
+ and from `275.28 ms` to `559.61 ms` for single text fillfactor `90`
+ unordered. Cases that improved were not enough to justify extra relcache
+ state. The likely lesson is that this does not remove the dominant per-row
+ target probe cost, so any saved syscache/index-open work is buried in noise
+ or outweighed by the extra cache state. The gzipped raw run is under
+ `/home/kom/tmp/clustered-write-synthetic/overflow-metadata-order-nonhot-20260430-233011/`.
+* **Immediate tail overflow after the first below-fillfactor hit:** fixed after
+ `ORDER_DIFF_BY_CLUSTER_KEY_VALUES=true` exposed the failure mode. Sorted
+ non-hot input groups the few rows for each key together; with immediate
+ overflow activation, those short groups were sent to the relation tail after
+ one reserve hit, dropping clustered insert locality to `20.48%` inside the
+ base range. The kept policy counts a small number of reserve hits per
+ prefix before activating the cached tail target. The focused scale `1`,
+ hot `0`, repeat `3` run restored sorted locality to `84.75%` at fillfactor
+ `90` and `100%` at fillfactor `50`, while the hot `0.9/1` regression run
+ kept write timings in the same broad range as the inline overflow-cache
+ sweep. Invalid reserve-count entries are deliberately not updated when a
+ real overflow target extends, so an unrelated tail extension cannot
+ prematurely activate a counted-but-not-yet-overflowing prefix.
+* **Lowering the reserve-hit threshold from `8` to `4`:** rejected. This
+ looked like a direct way to reduce the number of old-range outlier rows before
+ duplicate-key overflow moves to the compact tail, but it reintroduced the
+ short-group failure that the kept threshold fixed. In the focused scale `1`,
+ repeat `2`, integer-key matrix, sorted non-hot fillfactor `50` locality fell
+ to `81.92%` inside the base range, where the kept threshold-`8` run had
+ restored `100%`. Fillfactor `90` sorted non-hot also stayed below the kept
+ `84.75%` result (`81.91%` in the threshold-`4` run). Hot span did not
+ improve, so the more aggressive threshold only spent locality budget without
+ buying a useful compact-overflow win. The gzipped raw run is under
+ `/home/kom/tmp/clustered-write-synthetic/reserve-threshold4-focused-20260430-233816/`.
+* **Lowering `CLUSTERED_WRITE_MAX_PREFIX_TARGET_TUPLES` from `16` to `4`:**
+ rejected. This tried to make dense multi-hot COPY batches stop spending
+ clustered-target work sooner, using a scale `1`, repeat `3`,
+ `hot_tile_fraction=0.9`, `hot_tile_count=128` run with unordered and
+ clustered-key ordered COPY input. It preserved the same broad locality shape
+ but made both clustered COPY medians worse versus the clean threshold-`16`
+ head: unordered regressed from `140.79 ms` to `200.88 ms`, and ordered
+ regressed from `86.61 ms` to `129.12 ms`. The code experiment was reverted
+ and `tmp_install` was rebuilt from the clean branch. The gzipped raw run is
+ under
+ `/home/kom/tmp/clustered-write-synthetic/prefix-threshold4-multi-hot-128-copy-20260501-034550/`.
+* **Btree leading-key sorting for skipped too-hot COPY prefixes:** rejected
+ for now. The prototype left valid clustered target blocks alone, but used
+ btree sort support to order only the tuples whose clustered target probe had
+ already been skipped as too-hot. It made the unordered multi-hot tail more
+ compact in the scale `1`, repeat `3`, `hot_tile_count=128` COPY run
+ (`insert_hot` outside-base span `392` blocks, `insert_rest` `113`), and the
+ new read step showed a possible unordered read win (`12.92 ms` prototype
+ median versus `15.70 ms` in a clean-code rerun). However, the same prototype
+ added qsort/sortsupport work to the hot COPY path, did not beat the earlier
+ clean write baseline for unordered inserts (`157.76 ms` versus `140.79 ms`),
+ and the clean rerun was noisy enough that this needs a better read/write
+ benchmark shape before carrying more heap comparator complexity. The code
+ experiment was reverted; the read timing benchmark was kept. Raw gzip
+ outputs are under
+ `/home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-proto-20260501-035531/`,
+ `/home/kom/tmp/clustered-write-synthetic/btree-prefix-sort-read-ab-clean-20260501-035714/`,
+ and
+ `/home/kom/tmp/clustered-write-synthetic/multi-hot-128-ordered-copy-20260501-034338/`.
+* **Using clustered target selection for moved heap updates:** rejected for
+ now. The prototype passed the updated tuple from `heap_update()` into
+ `RelationGetBufferForTuple()` and allowed the existing clustered neighbor
+ search when `otherBuffer` was set, relying on the existing lower-block-first
+ buffer lock ordering. A scale `1`, repeat `3`, `hot_tile_fraction=0.9`,
+ `hot_tile_count=128` run did not improve the meaningful locality shape:
+ `pct_inside_base_range` stayed at `7.41/7.91%` for unordered/ordered
+ clustered updates, while update write cost regressed badly. Clustered update
+ medians rose to `1191.28 ms` unordered and `1072.18 ms` ordered, versus the
+ in-script control at `784.22 ms` and `889.52 ms`. The new
+ `read_updated_hot` step also showed no decisive read win (`37.64/39.88 ms`
+ clustered versus `39.05/35.88 ms` control). The code experiment was
+ reverted; the update-focused read timing was kept. Raw gzip output is under
+ `/home/kom/tmp/clustered-write-synthetic/update-placement-proto-20260501-040654/`.
+* **Applying enlarged updates before new hot inserts:** rejected as a general
+ workload-order fix. The new `UPDATES_BEFORE_INSERTS_VALUES` axis tested the
+ tempting osm2pgsql-style idea that existing rows should consume fillfactor
+ reserve before new duplicate-key rows arrive. In the focused scale `1`,
+ repeat `3`, fillfactor `50`, `hot_tile_fraction=0.9`,
+ `hot_update_fraction=0.9`, `hot_tile_count=128`, COPY run, update-first did
+ improve clustered `update_hot` locality from `0%` to `15.63%`, but it moved
+ the damage into inserts: clustered `insert_hot` locality fell from `28.31%`
+ to `1.42%`, outside-base span grew from `304` to `1082` blocks, and
+ clustered insert median slowed from `204.08 ms` to `345.64 ms`. The code
+ path is still useful as a diagnostic benchmark axis, but not as a fix by
+ itself. Raw gzip output is under
+ `/home/kom/tmp/clustered-write-synthetic/update-before-ab-20260501-041913/`.
+* **Treating hot-update failure as only a payload-size problem:** rejected.
+ The `UPDATE_PAYLOAD_REPEAT_VALUES` axis separates moved-update reserve
+ capacity from the insert/update interaction. In the scale `1`, repeat `2`,
+ fillfactor `50`, `hot_tile_fraction=0.9`, `hot_update_fraction=0.9`,
+ `hot_tile_count=128`, COPY run, the clustered table still kept `update_hot`
+ at `0%` inside the original base range for payload repeats `8`, `16`, `32`,
+ and `64` when hot inserts ran first. The control showed that payload size
+ matters for ordinary heap behaviour (`87.50%`, `53.13%`, `31.25%`,
+ `15.63%` inside base as payload grew), but clustered hot inserts had already
+ made the useful reserve unavailable to later updates. Reversing the phase
+ order with payload `8` restored clustered `update_hot` to `87.50%`, while
+ clustered `insert_hot` fell from `28.31%` to `0%`; payload `64` repeated the
+ earlier partial update win (`15.63%`) and insert regression (`1.42%`). So
+ the next fix should not merely shrink payloads or reorder phases; it needs a
+ policy that preserves update reserve while keeping duplicate-key insert
+ overflow compact. Raw gzip outputs are under
+ `/home/kom/tmp/clustered-write-synthetic/update-payload-hot-matrix-20260501-132655/`
+ and
+ `/home/kom/tmp/clustered-write-synthetic/update-payload-before-after-20260501-132803/`.
+* **Disabling below-fillfactor clustered reserve for all bulk/COPY clustered
+ targets:** rejected. The prototype changed `RelationGetBufferForTuple()` so
+ bulk insert callers could only use clustered neighbour pages that satisfied
+ the normal fillfactor-aware target free-space threshold, leaving reserve
+ space for future moved updates. It proved the reserve-conflict diagnosis:
+ in a scale `1`, repeat `2`, fillfactor `50`, `hot_tile_count=128`,
+ `hot_update_fraction=0.9`, COPY run, payload `8` recovered clustered
+ `update_hot` from `0%` to `87.50%`, and payload `64` recovered it to the
+ control-like `15.63%`. But the price was too broad: clustered `insert_hot`
+ fell to `0%` inside base, and a non-hot/hot COPY insert sweep also drove
+ ordinary clustered insert locality to `0%` for fillfactor `90` and `50`,
+ with worse spans than the control in several cases. The code was reverted;
+ the useful lesson is narrower than "bulk inserts must never consume reserve".
+ A viable fix needs to identify update-pressure or massive duplicate-key
+ insert phases more specifically, not remove clustered reserve use from every
+ COPY path. Raw gzip outputs are under
+ `/home/kom/tmp/clustered-write-synthetic/no-bulk-reserve-proto-20260501-133332/`
+ and
+ `/home/kom/tmp/clustered-write-synthetic/no-bulk-reserve-nonhot-proto-20260501-133415/`.
+* **Relying on ordered diff input as the non-hot cost fix:** rejected as a
+ standalone answer. A scale `1`, repeat `3`, single-key `hot=0` run with
+ `ORDER_DIFF_BY_CLUSTER_KEY_VALUES="false true"` kept the same locality shape
+ (`84.75%` instead of `83.97%` inside base range at fillfactor `90`, `100%` at
+ fillfactor `50`), but clustered insert medians were mixed and remained far
+ above the control. The raw gzip run is under
+ `/home/kom/tmp/clustered-write-synthetic/current-nonhot-ordered-median-20260501-014236/`.
+* **Skipping clustered probes for singleton prefixes in a batch:** rejected.
+ This tried to reduce ordinary non-hot single-key cost in `heap_multi_insert`
+ by not probing the clustered btree when a prefix appeared only once in the
+ current batch. The focused scale `1`, repeat `2`, single-key `hot=0` run did
+ not change locality in the synthetic matrix because the benchmark's
+ `INSERT ... SELECT` path uses `ExecInsert`/`table_tuple_insert()`/`heap_insert`,
+ not `heap_multi_insert`. This makes the experiment useful only as a layer
+ check: batch-level guards can help COPY, but they do not attack the measured
+ synthetic row-at-a-time insert cost. The policy is also risky for genuinely
+ sparse real COPY diffs, where a singleton may still be the only chance to
+ preserve locality for that key. The code experiment was reverted; raw gzip
+ output and the reverted patch are under
+ `/home/kom/tmp/clustered-write-synthetic/skip-singleton-prefix-experiment-20260501-015018/`.
+
+The useful lesson from the rejected hot-prefix work is that duplicate-key
+batches need either a genuinely local page-selection policy for the repeated
+key, or enough prefix grouping that the already-kept all-hot guard can keep the
+overflow compact. Adding more eager probes, late FSM fallback tweaks, or more
+per-tuple branching has repeatedly moved the cost curve in the wrong direction;
+when a prefix has already exhausted its clustered-neighbour pages, the fast path
+is to keep appending locally and recheck the real page free space.
+
+Important output columns:
+
+* `brin_enabled`: whether the optional BRIN index was present for this run.
+ Current placement does not consume BRIN directly, so this should normally
+ leave the locality metrics unchanged until a BRIN/range path is implemented.
+* `single_key_cluster`: whether the remembered clustered btree index was on
+ just the tile key instead of `(tile_id, osm_id)`.
+* `text_cluster_key`: whether the remembered clustered btree index used the
+ generated text `cluster_key` instead of the integer tile columns.
+* `heap_fillfactor`: heap fillfactor used for the initial clustered table and
+ the control copy.
+* `hot_tile_fraction`: fraction of inserted diff rows forced onto one tile key
+ to stress duplicate-key placement over already-full equal-key pages.
+* `hot_tile_count`: number of tile keys used for the forced duplicate-key rows
+ when `hot_tile_fraction` is non-zero.
+* `hot_update_fraction`: fraction of enlarged update rows biased toward the
+ configured hot tile range.
+* `update_payload_repeat`: multiplier for the `updated-row` payload used by
+ enlarged updates. Lower values test whether fillfactor reserve is sufficient
+ when moved rows are small; higher values stress reserve exhaustion.
+* `updates_before_inserts`: whether the update phase ran before the insert
+ phase in this synthetic workload.
+* `order_diff_by_cluster_key`: whether the synthetic diff insert was explicitly
+ ordered by `(tile_id, osm_id)` before insertion.
+* `copy_diff_from_file`: whether the insert diff used the server-side
+ `COPY ... FROM` benchmark path instead of the default `INSERT ... SELECT`
+ path. The timed insert excludes the preceding per-run `COPY (SELECT ...) TO`
+ staging step.
+* `variant`: either the clustered-write path or the in-script control without
+ remembered clustered-index metadata.
+* `diff_kind`: `insert`/`update` normally; `insert_hot`/`insert_rest` when
+ `hot_tile_fraction` is nonzero; and `update_hot`/`update_rest` when
+ `hot_update_fraction` is nonzero.
+* `step` and `elapsed_ms`: structured timing rows for the diff insert/update
+ statements, emitted before the locality summary so runs can be compared
+ without scraping `psql` timing chatter.
+* `avg_rows_measured`: average number of rows behind the locality row in
+ `locality_summary.tsv`. This is important for hot-update runs because the
+ configured fraction can be capped by the number of base rows that actually
+ belong to the hot tile range.
+* `avg_elapsed_ms`, `median_elapsed_ms`, `min_elapsed_ms`, `max_elapsed_ms`:
+ aggregate timing columns in `timing_summary.tsv`; prefer the median when the
+ run environment shows high load or a small number of outliers.
+* `pct_inside_base_range`: higher is better; percent of diff tuples still inside
+ the tile's original clustered heap block range.
+* `avg_block_drift`, `p95_block_drift`, `max_block_drift`: lower is better;
+ block distance outside the original tile range.
+* `heap_block_span`: lower is better for the measured diff rows themselves;
+ this is `max(heap_block) - min(heap_block) + 1`, so it catches whether an
+ overflow tail is compact even when it cannot fit inside the old base range.
+* `outside_base_heap_block_span`: lower is better for rows that did not fit
+ inside the old clustered key range. This helps separate a genuinely scattered
+ overflow tail from the expected mixed case where a few reserve-space rows
+ remain local and the rest append compactly.
+* `heap_blocks_touched`: lower can be better for locality, but interpret it
+ with the drift metrics because very small values can also mean hot spots.
diff --git a/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch
new file mode 100644
index 0000000000000..75ffb74427da8
--- /dev/null
+++ b/src/tools/clustered_write_bench/osm2pgsql_cluster_during_import.patch
@@ -0,0 +1,463 @@
+diff --git a/src/command-line-parser.cpp b/src/command-line-parser.cpp
+index 232a70a..4a98592 100644
+--- a/src/command-line-parser.cpp
++++ b/src/command-line-parser.cpp
+@@ -386,6 +386,12 @@ options_t parse_command_line(int argc, char *argv[])
+ "the columns.")
+ ->group("Pgsql output options");
+
++ // --cluster-during-import
++ app.add_flag("--cluster-during-import", options.cluster_during_import)
++ ->description("Experimental: create a clustered geometry-derived key before "
++ "loading rows and skip the final geometry sort.")
++ ->group("Pgsql output options");
++
+ // --keep-coastlines
+ app.add_flag("-K,--keep-coastlines", options.keep_coastlines)
+ ->description("Keep coastline data (default: discard objects tagged"
+diff --git a/src/db-copy.cpp b/src/db-copy.cpp
+index 1a7dede..0f66444 100644
+--- a/src/db-copy.cpp
++++ b/src/db-copy.cpp
+@@ -165,6 +165,7 @@ void db_copy_thread_t::thread_t::operator()()
+ }
+
+ finish_copy();
++ drain_ordered_copy_tables();
+ } catch (std::runtime_error const &e) {
+ log_error("DB copy thread failed: {}", e.what());
+ std::exit(2); // NOLINT(concurrency-mt-unsafe)
+@@ -177,6 +178,9 @@ bool db_copy_thread_t::thread_t::execute(db_cmd_copy_delete_t &cmd)
+ if (cmd.has_deletables() ||
+ (m_inflight && !cmd.target->same_copy_target(*m_inflight))) {
+ finish_copy();
++ if (cmd.has_deletables() && cmd.target->uses_ordered_copy()) {
++ delete_from_ordered_copy_table(cmd);
++ }
+ }
+
+ cmd.delete_data(m_db_connection);
+@@ -199,6 +203,7 @@ bool db_copy_thread_t::thread_t::execute(db_cmd_end_copy_t &)
+ bool db_copy_thread_t::thread_t::execute(db_cmd_sync_t &cmd)
+ {
+ finish_copy();
++ drain_ordered_copy_tables();
+ cmd.barrier.set_value();
+ return false;
+ }
+@@ -209,14 +214,21 @@ void db_copy_thread_t::thread_t::start_copy(
+ assert(!m_inflight);
+
+ auto const qname = qualified_name(target->schema(), target->name());
++ auto copy_name = qname;
++
++ if (target->uses_ordered_copy()) {
++ ensure_ordered_copy_table(target);
++ copy_name = fmt::format(R"("{}")", target->ordered_copy_table());
++ }
++
+ fmt::memory_buffer sql;
+- sql.reserve(qname.size() + target->rows().size() + 20);
++ sql.reserve(copy_name.size() + target->rows().size() + 20);
+ if (target->rows().empty()) {
+ fmt::format_to(std::back_inserter(sql),
+- FMT_STRING("COPY {} FROM STDIN"), qname);
++ FMT_STRING("COPY {} FROM STDIN"), copy_name);
+ } else {
+ fmt::format_to(std::back_inserter(sql),
+- FMT_STRING("COPY {} ({}) FROM STDIN"), qname,
++ FMT_STRING("COPY {} ({}) FROM STDIN"), copy_name,
+ target->rows());
+ }
+
+@@ -233,3 +245,100 @@ void db_copy_thread_t::thread_t::finish_copy()
+ m_inflight.reset();
+ }
+ }
++
++void db_copy_thread_t::thread_t::ensure_ordered_copy_table(
++ std::shared_ptr const &target)
++{
++ if (has_ordered_copy_table(target)) {
++ return;
++ }
++
++ auto const qname = qualified_name(target->schema(), target->name());
++ m_db_connection.exec(
++ R"(CREATE TEMP TABLE "{}" (LIKE {} INCLUDING GENERATED) )"
++ "ON COMMIT PRESERVE ROWS",
++ target->ordered_copy_table(), qname);
++ m_ordered_copy_targets.push_back(target);
++}
++
++bool db_copy_thread_t::thread_t::has_ordered_copy_table(
++ std::shared_ptr const &target) const
++{
++ for (auto const &known : m_ordered_copy_targets) {
++ if (target->same_copy_target(*known)) {
++ return true;
++ }
++ }
++ return false;
++}
++
++bool db_copy_thread_t::thread_t::has_ordered_copy_delete_index(
++ std::shared_ptr const &target) const
++{
++ for (auto const &known : m_ordered_copy_delete_index_targets) {
++ if (target->same_copy_target(*known)) {
++ return true;
++ }
++ }
++ return false;
++}
++
++void db_copy_thread_t::thread_t::ensure_ordered_copy_delete_index(
++ std::shared_ptr const &target)
++{
++ if (!target->has_ordered_copy_delete_index() ||
++ has_ordered_copy_delete_index(target)) {
++ return;
++ }
++
++ m_db_connection.exec(R"(CREATE INDEX ON "{}" ({}))",
++ target->ordered_copy_table(),
++ target->ordered_copy_delete_index_by());
++ m_ordered_copy_delete_index_targets.push_back(target);
++}
++
++void db_copy_thread_t::thread_t::delete_from_ordered_copy_table(
++ db_cmd_copy_delete_t &cmd)
++{
++ if (has_ordered_copy_table(cmd.target)) {
++ ensure_ordered_copy_delete_index(cmd.target);
++ cmd.delete_data_from(fmt::format(R"("{}")",
++ cmd.target->ordered_copy_table()),
++ m_db_connection);
++ }
++}
++
++void db_copy_thread_t::thread_t::delete_from_ordered_copy_table(
++ db_cmd_copy_delete_t &cmd)
++{
++ if (has_ordered_copy_table(cmd.target)) {
++ ensure_ordered_copy_delete_index(cmd.target);
++ cmd.delete_data_from(fmt::format(R"("{}")",
++ cmd.target->ordered_copy_table()),
++ m_db_connection);
++ }
++}
++
++void db_copy_thread_t::thread_t::drain_ordered_copy_table(
++ std::shared_ptr const &target)
++{
++ if (!has_ordered_copy_table(target)) {
++ return;
++ }
++
++ auto const qname = qualified_name(target->schema(), target->name());
++
++ m_db_connection.exec(
++ R"(INSERT INTO {} ({}) SELECT {} FROM "{}" ORDER BY {})", qname,
++ target->rows(), target->rows(), target->ordered_copy_table(),
++ target->ordered_copy_by());
++ m_db_connection.exec(R"(TRUNCATE TABLE "{}")",
++ target->ordered_copy_table());
++}
++
++void db_copy_thread_t::thread_t::drain_ordered_copy_tables()
++{
++ for (auto const &target : m_ordered_copy_targets) {
++ drain_ordered_copy_table(target);
++ }
++}
+diff --git a/src/db-copy.hpp b/src/db-copy.hpp
+index 3a9d3f0..95e85d7 100644
+--- a/src/db-copy.hpp
++++ b/src/db-copy.hpp
+@@ -46,8 +46,41 @@ public:
+ std::string const &name() const noexcept { return m_name; }
+ std::string const &id() const noexcept { return m_id; }
+ std::string const &rows() const noexcept { return m_rows; }
++ std::string const &ordered_copy_by() const noexcept
++ {
++ return m_ordered_copy_by;
++ }
++ std::string const &ordered_copy_table() const noexcept
++ {
++ return m_ordered_copy_table;
++ }
++ std::string const &ordered_copy_delete_index_by() const noexcept
++ {
++ return m_ordered_copy_delete_index_by;
++ }
+
+ void set_rows(std::string rows) { m_rows = std::move(rows); }
++ void set_ordered_copy_by(std::string order_by)
++ {
++ m_ordered_copy_by = std::move(order_by);
++ }
++ void set_ordered_copy_table(std::string table)
++ {
++ m_ordered_copy_table = std::move(table);
++ }
++ void set_ordered_copy_delete_index_by(std::string columns)
++ {
++ m_ordered_copy_delete_index_by = std::move(columns);
++ }
++
++ bool uses_ordered_copy() const noexcept
++ {
++ return !m_ordered_copy_by.empty();
++ }
++ bool has_ordered_copy_delete_index() const noexcept
++ {
++ return !m_ordered_copy_delete_index_by.empty();
++ }
+
+ /**
+ * Check if the buffer would use exactly the same copy operation.
+@@ -56,7 +89,11 @@ public:
+ {
+ return (this == &other) ||
+ (m_schema == other.m_schema && m_name == other.m_name &&
+- m_id == other.m_id && m_rows == other.m_rows);
++ m_id == other.m_id && m_rows == other.m_rows &&
++ m_ordered_copy_by == other.m_ordered_copy_by &&
++ m_ordered_copy_table == other.m_ordered_copy_table &&
++ m_ordered_copy_delete_index_by ==
++ other.m_ordered_copy_delete_index_by);
+ }
+
+ private:
+@@ -68,6 +105,12 @@ private:
+ std::string m_id;
+ /// Comma-separated list of rows for copy operation (when empty: all rows)
+ std::string m_rows;
++ /// Optional ORDER BY clause for a temporary staged COPY before insertion.
++ std::string m_ordered_copy_by;
++ /// Temporary table name used for staged ordered COPY.
++ std::string m_ordered_copy_table;
++ /// Optional index columns for fast DELETEs from a staged COPY table.
++ std::string m_ordered_copy_delete_index_by;
+ };
+
+ /**
+@@ -196,6 +239,14 @@ public:
+ }
+ }
+
++ void delete_data_from(std::string const &table,
++ pg_conn_t const &db_connection)
++ {
++ if (m_deleter.has_data()) {
++ m_deleter.delete_rows(table, target->id(), db_connection);
++ }
++ }
++
+ template
+ void add_deletable(ARGS &&...args)
+ {
+@@ -293,6 +344,21 @@ private:
+
+ void start_copy(std::shared_ptr const &target);
+ void finish_copy();
++ void ensure_ordered_copy_table(
++ std::shared_ptr const &target);
++ bool has_ordered_copy_table(
++ std::shared_ptr const &target) const;
++ void ensure_ordered_copy_delete_index(
++ std::shared_ptr const &target);
++ bool has_ordered_copy_delete_index(
++ std::shared_ptr const &target) const;
++ void delete_from_ordered_copy_table(
++ db_cmd_copy_delete_t &cmd);
++ void delete_from_ordered_copy_table(
++ db_cmd_copy_delete_t &cmd);
++ void drain_ordered_copy_table(
++ std::shared_ptr const &target);
++ void drain_ordered_copy_tables();
+ void delete_rows(db_cmd_copy_t *buffer);
+
+ connection_params_t m_connection_params;
+@@ -300,6 +366,9 @@ private:
+
+ // Target for copy operation currently ongoing.
+ std::shared_ptr m_inflight;
++ std::vector> m_ordered_copy_targets;
++ std::vector>
++ m_ordered_copy_delete_index_targets;
+
+ // These are shared with the db_copy_thread_t in the main program.
+ shared *m_shared;
+diff --git a/src/options.hpp b/src/options.hpp
+index 4f56bfb..4cc115b 100644
+--- a/src/options.hpp
++++ b/src/options.hpp
+@@ -136,6 +136,9 @@ struct options_t
+
+ bool enable_hstore_index = false; ///< add an index on the hstore column
+
++ /// create a clustered btree index over a generated geometry key before the first COPY
++ bool cluster_during_import = false;
++
+ /// Output multi-geometries intead of several simple geometries
+ bool enable_multi = false;
+
+diff --git a/src/output-pgsql.cpp b/src/output-pgsql.cpp
+index 6b250a6..4b38667 100644
+--- a/src/output-pgsql.cpp
++++ b/src/output-pgsql.cpp
+@@ -511,7 +511,8 @@ output_pgsql_t::output_pgsql_t(std::shared_ptr const &mid,
+ m_tables.at(i) = std::make_unique(
+ name, type, columns, options.hstore_columns,
+ options.projection->target_srs(), options.append,
+- options.hstore_mode, copy_thread, options.output_dbschema);
++ options.hstore_mode, copy_thread, options.output_dbschema,
++ options.cluster_during_import);
+ }
+ }
+
+diff --git a/src/table.cpp b/src/table.cpp
+index 6b65eb9..6794fe5 100644
+--- a/src/table.cpp
++++ b/src/table.cpp
+@@ -27,10 +27,11 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns,
+ hstores_t hstore_columns, int const srid, bool const append,
+ hstore_column hstore_mode,
+ std::shared_ptr const ©_thread,
+- std::string const &schema)
++ std::string const &schema, bool const cluster_during_import)
+ : m_target(std::make_shared(schema, name, "osm_id")),
+ m_type(std::move(type)), m_srid(fmt::to_string(srid)), m_append(append),
+- m_hstore_mode(hstore_mode), m_columns(std::move(columns)),
++ m_hstore_mode(hstore_mode), m_cluster_during_import(cluster_during_import),
++ m_columns(std::move(columns)),
+ m_hstore_columns(std::move(hstore_columns)), m_copy(copy_thread)
+ {
+ // if we dont have any columns
+@@ -39,13 +40,23 @@ table_t::table_t(std::string const &name, std::string type, columns_t columns,
+ }
+
+ generate_copy_column_list();
++
++ if (m_cluster_during_import) {
++ m_target->set_ordered_copy_by("osm2pgsql_cluster_key, osm_id");
++ m_target->set_ordered_copy_table("osm2pgsql_ordered_copy_" + name);
++ if (m_append) {
++ m_target->set_ordered_copy_delete_index_by(m_target->id());
++ }
++ }
+ }
+
+ table_t::table_t(table_t const &other,
+ std::shared_ptr const ©_thread)
+ : m_connection_params(other.m_connection_params), m_target(other.m_target),
+ m_type(other.m_type), m_srid(other.m_srid), m_append(other.m_append),
+- m_hstore_mode(other.m_hstore_mode), m_columns(other.m_columns),
++ m_hstore_mode(other.m_hstore_mode),
++ m_cluster_during_import(other.m_cluster_during_import),
++ m_columns(other.m_columns),
+ m_hstore_columns(other.m_hstore_columns), m_table_space(other.m_table_space),
+ m_copy(copy_thread)
+ {
+@@ -118,12 +129,28 @@ void table_t::start(connection_params_t const &connection_params,
+ sql += "\"tags\" hstore,";
+ }
+
+- sql += fmt::format("way geometry({},{}) )", m_type, m_srid);
++ sql += fmt::format("way geometry({},{})", m_type, m_srid);
++
++ if (m_cluster_during_import) {
++ sql +=
++ ", osm2pgsql_cluster_key text COLLATE \"C\" "
++ "GENERATED ALWAYS AS "
++ "(ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7)) "
++ "STORED";
++ }
++
++ sql += " )";
+
+ // The final tables are created with CREATE TABLE AS ... SELECT * FROM ...
+ // This means that they won't get this autovacuum setting, so it doesn't
+- // doesn't need to be RESET on these tables
+- sql += " WITH (autovacuum_enabled = off)";
++ // doesn't need to be RESET on these tables. Clustered import keeps the
++ // first heap layout, so leave room for later diff rows in the same key
++ // ranges instead of filling all clustered pages during COPY.
++ if (m_cluster_during_import) {
++ sql += " WITH (autovacuum_enabled = off, fillfactor = 90)";
++ } else {
++ sql += " WITH (autovacuum_enabled = off)";
++ }
+ //add the main table space
+ sql += m_table_space;
+
+@@ -190,17 +217,38 @@ void table_t::stop(bool updateable, bool enable_hstore_index,
+ m_target->name());
+ }
+
+- log_info("Clustering table '{}' by geometry...", m_target->name());
++ if (m_cluster_during_import) {
++ log_info("Keeping ordered staged COPY heap for table '{}'.",
++ m_target->name());
++ auto const idx_name = m_target->name() + "_cluster_key_idx";
++ auto const quoted_idx_name = fmt::format(R"("{}")", idx_name);
+
+- std::string const sql =
+- fmt::format("CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way",
+- qual_tmp_name, m_table_space, qual_name);
++ check_identifier(idx_name, "index names");
++ log_info("Creating clustered geometry key index on table '{}'...",
++ m_target->name());
++ m_db_connection->exec(
++ "CREATE INDEX {} ON {} USING BTREE "
++ "(osm2pgsql_cluster_key) {}",
++ quoted_idx_name, qual_name,
++ tablespace_clause(table_space_index));
+
+- m_db_connection->exec(sql);
++ log_info("Remembering clustered geometry key order for table '{}'...",
++ m_target->name());
++ m_db_connection->exec("CLUSTER {} USING {}", qual_name,
++ quoted_idx_name);
++ } else {
++ log_info("Clustering table '{}' by geometry...", m_target->name());
++
++ std::string const sql = fmt::format(
++ "CREATE TABLE {} {} AS SELECT * FROM {} ORDER BY way",
++ qual_tmp_name, m_table_space, qual_name);
+
+- m_db_connection->exec("DROP TABLE {}", qual_name);
+- m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")", qual_tmp_name,
+- m_target->name());
++ m_db_connection->exec(sql);
++
++ m_db_connection->exec("DROP TABLE {}", qual_name);
++ m_db_connection->exec(R"(ALTER TABLE {} RENAME TO "{}")",
++ qual_tmp_name, m_target->name());
++ }
+
+ log_info("Creating geometry index on table '{}'...", m_target->name());
+
+diff --git a/src/table.hpp b/src/table.hpp
+index db0f73f..122e1b5 100644
+--- a/src/table.hpp
++++ b/src/table.hpp
+@@ -32,7 +32,7 @@ public:
+ hstores_t hstore_columns, int srid, bool append,
+ hstore_column hstore_mode,
+ std::shared_ptr const ©_thread,
+- std::string const &schema);
++ std::string const &schema, bool cluster_during_import);
+
+ table_t(table_t const &other,
+ std::shared_ptr const ©_thread);
+@@ -77,6 +77,7 @@ private:
+ std::string m_srid;
+ bool m_append;
+ hstore_column m_hstore_mode;
++ bool m_cluster_during_import;
+ columns_t m_columns;
+ hstores_t m_hstore_columns;
+ std::string m_table_space;
diff --git a/src/tools/clustered_write_bench/osm2pgsql_diff.sql b/src/tools/clustered_write_bench/osm2pgsql_diff.sql
new file mode 100644
index 0000000000000..f28a7ac17ba25
--- /dev/null
+++ b/src/tools/clustered_write_bench/osm2pgsql_diff.sql
@@ -0,0 +1,667 @@
+\set ON_ERROR_STOP on
+
+\if :{?scale}
+\else
+\set scale 1
+\endif
+
+\if :{?use_brin}
+\else
+\set use_brin false
+\endif
+
+\if :{?single_key_cluster}
+\else
+\set single_key_cluster false
+\endif
+
+\if :{?text_cluster_key}
+\else
+\set text_cluster_key false
+\endif
+
+\if :{?hot_tile_fraction}
+\else
+\set hot_tile_fraction 0
+\endif
+
+\if :{?hot_tile_count}
+\else
+\set hot_tile_count 1
+\endif
+
+\if :{?hot_update_fraction}
+\else
+\set hot_update_fraction 0
+\endif
+
+\if :{?update_payload_repeat}
+\else
+\set update_payload_repeat 64
+\endif
+
+\if :{?heap_fillfactor}
+\else
+\set heap_fillfactor 90
+\endif
+
+\if :{?order_diff_by_cluster_key}
+\else
+\set order_diff_by_cluster_key false
+\endif
+
+\if :{?copy_diff_from_file}
+\else
+\set copy_diff_from_file false
+\endif
+
+\if :{?updates_before_inserts}
+\else
+\set updates_before_inserts false
+\endif
+
+\if :{?diff_copy_path}
+\else
+\set diff_copy_path '/tmp/clustered_write_diff_inserts.tsv'
+\endif
+
+\timing on
+
+drop table if exists clustered_write_osm_diff cascade;
+drop table if exists clustered_write_osm_diff_on cascade;
+drop table if exists clustered_write_osm_diff_off cascade;
+
+create temp table clustered_write_settings as
+select (200000 * :scale)::int as base_rows,
+ (20000 * :scale)::int as insert_rows,
+ (20000 * :scale)::int as update_rows,
+ (4096 * :scale)::int as tile_count,
+ (:'use_brin')::boolean as brin_enabled,
+ (:'single_key_cluster')::boolean as single_key_cluster,
+ (:'text_cluster_key')::boolean as text_cluster_key,
+ (:heap_fillfactor)::int as heap_fillfactor,
+ (:hot_tile_fraction)::numeric as hot_tile_fraction,
+ (:hot_update_fraction)::numeric as hot_update_fraction,
+ (:update_payload_repeat)::int as update_payload_repeat,
+ greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count,
+ (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key,
+ (:'copy_diff_from_file')::boolean as copy_diff_from_file,
+ (:'updates_before_inserts')::boolean as updates_before_inserts;
+
+create unlogged table clustered_write_osm_diff_on
+(
+ osm_id bigint primary key,
+ tile_id int not null,
+ cluster_key text collate "C" generated always as ('g' || lpad(tile_id::text, 8, '0')) stored,
+ version int not null,
+ payload text not null
+) with (fillfactor = :heap_fillfactor);
+
+insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload)
+select g,
+ ((g - 1) % s.tile_count) + 1,
+ 1,
+ repeat('base', 16)
+from clustered_write_settings as s,
+ generate_series(1, s.base_rows) as g;
+
+\if :text_cluster_key
+create index clustered_write_osm_diff_tile_idx
+ on clustered_write_osm_diff_on (cluster_key);
+\else
+ \if :single_key_cluster
+create index clustered_write_osm_diff_tile_idx
+ on clustered_write_osm_diff_on (tile_id);
+ \else
+create index clustered_write_osm_diff_tile_idx
+ on clustered_write_osm_diff_on (tile_id, osm_id);
+ \endif
+\endif
+
+\if :use_brin
+\if :text_cluster_key
+create index clustered_write_osm_diff_tile_brin_idx
+ on clustered_write_osm_diff_on using brin (cluster_key) with (pages_per_range = 32);
+\else
+create index clustered_write_osm_diff_tile_brin_idx
+ on clustered_write_osm_diff_on using brin (tile_id) with (pages_per_range = 32);
+\endif
+\endif
+
+cluster clustered_write_osm_diff_on using clustered_write_osm_diff_tile_idx;
+analyze clustered_write_osm_diff_on;
+
+create unlogged table clustered_write_osm_diff_off
+(
+ osm_id bigint primary key,
+ tile_id int not null,
+ cluster_key text collate "C" generated always as ('g' || lpad(tile_id::text, 8, '0')) stored,
+ version int not null,
+ payload text not null
+) with (fillfactor = :heap_fillfactor);
+
+insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload)
+select osm_id,
+ tile_id,
+ version,
+ payload
+from clustered_write_osm_diff_on;
+
+\if :text_cluster_key
+create index clustered_write_osm_diff_off_tile_idx
+ on clustered_write_osm_diff_off (cluster_key);
+\else
+ \if :single_key_cluster
+create index clustered_write_osm_diff_off_tile_idx
+ on clustered_write_osm_diff_off (tile_id);
+ \else
+create index clustered_write_osm_diff_off_tile_idx
+ on clustered_write_osm_diff_off (tile_id, osm_id);
+ \endif
+\endif
+
+\if :use_brin
+\if :text_cluster_key
+create index clustered_write_osm_diff_off_tile_brin_idx
+ on clustered_write_osm_diff_off using brin (cluster_key) with (pages_per_range = 32);
+\else
+create index clustered_write_osm_diff_off_tile_brin_idx
+ on clustered_write_osm_diff_off using brin (tile_id) with (pages_per_range = 32);
+\endif
+\endif
+
+cluster clustered_write_osm_diff_off using clustered_write_osm_diff_off_tile_idx;
+alter table clustered_write_osm_diff_off set without cluster;
+analyze clustered_write_osm_diff_off;
+
+-- Start the diff phase from a fresh relcache. The control table was just
+-- clustered and then marked SET WITHOUT CLUSTER; reconnecting ensures the
+-- following writes observe the cleared catalog bit rather than any relation
+-- state cached during setup.
+select current_database() as clustered_write_database \gset
+\connect :clustered_write_database
+\timing on
+
+create function pg_temp.tid_block(tid)
+returns bigint
+language sql
+immutable
+parallel safe
+as $$
+ select split_part(trim(both '()' from $1::text), ',', 1)::bigint;
+$$;
+
+create temp table clustered_write_settings as
+select (200000 * :scale)::int as base_rows,
+ (20000 * :scale)::int as insert_rows,
+ (20000 * :scale)::int as update_rows,
+ (4096 * :scale)::int as tile_count,
+ (:'use_brin')::boolean as brin_enabled,
+ (:'single_key_cluster')::boolean as single_key_cluster,
+ (:'text_cluster_key')::boolean as text_cluster_key,
+ (:heap_fillfactor)::int as heap_fillfactor,
+ (:hot_tile_fraction)::numeric as hot_tile_fraction,
+ (:hot_update_fraction)::numeric as hot_update_fraction,
+ (:update_payload_repeat)::int as update_payload_repeat,
+ greatest(1, least((4096 * :scale)::int, (:hot_tile_count)::int)) as hot_tile_count,
+ (:'order_diff_by_cluster_key')::boolean as order_diff_by_cluster_key,
+ (:'copy_diff_from_file')::boolean as copy_diff_from_file,
+ (:'updates_before_inserts')::boolean as updates_before_inserts;
+
+create temp table clustered_write_step_timings
+(
+ step text primary key,
+ started_at timestamptz not null,
+ finished_at timestamptz
+);
+
+create temp table clustered_write_base_ranges as
+select 'clustered_write'::text as variant,
+ tile_id,
+ min(pg_temp.tid_block(ctid)) as min_block,
+ max(pg_temp.tid_block(ctid)) as max_block
+from clustered_write_osm_diff_on
+group by tile_id
+
+union all
+
+select 'without_cluster_metadata'::text as variant,
+ tile_id,
+ min(pg_temp.tid_block(ctid)) as min_block,
+ max(pg_temp.tid_block(ctid)) as max_block
+from clustered_write_osm_diff_off
+group by tile_id;
+
+create temp table clustered_write_diff_inserts as
+select s.base_rows + g as osm_id,
+ (g <= (s.insert_rows * s.hot_tile_fraction)::int) as is_hot_insert,
+ case
+ when g <= (s.insert_rows * s.hot_tile_fraction)::int then ((g - 1) % s.hot_tile_count) + 1
+ else (((g::bigint * 1103515245 + 12345) % s.tile_count) + 1)::int
+ end as tile_id
+from clustered_write_settings as s,
+ generate_series(1, s.insert_rows) as g;
+
+create temp table clustered_write_diff_updates as
+with hot_candidates as
+(
+ select g as osm_id,
+ row_number() over (order by (g::bigint * 2654435761) % s.base_rows) as candidate_number
+ from clustered_write_settings as s,
+ generate_series(1, s.base_rows) as g
+ where ((g - 1) % s.tile_count) + 1 <= s.hot_tile_count
+),
+hot_updates as
+(
+ select h.osm_id,
+ true as is_hot_update
+ from hot_candidates as h
+ join clustered_write_settings as s on true
+ where h.candidate_number <= (s.update_rows * s.hot_update_fraction)::int
+),
+rest_candidates as
+(
+ select g as osm_id,
+ row_number() over (order by (g::bigint * 2654435761) % s.base_rows) as candidate_number
+ from clustered_write_settings as s,
+ generate_series(1, s.base_rows) as g
+ where s.hot_update_fraction = 0
+ or ((g - 1) % s.tile_count) + 1 > s.hot_tile_count
+),
+rest_updates as
+(
+ select r.osm_id,
+ false as is_hot_update
+ from rest_candidates as r
+ join clustered_write_settings as s on true
+ where r.candidate_number <= s.update_rows - (select count(*) from hot_updates)
+)
+select osm_id,
+ is_hot_update
+from hot_updates
+
+union all
+
+select osm_id,
+ is_hot_update
+from rest_updates;
+
+\if :copy_diff_from_file
+\if :order_diff_by_cluster_key
+copy (
+ select d.osm_id,
+ d.tile_id,
+ 1 as version,
+ repeat('insert', 16) as payload
+ from clustered_write_diff_inserts as d
+ order by d.tile_id, d.osm_id
+) to :'diff_copy_path' with (format csv, delimiter E'\t');
+\else
+copy (
+ select d.osm_id,
+ d.tile_id,
+ 1 as version,
+ repeat('insert', 16) as payload
+ from clustered_write_diff_inserts as d
+) to :'diff_copy_path' with (format csv, delimiter E'\t');
+\endif
+\endif
+
+\if :updates_before_inserts
+insert into clustered_write_step_timings
+values ('clustered_write_update', clock_timestamp(), null);
+
+update clustered_write_osm_diff_on as o
+set version = o.version + 1,
+ payload = repeat('updated-row', s.update_payload_repeat)
+from clustered_write_diff_updates as u
+join clustered_write_settings as s on true
+where o.osm_id = u.osm_id;
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'clustered_write_update';
+
+insert into clustered_write_step_timings
+values ('without_cluster_metadata_update', clock_timestamp(), null);
+
+update clustered_write_osm_diff_off as o
+set version = o.version + 1,
+ payload = repeat('updated-row', s.update_payload_repeat)
+from clustered_write_diff_updates as u
+join clustered_write_settings as s on true
+where o.osm_id = u.osm_id;
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'without_cluster_metadata_update';
+\endif
+
+insert into clustered_write_step_timings
+values ('clustered_write_insert', clock_timestamp(), null);
+
+\if :copy_diff_from_file
+copy clustered_write_osm_diff_on (osm_id, tile_id, version, payload)
+from :'diff_copy_path' with (format csv, delimiter E'\t');
+\else
+\if :order_diff_by_cluster_key
+insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload)
+select d.osm_id,
+ d.tile_id,
+ 1,
+ repeat('insert', 16)
+from clustered_write_diff_inserts as d
+order by d.tile_id, d.osm_id;
+\else
+insert into clustered_write_osm_diff_on (osm_id, tile_id, version, payload)
+select d.osm_id,
+ d.tile_id,
+ 1,
+ repeat('insert', 16)
+from clustered_write_diff_inserts as d;
+\endif
+\endif
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'clustered_write_insert';
+
+insert into clustered_write_step_timings
+values ('without_cluster_metadata_insert', clock_timestamp(), null);
+
+\if :copy_diff_from_file
+copy clustered_write_osm_diff_off (osm_id, tile_id, version, payload)
+from :'diff_copy_path' with (format csv, delimiter E'\t');
+\else
+\if :order_diff_by_cluster_key
+insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload)
+select d.osm_id,
+ d.tile_id,
+ 1,
+ repeat('insert', 16)
+from clustered_write_diff_inserts as d
+order by d.tile_id, d.osm_id;
+\else
+insert into clustered_write_osm_diff_off (osm_id, tile_id, version, payload)
+select d.osm_id,
+ d.tile_id,
+ 1,
+ repeat('insert', 16)
+from clustered_write_diff_inserts as d;
+\endif
+\endif
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'without_cluster_metadata_insert';
+
+\if :updates_before_inserts
+\else
+insert into clustered_write_step_timings
+values ('clustered_write_update', clock_timestamp(), null);
+
+update clustered_write_osm_diff_on as o
+set version = o.version + 1,
+ payload = repeat('updated-row', s.update_payload_repeat)
+from clustered_write_diff_updates as u
+join clustered_write_settings as s on true
+where o.osm_id = u.osm_id;
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'clustered_write_update';
+
+insert into clustered_write_step_timings
+values ('without_cluster_metadata_update', clock_timestamp(), null);
+
+update clustered_write_osm_diff_off as o
+set version = o.version + 1,
+ payload = repeat('updated-row', s.update_payload_repeat)
+from clustered_write_diff_updates as u
+join clustered_write_settings as s on true
+where o.osm_id = u.osm_id;
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'without_cluster_metadata_update';
+\endif
+
+analyze clustered_write_osm_diff_on;
+analyze clustered_write_osm_diff_off;
+
+insert into clustered_write_step_timings
+values ('clustered_write_read_hot', clock_timestamp(), null);
+
+\if :text_cluster_key
+create temp table clustered_write_read_hot_on as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_on as o
+join clustered_write_settings as s on true
+where o.cluster_key in (
+ select 'g' || lpad(g::text, 8, '0')
+ from generate_series(1, s.hot_tile_count) as g
+);
+\else
+create temp table clustered_write_read_hot_on as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_on as o
+join clustered_write_settings as s on true
+where o.tile_id between 1 and s.hot_tile_count;
+\endif
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'clustered_write_read_hot';
+
+insert into clustered_write_step_timings
+values ('without_cluster_metadata_read_hot', clock_timestamp(), null);
+
+\if :text_cluster_key
+create temp table clustered_write_read_hot_off as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_off as o
+join clustered_write_settings as s on true
+where o.cluster_key in (
+ select 'g' || lpad(g::text, 8, '0')
+ from generate_series(1, s.hot_tile_count) as g
+);
+\else
+create temp table clustered_write_read_hot_off as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_off as o
+join clustered_write_settings as s on true
+where o.tile_id between 1 and s.hot_tile_count;
+\endif
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'without_cluster_metadata_read_hot';
+
+insert into clustered_write_step_timings
+values ('clustered_write_read_updated_hot', clock_timestamp(), null);
+
+\if :text_cluster_key
+create temp table clustered_write_read_updated_hot_on as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_on as o
+join clustered_write_settings as s on true
+where o.version > 1
+ and o.cluster_key in (
+ select 'g' || lpad(g::text, 8, '0')
+ from generate_series(1, s.hot_tile_count) as g
+);
+\else
+create temp table clustered_write_read_updated_hot_on as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_on as o
+join clustered_write_settings as s on true
+where o.version > 1
+ and o.tile_id between 1 and s.hot_tile_count;
+\endif
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'clustered_write_read_updated_hot';
+
+insert into clustered_write_step_timings
+values ('without_cluster_metadata_read_updated_hot', clock_timestamp(), null);
+
+\if :text_cluster_key
+create temp table clustered_write_read_updated_hot_off as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_off as o
+join clustered_write_settings as s on true
+where o.version > 1
+ and o.cluster_key in (
+ select 'g' || lpad(g::text, 8, '0')
+ from generate_series(1, s.hot_tile_count) as g
+);
+\else
+create temp table clustered_write_read_updated_hot_off as
+select count(*) as rows_read,
+ sum(length(o.payload)) as payload_bytes
+from clustered_write_osm_diff_off as o
+join clustered_write_settings as s on true
+where o.version > 1
+ and o.tile_id between 1 and s.hot_tile_count;
+\endif
+
+update clustered_write_step_timings
+set finished_at = clock_timestamp()
+where step = 'without_cluster_metadata_read_updated_hot';
+
+select s.brin_enabled,
+ s.text_cluster_key,
+ s.heap_fillfactor,
+ s.copy_diff_from_file,
+ t.step,
+ round((extract(epoch from t.finished_at - t.started_at) * 1000)::numeric, 2) as elapsed_ms
+from clustered_write_step_timings as t
+join clustered_write_settings as s on true
+order by t.step;
+
+with measured as
+(
+ select 'clustered_write'::text as variant,
+ case
+ when s.hot_tile_fraction > 0 and d.is_hot_insert then 'insert_hot'
+ when s.hot_tile_fraction > 0 then 'insert_rest'
+ else 'insert'
+ end as diff_kind,
+ pg_temp.tid_block(o.ctid) as heap_block,
+ r.min_block,
+ r.max_block
+ from clustered_write_osm_diff_on as o
+ join clustered_write_settings as s on true
+ join clustered_write_diff_inserts as d
+ on d.osm_id = o.osm_id
+ join clustered_write_base_ranges as r
+ on r.variant = 'clustered_write'
+ and r.tile_id = o.tile_id
+ where o.osm_id > s.base_rows
+
+ union all
+
+ select 'clustered_write'::text as variant,
+ case
+ when s.hot_update_fraction > 0 and u.is_hot_update then 'update_hot'
+ when s.hot_update_fraction > 0 then 'update_rest'
+ else 'update'
+ end as diff_kind,
+ pg_temp.tid_block(o.ctid) as heap_block,
+ r.min_block,
+ r.max_block
+ from clustered_write_osm_diff_on as o
+ join clustered_write_settings as s on true
+ join clustered_write_diff_updates as u
+ on u.osm_id = o.osm_id
+ join clustered_write_base_ranges as r
+ on r.variant = 'clustered_write'
+ and r.tile_id = o.tile_id
+ where o.osm_id <= s.base_rows
+ and o.version > 1
+
+ union all
+
+ select 'without_cluster_metadata'::text as variant,
+ case
+ when s.hot_tile_fraction > 0 and d.is_hot_insert then 'insert_hot'
+ when s.hot_tile_fraction > 0 then 'insert_rest'
+ else 'insert'
+ end as diff_kind,
+ pg_temp.tid_block(o.ctid) as heap_block,
+ r.min_block,
+ r.max_block
+ from clustered_write_osm_diff_off as o
+ join clustered_write_settings as s on true
+ join clustered_write_diff_inserts as d
+ on d.osm_id = o.osm_id
+ join clustered_write_base_ranges as r
+ on r.variant = 'without_cluster_metadata'
+ and r.tile_id = o.tile_id
+ where o.osm_id > s.base_rows
+
+ union all
+
+ select 'without_cluster_metadata'::text as variant,
+ case
+ when s.hot_update_fraction > 0 and u.is_hot_update then 'update_hot'
+ when s.hot_update_fraction > 0 then 'update_rest'
+ else 'update'
+ end as diff_kind,
+ pg_temp.tid_block(o.ctid) as heap_block,
+ r.min_block,
+ r.max_block
+ from clustered_write_osm_diff_off as o
+ join clustered_write_settings as s on true
+ join clustered_write_diff_updates as u
+ on u.osm_id = o.osm_id
+ join clustered_write_base_ranges as r
+ on r.variant = 'without_cluster_metadata'
+ and r.tile_id = o.tile_id
+ where o.osm_id <= s.base_rows
+ and o.version > 1
+),
+drift as
+(
+ select variant,
+ diff_kind,
+ heap_block,
+ case
+ when heap_block < min_block then min_block - heap_block
+ when heap_block > max_block then heap_block - max_block
+ else 0
+ end as block_drift
+ from measured
+)
+select s.brin_enabled,
+ s.text_cluster_key,
+ s.heap_fillfactor,
+ s.copy_diff_from_file,
+ variant,
+ diff_kind,
+ count(*) as rows_measured,
+ count(distinct heap_block) as heap_blocks_touched,
+ max(heap_block) - min(heap_block) + 1 as heap_block_span,
+ coalesce(
+ max(heap_block) filter (where block_drift > 0) -
+ min(heap_block) filter (where block_drift > 0) + 1,
+ 0
+ ) as outside_base_heap_block_span,
+ round(100.0 * avg((block_drift = 0)::int), 2) as pct_inside_base_range,
+ round(avg(block_drift)::numeric, 2) as avg_block_drift,
+ round((percentile_cont(0.95) within group (order by block_drift))::numeric, 2) as p95_block_drift,
+ max(block_drift) as max_block_drift
+from drift
+join clustered_write_settings as s on true
+group by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor,
+ s.copy_diff_from_file, variant, diff_kind
+order by s.brin_enabled, s.text_cluster_key, s.heap_fillfactor,
+ s.copy_diff_from_file, variant, diff_kind;
diff --git a/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql
new file mode 100644
index 0000000000000..2b8355deeef5d
--- /dev/null
+++ b/src/tools/clustered_write_bench/osm2pgsql_georgia_read.sql
@@ -0,0 +1,135 @@
+\set ON_ERROR_STOP on
+\timing on
+
+create extension if not exists postgis;
+
+drop table if exists clustered_write_read_windows;
+
+create temp table clustered_write_read_windows
+(
+ name text primary key,
+ geom geometry(Polygon, 3857) not null
+);
+
+insert into clustered_write_read_windows
+values
+ ('tbilisi_core', ST_Transform(ST_MakeEnvelope(44.75, 41.67, 44.86, 41.75, 4326), 3857)),
+ ('batumi_core', ST_Transform(ST_MakeEnvelope(41.59, 41.61, 41.68, 41.67, 4326), 3857)),
+ ('kutaisi_core', ST_Transform(ST_MakeEnvelope(42.62, 42.23, 42.75, 42.31, 4326), 3857)),
+ ('georgia_west_east', ST_Transform(ST_MakeEnvelope(39.9, 41.0, 46.8, 43.7, 4326), 3857));
+
+analyze clustered_write_read_windows;
+
+\echo 'bbox count reads'
+
+explain (analyze, buffers, timing, summary)
+select w.name, count(*) as rows_seen
+from clustered_write_read_windows as w
+join planet_osm_point as p
+ on p.way && w.geom
+group by w.name
+order by w.name;
+
+explain (analyze, buffers, timing, summary)
+select w.name, count(*) as rows_seen
+from clustered_write_read_windows as w
+join planet_osm_line as l
+ on l.way && w.geom
+group by w.name
+order by w.name;
+
+explain (analyze, buffers, timing, summary)
+select w.name, count(*) as rows_seen
+from clustered_write_read_windows as w
+join planet_osm_polygon as p
+ on p.way && w.geom
+group by w.name
+order by w.name;
+
+\echo 'exact spatial reads'
+
+explain (analyze, buffers, timing, summary)
+select w.name, count(*) as rows_seen
+from clustered_write_read_windows as w
+join planet_osm_roads as r
+ on r.way && w.geom
+ and ST_Intersects(r.way, w.geom)
+group by w.name
+order by w.name;
+
+explain (analyze, buffers, timing, summary)
+select w.name, count(*) as rows_seen
+from clustered_write_read_windows as w
+join planet_osm_polygon as p
+ on p.way && w.geom
+ and ST_Intersects(p.way, w.geom)
+group by w.name
+order by w.name;
+
+\echo 'heap locality summary'
+
+with table_blocks as
+(
+ select 'point'::text as table_name,
+ (ctid::text::point)[0]::bigint as block_id,
+ ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7) as geohash
+ from planet_osm_point
+ where way is not null
+
+ union all
+
+ select 'line'::text as table_name,
+ (ctid::text::point)[0]::bigint as block_id,
+ ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7) as geohash
+ from planet_osm_line
+ where way is not null
+
+ union all
+
+ select 'polygon'::text as table_name,
+ (ctid::text::point)[0]::bigint as block_id,
+ ST_GeoHash(ST_Transform(ST_Envelope(way), 4326), 7) as geohash
+ from planet_osm_polygon
+ where way is not null
+),
+geohash_blocks as
+(
+ select table_name,
+ geohash,
+ count(*) as row_count,
+ count(distinct block_id) as block_count,
+ max(block_id) - min(block_id) as block_span
+ from table_blocks
+ group by table_name, geohash
+ having count(*) >= 10
+)
+select table_name,
+ count(*) as geohashes,
+ percentile_cont(0.50) within group (order by block_count) as p50_blocks,
+ percentile_cont(0.95) within group (order by block_count) as p95_blocks,
+ percentile_cont(0.50) within group (order by block_span) as p50_span,
+ percentile_cont(0.95) within group (order by block_span) as p95_span
+from geohash_blocks
+group by table_name
+order by table_name;
+
+\echo 'clustered import diagnostics'
+
+select relname, reloptions
+from pg_class
+where relname in ('planet_osm_point',
+ 'planet_osm_line',
+ 'planet_osm_polygon',
+ 'planet_osm_roads')
+order by relname;
+
+analyze planet_osm_point;
+analyze planet_osm_line;
+analyze planet_osm_polygon;
+analyze planet_osm_roads;
+
+select tablename, correlation
+from pg_stats
+where schemaname = 'public'
+ and attname = 'osm2pgsql_cluster_key'
+order by tablename;
diff --git a/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh
new file mode 100755
index 0000000000000..5c44c8f091101
--- /dev/null
+++ b/src/tools/clustered_write_bench/run_osm2pgsql_georgia_bench.sh
@@ -0,0 +1,446 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+WORKDIR="${WORKDIR:-$HOME/tmp/clustered-write-osm2pgsql}"
+GEORGIA_URL="${GEORGIA_URL:-https://download.geofabrik.de/europe/georgia-latest.osm.pbf}"
+PLANET_DAY_STATE_URL="${PLANET_DAY_STATE_URL:-https://planet.openstreetmap.org/replication/day/state.txt}"
+OSC_URL="${OSC_URL:-}"
+
+PATCHED_PG_BIN="${PATCHED_PG_BIN:-$ROOT_DIR/tmp_install/usr/local/pgsql/bin}"
+BASELINE_PG_BIN="${BASELINE_PG_BIN:-/usr/lib/postgresql/18/bin}"
+STOCK_OSM2PGSQL="${STOCK_OSM2PGSQL:-osm2pgsql}"
+CLUSTERED_OSM2PGSQL="${CLUSTERED_OSM2PGSQL:-/tmp/osm2pgsql-clustered-import/build/osm2pgsql}"
+OSM2PGSQL_STYLE="${OSM2PGSQL_STYLE:-/tmp/osm2pgsql-clustered-import/default.style}"
+OSMIUM="${OSMIUM:-osmium}"
+
+POSTGIS_SHARE="${POSTGIS_SHARE:-/usr/share/postgresql/18/extension}"
+POSTGIS_LIB="${POSTGIS_LIB:-/usr/lib/postgresql/18/lib}"
+
+PG_PORT_BASE="${PG_PORT_BASE:-55432}"
+OSM2PGSQL_CACHE_MB="${OSM2PGSQL_CACHE_MB:-2048}"
+OSM2PGSQL_PROCS="${OSM2PGSQL_PROCS:-4}"
+PG_WORK_MEM="${PG_WORK_MEM:-64MB}"
+BENCH_VARIANTS="${BENCH_VARIANTS:-baseline_stock,patched_stock,patched_clustered_import}"
+READ_REPEATS="${READ_REPEATS:-1}"
+COMPRESS_LOGS="${COMPRESS_LOGS:-true}"
+KEEP_PGDATA="${KEEP_PGDATA:-false}"
+PG_LOG_MIN_DURATION_MS="${PG_LOG_MIN_DURATION_MS:-}"
+
+mkdir -p "$WORKDIR"/{data,logs,pgdata}
+
+log()
+{
+ printf '[%s] %s\n' "$(date -Is)" "$*" >&2
+}
+
+require_executable()
+{
+ if ! command -v "$1" >/dev/null 2>&1; then
+ printf 'missing executable: %s\n' "$1" >&2
+ exit 1
+ fi
+}
+
+require_path_executable()
+{
+ if [[ ! -x "$1" ]]; then
+ printf 'missing executable: %s\n' "$1" >&2
+ exit 1
+ fi
+}
+
+variant_enabled()
+{
+ local wanted="$1"
+
+ case ",$BENCH_VARIANTS," in
+ *,"$wanted",*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+resolve_executable()
+{
+ local executable="$1"
+
+ if command -v "$executable" >/dev/null 2>&1; then
+ command -v "$executable"
+ elif [[ -x "$executable" ]]; then
+ printf '%s\n' "$executable"
+ else
+ printf 'missing executable: %s\n' "$executable" >&2
+ exit 1
+ fi
+}
+
+download_if_missing()
+{
+ local url="$1"
+ local out="$2"
+
+ if [[ -s "$out" ]]; then
+ log "using existing $out"
+ return
+ fi
+
+ log "downloading $url"
+ curl --fail --location --retry 3 --output "$out".tmp "$url"
+ mv "$out".tmp "$out"
+}
+
+osc_path_for_sequence()
+{
+ local seq="$1"
+ printf '%09d' "$seq" | sed -E 's#(...)(...)(...)#\1/\2/\3.osc.gz#'
+}
+
+resolve_daily_diff_url()
+{
+ if [[ -n "$OSC_URL" ]]; then
+ printf '%s\n' "$OSC_URL"
+ return
+ fi
+
+ local state_file="$WORKDIR/data/day-state.txt"
+ curl --fail --location --retry 3 --output "$state_file".tmp "$PLANET_DAY_STATE_URL"
+ mv "$state_file".tmp "$state_file"
+
+ local sequence
+ sequence="$(awk -F= '$1 == "sequenceNumber" {print $2}' "$state_file")"
+ if [[ -z "$sequence" ]]; then
+ printf 'could not read sequenceNumber from %s\n' "$state_file" >&2
+ exit 1
+ fi
+
+ printf 'https://planet.openstreetmap.org/replication/day/%s\n' "$(osc_path_for_sequence "$sequence")"
+}
+
+simplify_daily_diff()
+{
+ local in_file="$1"
+ local out_file="${in_file%.osc.gz}-simplified.osc.gz"
+ local tmp_file="$out_file.tmp.osc.gz"
+
+ if [[ -s "$out_file" && "$out_file" -nt "$in_file" ]]; then
+ log "using existing simplified diff $out_file"
+ printf '%s\n' "$out_file"
+ return
+ fi
+
+ log "simplifying daily diff for osm2pgsql append"
+ rm -f "$tmp_file"
+ if ! "$OSMIUM" merge-changes -s -O --output-format=osc.gz -o "$tmp_file" "$in_file"; then
+ rm -f "$tmp_file"
+ return 1
+ fi
+ mv "$tmp_file" "$out_file"
+ printf '%s\n' "$out_file"
+}
+
+copy_postgis_into_install()
+{
+ local pg_bin="$1"
+ local pg_home
+ local pg_extension_dir
+ local pg_lib_dir
+ pg_home="$(cd "$pg_bin/.." && pwd)"
+
+ if [[ "$pg_bin" == /usr/lib/postgresql/* ]]; then
+ return
+ fi
+
+ mkdir -p "$pg_home/share/extension" "$pg_home/lib"
+ pg_extension_dir="$(cd "$pg_home/share/extension" && pwd -P)"
+ pg_lib_dir="$(cd "$pg_home/lib" && pwd -P)"
+
+ if [[ "$(cd "$POSTGIS_SHARE" && pwd -P)" != "$pg_extension_dir" ]]; then
+ rm -f "$pg_home/share/extension"/postgis*
+ rm -f "$pg_home/share/extension"/address_standardizer*
+ cp -a "$POSTGIS_SHARE"/postgis* "$pg_home/share/extension/"
+ cp -a "$POSTGIS_SHARE"/sql/postgis* "$pg_home/share/extension/" 2>/dev/null || true
+ cp -a "$POSTGIS_SHARE"/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true
+ cp -a "$POSTGIS_SHARE"/sql/address_standardizer* "$pg_home/share/extension/" 2>/dev/null || true
+ fi
+ if [[ "$(cd "$POSTGIS_LIB" && pwd -P)" != "$pg_lib_dir" ]]; then
+ rm -f "$pg_home/lib"/postgis-*.so
+ rm -f "$pg_home/lib"/postgis_raster-*.so
+ cp -a "$POSTGIS_LIB"/postgis-*.so "$pg_home/lib/"
+ cp -a "$POSTGIS_LIB"/postgis_raster-*.so "$pg_home/lib/" 2>/dev/null || true
+ fi
+}
+
+start_server()
+{
+ local name="$1"
+ local pg_bin="$2"
+ local port="$3"
+ local data_dir="$WORKDIR/pgdata/$name"
+ local log_file="$WORKDIR/logs/$name-postgres.log"
+ local socket_dir="${TMPDIR:-/tmp}/clustered-write-osm2pgsql-$port"
+
+ rm -rf "$data_dir"
+ rm -rf "$socket_dir"
+ mkdir -p "$socket_dir"
+ "$pg_bin/initdb" -D "$data_dir" >"$WORKDIR/logs/$name-initdb.log"
+ cat >>"$data_dir/postgresql.conf" <>"$data_dir/postgresql.conf" </dev/null 2>&1 || true
+ if [[ -n "$port" ]]; then
+ rm -rf "${TMPDIR:-/tmp}/clustered-write-osm2pgsql-$port"
+ fi
+}
+
+compress_variant_logs()
+{
+ local name="$1"
+
+ if [[ "$COMPRESS_LOGS" != "true" ]]; then
+ return
+ fi
+
+ find "$WORKDIR/logs" -maxdepth 1 -type f -name "$name-*" \
+ ! -name '*.gz' -exec gzip -f {} +
+}
+
+cleanup_variant()
+{
+ local pg_bin="$1"
+ local name="$2"
+ local port="$3"
+ local data_dir="$WORKDIR/pgdata/$name"
+
+ stop_server "$pg_bin" "$name" "$port"
+ compress_variant_logs "$name"
+
+ if [[ "$KEEP_PGDATA" != "true" ]]; then
+ rm -rf "$data_dir"
+ fi
+}
+
+run_psql()
+{
+ local pg_bin="$1"
+ local port="$2"
+ local db="$3"
+ shift 3
+
+ "$pg_bin/psql" -v ON_ERROR_STOP=1 -h 127.0.0.1 -p "$port" -d "$db" "$@"
+}
+
+run_read_benchmarks()
+{
+ local name="$1"
+ local pg_bin="$2"
+ local port="$3"
+ local read_run
+ local out_prefix
+
+ if (( READ_REPEATS <= 0 )); then
+ log "skipping read benchmark for $name (READ_REPEATS=$READ_REPEATS)"
+ return
+ fi
+
+ for read_run in $(seq 1 "$READ_REPEATS"); do
+ if [[ "$READ_REPEATS" == "1" ]]; then
+ out_prefix="$WORKDIR/logs/$name-read"
+ else
+ out_prefix="$WORKDIR/logs/$name-read-run-$read_run"
+ fi
+
+ log "read benchmark for $name ($read_run/$READ_REPEATS)"
+ run_psql "$pg_bin" "$port" osm \
+ -f "$BENCH_DIR/osm2pgsql_georgia_read.sql" \
+ >"$out_prefix.sqlout" \
+ 2>"$out_prefix.sqlerr"
+ done
+}
+
+run_variant()
+{
+ local name="$1"
+ local pg_bin="$2"
+ local port="$3"
+ local osm2pgsql="$4"
+ local mode="$5"
+ local pbf="$6"
+ local osc="$7"
+
+ require_path_executable "$pg_bin/initdb"
+ require_path_executable "$pg_bin/pg_ctl"
+ require_path_executable "$pg_bin/psql"
+ require_path_executable "$osm2pgsql"
+ copy_postgis_into_install "$pg_bin"
+
+ log "starting PostgreSQL for $name on port $port"
+ start_server "$name" "$pg_bin" "$port"
+ trap "cleanup_variant '$pg_bin' '$name' '$port'" EXIT
+
+ "$pg_bin/createdb" -h 127.0.0.1 -p "$port" osm
+ run_psql "$pg_bin" "$port" osm -c 'create extension postgis; create extension hstore;'
+
+ local common_args=(
+ --slim
+ --database osm
+ --host 127.0.0.1
+ --port "$port"
+ --style "$OSM2PGSQL_STYLE"
+ --cache "$OSM2PGSQL_CACHE_MB"
+ --number-processes "$OSM2PGSQL_PROCS"
+ )
+
+ local create_args=(--create)
+ if [[ "$mode" == "clustered_import" ]]; then
+ create_args+=(--cluster-during-import)
+ fi
+
+ log "initial import for $name"
+ /usr/bin/time -v -o "$WORKDIR/logs/$name-create.time" \
+ "$osm2pgsql" "${common_args[@]}" "${create_args[@]}" "$pbf" \
+ >"$WORKDIR/logs/$name-create.stdout" \
+ 2>"$WORKDIR/logs/$name-create.stderr"
+
+ local append_args=(--append)
+ if [[ "$mode" == "clustered_import" ]]; then
+ append_args+=(--cluster-during-import)
+ fi
+
+ log "daily diff append for $name"
+ /usr/bin/time -v -o "$WORKDIR/logs/$name-append.time" \
+ "$osm2pgsql" "${common_args[@]}" "${append_args[@]}" "$osc" \
+ >"$WORKDIR/logs/$name-append.stdout" \
+ 2>"$WORKDIR/logs/$name-append.stderr"
+
+ run_read_benchmarks "$name" "$pg_bin" "$port"
+
+ run_psql "$pg_bin" "$port" osm \
+ -c "select current_database() as db, pg_size_pretty(pg_database_size(current_database())) as database_size;" \
+ >"$WORKDIR/logs/$name-size.sqlout"
+
+ cleanup_variant "$pg_bin" "$name" "$port"
+ trap - EXIT
+}
+
+write_run_environment()
+{
+ {
+ printf 'date: '
+ date -Is
+ printf 'root_dir: %s\n' "$ROOT_DIR"
+ printf 'bench_dir: %s\n' "$BENCH_DIR"
+ if git -C "$ROOT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ printf 'git_head: %s\n' "$(git -C "$ROOT_DIR" rev-parse HEAD)"
+ printf 'git_branch: %s\n' "$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)"
+ fi
+ printf 'workdir: %s\n' "$WORKDIR"
+ printf 'georgia_url: %s\n' "$GEORGIA_URL"
+ printf 'planet_day_state_url: %s\n' "$PLANET_DAY_STATE_URL"
+ printf 'osc_url: %s\n' "$OSC_URL"
+ printf 'patched_pg_bin: %s\n' "$PATCHED_PG_BIN"
+ printf 'baseline_pg_bin: %s\n' "$BASELINE_PG_BIN"
+ printf 'stock_osm2pgsql: %s\n' "$STOCK_OSM2PGSQL"
+ printf 'clustered_osm2pgsql: %s\n' "$CLUSTERED_OSM2PGSQL"
+ printf 'osm2pgsql_style: %s\n' "$OSM2PGSQL_STYLE"
+ printf 'osmium: %s\n' "$OSMIUM"
+ printf 'postgis_share: %s\n' "$POSTGIS_SHARE"
+ printf 'postgis_lib: %s\n' "$POSTGIS_LIB"
+ printf 'pg_port_base: %s\n' "$PG_PORT_BASE"
+ printf 'pg_work_mem: %s\n' "$PG_WORK_MEM"
+ printf 'osm2pgsql_cache_mb: %s\n' "$OSM2PGSQL_CACHE_MB"
+ printf 'osm2pgsql_procs: %s\n' "$OSM2PGSQL_PROCS"
+ printf 'bench_variants: %s\n' "$BENCH_VARIANTS"
+ printf 'read_repeats: %s\n' "$READ_REPEATS"
+ printf 'compress_logs: %s\n' "$COMPRESS_LOGS"
+ printf 'keep_pgdata: %s\n' "$KEEP_PGDATA"
+ printf 'pg_log_min_duration_ms: %s\n' "${PG_LOG_MIN_DURATION_MS:-off}"
+ printf 'uname: '
+ uname -a
+ printf 'uptime: '
+ uptime
+ df -h "$WORKDIR" "${TMPDIR:-/tmp}" 2>/dev/null || true
+ } >"$WORKDIR/run_environment.txt"
+}
+
+require_executable curl
+require_executable awk
+require_executable sed
+require_executable "$OSMIUM"
+if variant_enabled baseline_stock; then
+ require_path_executable "$BASELINE_PG_BIN/initdb"
+fi
+if variant_enabled patched_stock || variant_enabled patched_clustered_import; then
+ require_path_executable "$PATCHED_PG_BIN/initdb"
+fi
+if variant_enabled patched_clustered_import; then
+ require_path_executable "$CLUSTERED_OSM2PGSQL"
+fi
+if [[ ! -f "$OSM2PGSQL_STYLE" ]]; then
+ printf 'missing osm2pgsql style file: %s\n' "$OSM2PGSQL_STYLE" >&2
+ exit 1
+fi
+stock_osm2pgsql_path=""
+if variant_enabled baseline_stock || variant_enabled patched_stock; then
+ stock_osm2pgsql_path="$(resolve_executable "$STOCK_OSM2PGSQL")"
+fi
+
+georgia_pbf="$WORKDIR/data/georgia-latest.osm.pbf"
+daily_diff_url="$(resolve_daily_diff_url)"
+daily_diff="$WORKDIR/data/$(basename "$daily_diff_url")"
+
+download_if_missing "$GEORGIA_URL" "$georgia_pbf"
+download_if_missing "$daily_diff_url" "$daily_diff"
+simplified_daily_diff="$(simplify_daily_diff "$daily_diff")"
+
+log "benchmark inputs:"
+log " georgia=$GEORGIA_URL"
+log " daily_diff=$daily_diff_url"
+log " simplified_daily_diff=$simplified_daily_diff"
+log " variants=$BENCH_VARIANTS"
+log " workdir=$WORKDIR"
+log " pg_work_mem=$PG_WORK_MEM"
+log " read_repeats=$READ_REPEATS"
+log " compress_logs=$COMPRESS_LOGS"
+log " keep_pgdata=$KEEP_PGDATA"
+log " pg_log_min_duration_ms=${PG_LOG_MIN_DURATION_MS:-off}"
+
+write_run_environment
+
+if variant_enabled baseline_stock; then
+ run_variant baseline_stock "$BASELINE_PG_BIN" "$((PG_PORT_BASE + 1))" "$stock_osm2pgsql_path" stock "$georgia_pbf" "$simplified_daily_diff"
+fi
+
+if variant_enabled patched_stock; then
+ run_variant patched_stock "$PATCHED_PG_BIN" "$((PG_PORT_BASE + 2))" "$stock_osm2pgsql_path" stock "$georgia_pbf" "$simplified_daily_diff"
+fi
+
+if variant_enabled patched_clustered_import; then
+ run_variant patched_clustered_import "$PATCHED_PG_BIN" "$((PG_PORT_BASE + 3))" "$CLUSTERED_OSM2PGSQL" clustered_import "$georgia_pbf" "$simplified_daily_diff"
+fi
+
+log "done; logs are in $WORKDIR/logs"
diff --git a/src/tools/clustered_write_bench/run_synthetic_bench.sh b/src/tools/clustered_write_bench/run_synthetic_bench.sh
new file mode 100755
index 0000000000000..1210d13bd4871
--- /dev/null
+++ b/src/tools/clustered_write_bench/run_synthetic_bench.sh
@@ -0,0 +1,319 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
+REPO_ROOT=$(cd "$SCRIPT_DIR/../../.." && pwd)
+
+DBNAME=${DBNAME:-postgres}
+PSQL=${PSQL:-psql}
+PG_BINDIR=${PG_BINDIR:-}
+USE_TEMP_INSTANCE=${USE_TEMP_INSTANCE:-false}
+PGPORT=${PGPORT:-6543}
+PG_OPTS=${PG_OPTS:-}
+REPEATS=${REPEATS:-3}
+SCALE_VALUES=${SCALE_VALUES:-"0.1 1"}
+BRIN_VALUES=${BRIN_VALUES:-"false true"}
+SINGLE_KEY_VALUES=${SINGLE_KEY_VALUES:-"false"}
+TEXT_KEY_VALUES=${TEXT_KEY_VALUES:-"false"}
+HOT_TILE_FRACTION_VALUES=${HOT_TILE_FRACTION_VALUES:-"0"}
+HOT_TILE_COUNT_VALUES=${HOT_TILE_COUNT_VALUES:-"1"}
+HOT_UPDATE_FRACTION_VALUES=${HOT_UPDATE_FRACTION_VALUES:-"0"}
+UPDATE_PAYLOAD_REPEAT_VALUES=${UPDATE_PAYLOAD_REPEAT_VALUES:-"64"}
+HEAP_FILLFACTOR_VALUES=${HEAP_FILLFACTOR_VALUES:-"90"}
+ORDER_DIFF_BY_CLUSTER_KEY_VALUES=${ORDER_DIFF_BY_CLUSTER_KEY_VALUES:-"false"}
+COPY_DIFF_FROM_FILE_VALUES=${COPY_DIFF_FROM_FILE_VALUES:-"false"}
+UPDATES_BEFORE_INSERTS_VALUES=${UPDATES_BEFORE_INSERTS_VALUES:-"false"}
+OUTDIR=${OUTDIR:-"$HOME/tmp/clustered-write-synthetic/$(date +%Y%m%d-%H%M%S)"}
+COMPRESS_RAW=${COMPRESS_RAW:-true}
+KEEP_TEMP_INSTANCE_DATA=${KEEP_TEMP_INSTANCE_DATA:-false}
+
+mkdir -p "$OUTDIR/raw"
+
+{
+ printf 'date: '
+ date -Is
+ printf 'script_dir: %s\n' "$SCRIPT_DIR"
+ printf 'repo_root: %s\n' "$REPO_ROOT"
+ if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ printf 'git_head: %s\n' "$(git -C "$REPO_ROOT" rev-parse HEAD)"
+ printf 'git_branch: %s\n' "$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
+ fi
+ printf 'dbname: %s\n' "$DBNAME"
+ printf 'pg_bindir: %s\n' "$PG_BINDIR"
+ printf 'use_temp_instance: %s\n' "$USE_TEMP_INSTANCE"
+ printf 'pgport: %s\n' "$PGPORT"
+ printf 'pg_opts: %s\n' "$PG_OPTS"
+ printf 'repeats: %s\n' "$REPEATS"
+ printf 'scale_values: %s\n' "$SCALE_VALUES"
+ printf 'brin_values: %s\n' "$BRIN_VALUES"
+ printf 'single_key_values: %s\n' "$SINGLE_KEY_VALUES"
+ printf 'text_key_values: %s\n' "$TEXT_KEY_VALUES"
+ printf 'hot_tile_fraction_values: %s\n' "$HOT_TILE_FRACTION_VALUES"
+ printf 'hot_tile_count_values: %s\n' "$HOT_TILE_COUNT_VALUES"
+ printf 'hot_update_fraction_values: %s\n' "$HOT_UPDATE_FRACTION_VALUES"
+ printf 'update_payload_repeat_values: %s\n' "$UPDATE_PAYLOAD_REPEAT_VALUES"
+ printf 'heap_fillfactor_values: %s\n' "$HEAP_FILLFACTOR_VALUES"
+ printf 'order_diff_by_cluster_key_values: %s\n' "$ORDER_DIFF_BY_CLUSTER_KEY_VALUES"
+ printf 'copy_diff_from_file_values: %s\n' "$COPY_DIFF_FROM_FILE_VALUES"
+ printf 'updates_before_inserts_values: %s\n' "$UPDATES_BEFORE_INSERTS_VALUES"
+ printf 'compress_raw: %s\n' "$COMPRESS_RAW"
+ printf 'keep_temp_instance_data: %s\n' "$KEEP_TEMP_INSTANCE_DATA"
+ printf 'uname: '
+ uname -a
+ printf 'uptime: '
+ uptime
+ df -h "$OUTDIR" "${TMPDIR:-/tmp}" 2>/dev/null || true
+} >"$OUTDIR/run_environment.txt"
+
+if [[ "$USE_TEMP_INSTANCE" == "true" ]]; then
+ if [[ -z "$PG_BINDIR" ]]; then
+ printf 'USE_TEMP_INSTANCE=true requires PG_BINDIR=/path/to/postgres/bin\n' >&2
+ exit 1
+ fi
+
+ PGDATA="$OUTDIR/pgdata"
+ PGHOST=$(mktemp -d "${TMPDIR:-/tmp}/clustered-write-bench-socket.XXXXXX")
+ export PGHOST PGPORT
+ PSQL="$PG_BINDIR/psql"
+
+ "$PG_BINDIR/initdb" -D "$PGDATA" --auth trust --no-sync --no-instructions \
+ >"$OUTDIR/initdb.log"
+ "$PG_BINDIR/pg_ctl" -D "$PGDATA" -l "$OUTDIR/postgres.log" \
+ -o "-k $PGHOST -p $PGPORT -c listen_addresses='' -c fsync=off -c synchronous_commit=off -c full_page_writes=off $PG_OPTS" \
+ start >"$OUTDIR/pg_ctl_start.log"
+
+ stop_temp_instance()
+ {
+ "$PG_BINDIR/pg_ctl" -D "$PGDATA" stop -m fast \
+ >"$OUTDIR/pg_ctl_stop.log" 2>&1 || true
+ if [[ "$KEEP_TEMP_INSTANCE_DATA" != "true" ]]; then
+ rm -rf "$PGDATA"
+ fi
+ rm -rf "$PGHOST"
+ }
+ trap stop_temp_instance EXIT
+fi
+
+"$PSQL" -X -v ON_ERROR_STOP=1 -d "$DBNAME" \
+ -c 'select version() as benchmark_postgres_version' \
+ >"$OUTDIR/server_version.txt"
+
+timings_tsv="$OUTDIR/timings.tsv"
+locality_tsv="$OUTDIR/locality.tsv"
+timing_summary_tsv="$OUTDIR/timing_summary.tsv"
+locality_summary_tsv="$OUTDIR/locality_summary.tsv"
+
+printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdate_payload_repeat\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tstep\telapsed_ms\n' >"$timings_tsv"
+printf 'run\tscale\tbrin_enabled\tsingle_key_cluster\ttext_cluster_key\theap_fillfactor\thot_tile_fraction\thot_tile_count\thot_update_fraction\tupdate_payload_repeat\tupdates_before_inserts\torder_diff_by_cluster_key\tcopy_diff_from_file\tvariant\tdiff_kind\trows_measured\theap_blocks_touched\theap_block_span\toutside_base_heap_block_span\tpct_inside_base_range\tavg_block_drift\tp95_block_drift\tmax_block_drift\n' >"$locality_tsv"
+
+for scale in $SCALE_VALUES; do
+ for brin in $BRIN_VALUES; do
+ for single_key in $SINGLE_KEY_VALUES; do
+ for text_cluster_key in $TEXT_KEY_VALUES; do
+ for heap_fillfactor in $HEAP_FILLFACTOR_VALUES; do
+ for hot_tile_fraction in $HOT_TILE_FRACTION_VALUES; do
+ for hot_tile_count in $HOT_TILE_COUNT_VALUES; do
+ for hot_update_fraction in $HOT_UPDATE_FRACTION_VALUES; do
+ for update_payload_repeat in $UPDATE_PAYLOAD_REPEAT_VALUES; do
+ for updates_before_inserts in $UPDATES_BEFORE_INSERTS_VALUES; do
+ for order_diff_by_cluster_key in $ORDER_DIFF_BY_CLUSTER_KEY_VALUES; do
+ for copy_diff_from_file in $COPY_DIFF_FROM_FILE_VALUES; do
+ for run in $(seq 1 "$REPEATS"); do
+ raw="$OUTDIR/raw/scale-${scale}_brin-${brin}_single-key-${single_key}_text-key-${text_cluster_key}_fillfactor-${heap_fillfactor}_hot-tile-${hot_tile_fraction}_hot-tile-count-${hot_tile_count}_hot-update-${hot_update_fraction}_update-payload-${update_payload_repeat}_updates-before-${updates_before_inserts}_order-diff-${order_diff_by_cluster_key}_copy-${copy_diff_from_file}_run-${run}.out"
+ diff_copy_path="${raw%.out}.copy.tsv"
+
+ if ! "$PSQL" -X -v ON_ERROR_STOP=1 \
+ -v scale="$scale" \
+ -v use_brin="$brin" \
+ -v single_key_cluster="$single_key" \
+ -v text_cluster_key="$text_cluster_key" \
+ -v heap_fillfactor="$heap_fillfactor" \
+ -v hot_tile_fraction="$hot_tile_fraction" \
+ -v hot_tile_count="$hot_tile_count" \
+ -v hot_update_fraction="$hot_update_fraction" \
+ -v update_payload_repeat="$update_payload_repeat" \
+ -v updates_before_inserts="$updates_before_inserts" \
+ -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \
+ -v copy_diff_from_file="$copy_diff_from_file" \
+ -v diff_copy_path="$diff_copy_path" \
+ -d "$DBNAME" >"$raw" <>"$timings_tsv"
+
+ awk -F'|' \
+ -v run="$run" \
+ -v scale="$scale" \
+ -v brin="$brin" \
+ -v single_key="$single_key" \
+ -v text_cluster_key="$text_cluster_key" \
+ -v heap_fillfactor="$heap_fillfactor" \
+ -v hot_tile_fraction="$hot_tile_fraction" \
+ -v hot_tile_count="$hot_tile_count" \
+ -v hot_update_fraction="$hot_update_fraction" \
+ -v update_payload_repeat="$update_payload_repeat" \
+ -v updates_before_inserts="$updates_before_inserts" \
+ -v order_diff_by_cluster_key="$order_diff_by_cluster_key" \
+ -v copy_diff_from_file="$copy_diff_from_file" \
+ '$5 == "clustered_write" ||
+ $5 == "without_cluster_metadata" {
+ printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ run, scale, brin, single_key, text_cluster_key, heap_fillfactor, hot_tile_fraction, hot_tile_count, hot_update_fraction, update_payload_repeat, updates_before_inserts, order_diff_by_cluster_key, copy_diff_from_file, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
+ }' "$raw" >>"$locality_tsv"
+
+ if [[ "$COMPRESS_RAW" == "true" ]]; then
+ gzip -f "$raw"
+ fi
+ done
+ done
+ done
+ done
+ done
+ done
+ done
+ done
+ done
+ done
+ done
+ done
+done
+
+awk -F'\t' '
+ BEGIN {
+ OFS = "\t"
+ print "scale", "brin_enabled", "single_key_cluster",
+ "text_cluster_key", "heap_fillfactor", "hot_tile_fraction",
+ "hot_tile_count", "hot_update_fraction",
+ "update_payload_repeat",
+ "updates_before_inserts",
+ "order_diff_by_cluster_key",
+ "copy_diff_from_file",
+ "step", "runs",
+ "avg_elapsed_ms", "median_elapsed_ms", "min_elapsed_ms",
+ "max_elapsed_ms"
+ }
+ NR > 1 {
+ key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 OFS $14
+ sum[key] += $15
+ count[key]++
+ sample[key, count[key]] = $15
+ if (!(key in min) || $15 < min[key])
+ min[key] = $15
+ if (!(key in max) || $15 > max[key])
+ max[key] = $15
+ }
+ END {
+ for (key in count) {
+ for (i = 1; i <= count[key]; i++)
+ value[i] = sample[key, i] + 0
+ for (i = 1; i <= count[key]; i++) {
+ for (j = i + 1; j <= count[key]; j++) {
+ if (value[i] <= value[j])
+ continue
+ tmp = value[i]
+ value[i] = value[j]
+ value[j] = tmp
+ }
+ }
+ median_pos = int((count[key] + 1) / 2)
+ if (count[key] % 2)
+ median[key] = value[median_pos]
+ else
+ median[key] = (value[median_pos] + value[median_pos + 1]) / 2
+ }
+ for (key in count)
+ printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\n",
+ key, count[key], sum[key] / count[key], median[key],
+ min[key], max[key]
+ }
+' "$timings_tsv" >"$timing_summary_tsv"
+{
+ head -n 1 "$timing_summary_tsv"
+ tail -n +2 "$timing_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10n -k11,11 -k12,12 -k13,13
+} >"$timing_summary_tsv.tmp"
+mv "$timing_summary_tsv.tmp" "$timing_summary_tsv"
+
+awk -F'\t' '
+ BEGIN {
+ OFS = "\t"
+ print "scale", "brin_enabled", "single_key_cluster",
+ "text_cluster_key", "heap_fillfactor", "hot_tile_fraction",
+ "hot_tile_count", "hot_update_fraction",
+ "update_payload_repeat",
+ "updates_before_inserts",
+ "order_diff_by_cluster_key",
+ "copy_diff_from_file",
+ "variant", "diff_kind", "runs", "avg_rows_measured",
+ "avg_heap_block_span", "avg_outside_base_heap_block_span",
+ "avg_pct_inside_base_range",
+ "avg_block_drift", "avg_p95_block_drift"
+ }
+ NR > 1 {
+ key = $2 OFS $3 OFS $4 OFS $5 OFS $6 OFS $7 OFS $8 OFS $9 OFS $10 OFS $11 OFS $12 OFS $13 OFS $14 OFS $15
+ rows[key] += $16
+ span[key] += $18
+ outside_span[key] += $19
+ pct[key] += $20
+ avg[key] += $21
+ p95[key] += $22
+ count[key]++
+ }
+ END {
+ for (key in count)
+ printf "%s\t%d\t%.2f\t%.2f\t%.2f\t%.2f\t%.2f\t%.2f\n",
+ key, count[key], rows[key] / count[key], span[key] / count[key],
+ outside_span[key] / count[key], pct[key] / count[key],
+ avg[key] / count[key], p95[key] / count[key]
+ }
+' "$locality_tsv" >"$locality_summary_tsv"
+{
+ head -n 1 "$locality_summary_tsv"
+ tail -n +2 "$locality_summary_tsv" | sort -t ' ' -k1,1V -k2,2 -k3,3 -k4,4 -k5,5n -k6,6V -k7,7n -k8,8 -k9,9 -k10,10n -k11,11 -k12,12 -k13,13 -k14,14
+} >"$locality_summary_tsv.tmp"
+mv "$locality_summary_tsv.tmp" "$locality_summary_tsv"
+
+printf 'raw output: %s/raw\n' "$OUTDIR"
+printf 'timings: %s\n' "$timings_tsv"
+printf 'locality: %s\n' "$locality_tsv"
+printf 'timing summary: %s\n' "$timing_summary_tsv"
+printf 'locality summary: %s\n' "$locality_summary_tsv"
+if [[ "$COMPRESS_RAW" == "true" ]]; then
+ printf 'raw files compressed: true\n'
+fi
+if [[ "$USE_TEMP_INSTANCE" == "true" && "$KEEP_TEMP_INSTANCE_DATA" != "true" ]]; then
+ printf 'temporary instance data removed: true\n'
+fi