GCC Code Coverage Report


Directory: src/
File: src/pg/maintenance.c
Date: 2026-09-30 11:11:31
Exec Total Coverage
Lines: 330 345 95.7%
Functions: 27 27 100.0%
Branches: 110 152 72.4%

Line Branch Exec Source
1 /*
2 * Copyright (c) 2026 Tiger Data, Inc.
3 * Licensed under the PostgreSQL License. See LICENSE for details.
4 *
5 * maintenance.c - mutating index maintenance functions
6 *
7 * SQL-callable operations that modify prism index pages, and therefore
8 * WAL-log them. Kept separate from the strictly read-only inspection
9 * functions in inspect.c so the write/WAL paths are grouped where they
10 * get the scrutiny persistent mutations need.
11 *
12 * Operations:
13 * prism_convert_posting_to_fastscan(regclass, int4) -- convert one cluster's
14 * posting chain from AoS to fastscan format (procedure)
15 * prism_split_posting_list(regclass, bigint) -- split one list by head block
16 * (procedure)
17 * prism_rebalance(regclass, int4) -- split every list over a given size
18 * (procedure)
19 *
20 * All three are procedures, not functions: they are DDL-like mutating
21 * maintenance and take no return value (reporting via NOTICE). Only
22 * split_posting_list and rebalance additionally require their own
23 * transaction (require_own_transaction): they manage a maintenance pass
24 * across possibly many lists, and letting them run inside a caller's
25 * transaction would offer a rollback that does not roll back the pages
26 * they have already written. convert_posting_to_fastscan performs one
27 * atomic write with no such internal boundary, so it carries no such
28 * restriction -- it is meant to be called in a loop from a DO block or
29 * function, e.g. to convert every cluster a query selects.
30 */
31
32 #include <postgres.h>
33
34 #include <access/generic_xlog.h>
35 #include <access/htup_details.h>
36 #include <access/relation.h>
37 #include <access/reloptions.h>
38 #include <access/table.h>
39 #include <access/tableam.h>
40 #include <access/transam.h>
41 #include <access/xact.h>
42 #include <catalog/index.h>
43 #include <catalog/indexing.h>
44 #include <catalog/objectaddress.h>
45 #include <catalog/pg_class.h>
46 #include <executor/tuptable.h>
47 #include <fmgr.h>
48 #include <miscadmin.h>
49 #include <nodes/makefuncs.h>
50 #include <nodes/parsenodes.h>
51 #include <storage/bufmgr.h>
52 #include <storage/lmgr.h>
53 #include <utils/acl.h>
54 #include <utils/builtins.h>
55 #include <utils/injection_point.h>
56 #include <utils/inval.h>
57 #include <utils/lsyscache.h>
58 #include <utils/rel.h>
59 #include <utils/snapmgr.h>
60 #include <utils/syscache.h>
61
62 #include "amcache.h"
63 #include "build.h"
64 #include "index/centroid_page.h"
65 #include "index/index_base.h"
66 #include "index/index_build.h"
67 #include "index/posting_convert.h"
68 #include "index/posting_page.h"
69 #include "index/posting_split.h"
70 #include "inspect.h"
71 #include "meta.h"
72 #include "pg/bufstorage.h"
73 #include "support_pg.h"
74 #include "typeinfo.h"
75 #include "types/vec32.h"
76
77 /*
78 * Lock mode the mutating maintenance entry points take on the index.
79 *
80 * ShareUpdateExclusiveLock rather than RowExclusiveLock, because it conflicts
81 * with itself: two maintenance calls on one index have to serialize. A split
82 * mints ids for its new clusters from the leaf count it read (nlist + j) and
83 * writes the updated count back when it finishes, so two overlapping passes
84 * would hand the same ids to different lists and persist a count too low by
85 * one pass's worth -- which then undercounts leaves for the automatic probe
86 * count and the cost model, and leaves cluster ids ambiguous for
87 * convert_posting_to_fastscan and tids_clusters.
88 *
89 * Like VACUUM's lock (the same mode, for the same "one housekeeper at a time"
90 * reason) it still admits reads and inserts, so maintenance does not block
91 * queries.
92 *
93 * Held until end of transaction, not until the relation is closed: the new
94 * leaf count reaches other backends as a relcache invalidation, which is only
95 * delivered at commit.
96 */
97 #define PRISM_MAINT_LOCK ShareUpdateExclusiveLock
98
99 /*
100 * Refuse to run inside a caller's transaction.
101 *
102 * These procedures reorganize the index; they do not change the data it points
103 * at. Their page writes are not transactional either -- a ROLLBACK leaves the
104 * lists split, the old chains retired and the leaf count raised, while
105 * discarding the relcache invalidation that tells other backends the leaf
106 * count moved. Letting them run inside a transaction block therefore offers a
107 * rollback that does not roll anything back.
108 *
109 * It is also what lets a pass commit as it goes. Splitting each list in its
110 * own transaction -- so a long pass bounds its transaction and can reclaim
111 * what its earlier splits retired -- is impossible from inside a caller's
112 * transaction, which is the reason a procedure was the right shape for this to
113 * begin with.
114 *
115 * The same test VACUUM makes, in the form a procedure has available: a
116 * top-level CALL gets a non-atomic call context, anything else (a transaction
117 * block, a function, a DO block) gets an atomic one.
118 */
119 static void
120 51 require_own_transaction(FunctionCallInfo fcinfo, const char *procname)
121 {
122 155 CallContext *cc = (fcinfo->context != NULL &&
123
1/2
✓ Branch 0 taken 51 times.
✗ Branch 1 not taken.
51 IsA(fcinfo->context, CallContext))
124 ? (CallContext *)fcinfo->context
125
1/2
✓ Branch 0 taken 51 times.
✗ Branch 1 not taken.
51 : NULL;
126
127
2/2
✓ Branch 0 taken 49 times.
✓ Branch 1 taken 2 times.
51 if (cc != NULL && !cc->atomic)
128 49 return;
129
130
1/2
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
51 ereport(ERROR,
131 (errcode(ERRCODE_ACTIVE_SQL_TRANSACTION),
132 errmsg("%s cannot run inside a transaction block", procname),
133 errdetail(
134 "It reorganizes the index outside transaction control, "
135 "so a rollback would not undo it."),
136 errhint("Call it on its own, outside BEGIN/COMMIT.")));
137 }
138
139 /*
140 * A PROCEDURE cannot be declared STRICT, so a NULL argument arrives here as a
141 * zero rather than short-circuiting to a NULL result. Left unchecked, a NULL
142 * index reads as OID 0 and fails with "could not open relation with OID 0",
143 * and a NULL block number as block 0 -- errors that describe the internal
144 * consequence instead of the caller's mistake.
145 */
146 static void
147 705 reject_null_arg(FunctionCallInfo fcinfo, int argno, const char *argname)
148 {
149
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 702 times.
705 if (PG_ARGISNULL(argno))
150
1/2
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
3 ereport(ERROR,
151 (errcode(ERRCODE_NULL_VALUE_NOT_ALLOWED),
152 errmsg("%s must not be null", argname)));
153 702 }
154
155 10 PG_FUNCTION_INFO_V1(vs_convert_posting_to_fastscan);
156 11 PG_FUNCTION_INFO_V1(vs_split_posting_list);
157 18 PG_FUNCTION_INFO_V1(vs_rebalance);
158
159 /*
160 * Authorization. convert_posting_to_fastscan mutates the index, so it requires
161 * ownership of the *table* the index belongs to -- the same relation model
162 * PostgreSQL's pgrowlocks/pgstattuple use for relation-level operations.
163 * EXECUTE stays granted to PUBLIC; this runtime check does the per-object
164 * authorization a static GRANT cannot express for a regclass argument.
165 * Superusers pass automatically.
166 */
167 static void
168 369 require_index_owner(Relation index, LOCKMODE lockmode)
169 {
170 369 Oid heaprelid = IndexGetRelation(RelationGetRelid(index), false);
171
2/2
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 365 times.
369 if (!object_ownercheck(RelationRelationId, heaprelid, GetUserId()))
172 {
173 4 char *relname = get_rel_name(heaprelid);
174 4 ObjectType objtype = get_relkind_objtype(get_rel_relkind(heaprelid));
175 4 relation_close(index, lockmode);
176 4 aclcheck_error(ACLCHECK_NOT_OWNER, objtype, relname);
177 }
178 365 }
179
180 /*
181 * Writable pointer to a leaf entry's child block number on a centroid page.
182 * The location depends on the page format: an AoS meta array for the
183 * meta-based formats, or the packed per-group child array for a fastscan
184 * centroid page. This must match how collect_leaf_entries reads the child, or
185 * the compare-and-set in update_centroid_posting_head acts on the wrong slot.
186 */
187 static BlockNumber *
188 638 centroid_child_ptr(Page page, uint16_t entry_idx, Dimension dim)
189 {
190
2/2
✓ Branch 1 taken 574 times.
✓ Branch 2 taken 64 times.
638 if (prism_centroid_page_format(page) == PRISM_CENTROID_FMT_FASTSCAN)
191 {
192 574 char *content = (char *)PageGetContents(page);
193 574 uint32_t g = entry_idx / VS_FASTSCAN_GROUP;
194 574 uint32_t slot = entry_idx % VS_FASTSCAN_GROUP;
195 574 return &prism_centroid_fastscan_group_child(content, g, dim)[slot];
196 }
197 64 return &prism_centroid_meta_mut(page, entry_idx)->child_blkno;
198 }
199
200 /*
201 * Point a centroid leaf entry at new_head via WAL, but only if it still
202 * points at expected_old_head. The compare-and-set runs under the same
203 * exclusive lock as the write, with no gap. The maintenance entry points hold
204 * a self-conflicting lock on the index (PRISM_MAINT_LOCK), so two
205 * of them cannot race on one cluster in the first place -- but the
206 * compare-and-set stays as a gate that does not depend on callers agreeing
207 * about lock modes. The head pointer -- not the posting page's fastscan flag
208 * -- is what conversion actually updates, so it is the correct thing to test.
209 *
210 * Returns true if it performed the update. Returns false if another
211 * converter already moved the head, writing the current head to
212 * *current_head_out (the caller's freshly built chain is then orphaned,
213 * to be reclaimed by a later rebuild/VACUUM).
214 */
215 static bool
216 319 update_centroid_posting_head(
217 Relation index,
218 BlockNumber centroid_page,
219 uint16_t entry_idx,
220 BlockNumber expected_old_head,
221 BlockNumber new_head,
222 Dimension dim,
223 BlockNumber *current_head_out)
224 {
225 319 Buffer buf = ReadBuffer(index, centroid_page);
226 319 LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
227
228
1/2
✗ Branch 2 not taken.
✓ Branch 3 taken 319 times.
319 if (*centroid_child_ptr(BufferGetPage(buf), entry_idx, dim) !=
229 expected_old_head)
230 {
231 ✗ *current_head_out =
232 ✗ *centroid_child_ptr(BufferGetPage(buf), entry_idx, dim);
233 ✗ UnlockReleaseBuffer(buf);
234 ✗ return false;
235 }
236
237 319 GenericXLogState *state = GenericXLogStart(index);
238 319 Page page = GenericXLogRegisterBuffer(state, buf, GENERIC_XLOG_FULL_IMAGE);
239 319 *centroid_child_ptr(page, entry_idx, dim) = new_head;
240
241 319 GenericXLogFinish(state);
242 319 UnlockReleaseBuffer(buf);
243 319 return true;
244 }
245
246 /*
247 * Set PRISM_META_FLAG_FASTSCAN on the metadata page if not already set.
248 */
249 static void
250 319 ensure_meta_fastscan_flag(Relation index)
251 {
252 319 Buffer buf = ReadBuffer(index, 0);
253 319 LockBuffer(buf, BUFFER_LOCK_SHARE);
254 319 Page page = BufferGetPage(buf);
255 319 PrismMetaPage *mp = (PrismMetaPage *)PageGetSpecialPointer(page);
256 319 bool needs_update = !(mp->flags & PRISM_META_FLAG_FASTSCAN);
257 319 UnlockReleaseBuffer(buf);
258
259
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 315 times.
319 if (needs_update)
260 {
261 4 GenericXLogState *state = GenericXLogStart(index);
262 4 buf = ReadBuffer(index, 0);
263 4 LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
264 4 page = GenericXLogRegisterBuffer(state, buf, GENERIC_XLOG_FULL_IMAGE);
265 4 mp = (PrismMetaPage *)PageGetSpecialPointer(page);
266 4 mp->flags |= PRISM_META_FLAG_FASTSCAN;
267 4 GenericXLogFinish(state);
268 4 UnlockReleaseBuffer(buf);
269 }
270 319 }
271
272 /* ----------------------------------------------------------------
273 * CALL prism_convert_posting_to_fastscan(regclass, int4)
274 *
275 * Converts one cluster's posting chain from AoS to fastscan.
276 * Updates the centroid leaf entry and sets the metadata flag.
277 * Reports the new posting head block number via NOTICE.
278 * ---------------------------------------------------------------- */
279 Datum
280 324 vs_convert_posting_to_fastscan(PG_FUNCTION_ARGS)
281 {
282 324 reject_null_arg(fcinfo, 0, "index_oid");
283 324 reject_null_arg(fcinfo, 1, "cluster_id");
284
285 324 Oid indexoid = PG_GETARG_OID(0);
286 324 int32 cluster_id = PG_GETARG_INT32(1);
287 324 Relation index = relation_open(indexoid, PRISM_MAINT_LOCK);
288
289
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 324 times.
324 if (index->rd_rel->relkind != RELKIND_INDEX)
290 {
291 ✗ relation_close(index, PRISM_MAINT_LOCK);
292 ✗ ereport(ERROR,
293 (errcode(ERRCODE_WRONG_OBJECT_TYPE),
294 errmsg("\"%s\" is not an index",
295 RelationGetRelationName(index))));
296 }
297
298 324 require_index_owner(index, PRISM_MAINT_LOCK);
299
300 /* Read metadata */
301 322 Buffer meta_buf = ReadBuffer(index, 0);
302 322 LockBuffer(meta_buf, BUFFER_LOCK_SHARE);
303 322 Page meta_page = BufferGetPage(meta_buf);
304
305 322 const PrismMetaPage *meta = (const PrismMetaPage *)PageGetSpecialPointer(
306 meta_page);
307
308
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 321 times.
322 if (meta->magic != PRISM_META_MAGIC)
309 {
310 1 UnlockReleaseBuffer(meta_buf);
311 1 relation_close(index, PRISM_MAINT_LOCK);
312
1/2
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
1 ereport(ERROR,
313 (errcode(ERRCODE_WRONG_OBJECT_TYPE),
314 errmsg("\"%s\" is not a prism index",
315 RelationGetRelationName(index))));
316 }
317
318 321 BlockNumber first_centroid = meta->first_centroid;
319 321 Dimension dim = meta->dim;
320 321 uint8_t nlevels = meta->nlevels;
321 321 UnlockReleaseBuffer(meta_buf);
322
323 /*
324 * Find the leaf for this cluster. The argument is the stored cluster_id --
325 * what prism_posting_pages reports and what callers pass -- not a
326 * positional index. collect_leaf_entries returns leaves in tree-traversal
327 * order, which coincides with cluster_id only for a single-page flat tree;
328 * on any deeper or multi-page tree the two diverge. Match on each leaf's
329 * own head cluster_id so the argument means the same thing everywhere.
330 */
331 321 LeafEntry *leaves;
332 321 int nleaves =
333 321 collect_leaf_entries(index, first_centroid, nlevels, dim, &leaves);
334
335 321 LeafEntry *leaf = NULL;
336
2/2
✓ Branch 1 taken 33921 times.
✓ Branch 2 taken 1 times.
33922 for (int i = 0; i < nleaves; i++)
337 {
338 33921 Buffer hbuf = ReadBuffer(index, leaves[i].posting_head);
339 33921 LockBuffer(hbuf, BUFFER_LOCK_SHARE);
340 33921 uint32_t cid = prism_posting_opaque(BufferGetPage(hbuf))->cluster_id;
341 33921 UnlockReleaseBuffer(hbuf);
342
2/2
✓ Branch 0 taken 320 times.
✓ Branch 1 taken 33601 times.
33921 if (cid == (uint32_t)cluster_id)
343 {
344 320 leaf = &leaves[i];
345 320 break;
346 }
347 }
348
349
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 320 times.
321 if (leaf == NULL)
350 {
351 1 pfree(leaves);
352 1 relation_close(index, PRISM_MAINT_LOCK);
353
1/2
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
1 ereport(ERROR,
354 (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
355 errmsg("cluster %d not found or has no posting list",
356 cluster_id)));
357 }
358
359 320 BlockNumber old_head = leaf->posting_head;
360 320 BlockNumber centroid_page = leaf->centroid_page;
361 320 uint16_t entry_idx = leaf->entry_idx;
362 320 pfree(leaves);
363
364 /* Skip if already fastscan */
365 {
366 320 Buffer buf = ReadBuffer(index, old_head);
367 320 LockBuffer(buf, BUFFER_LOCK_SHARE);
368 320 Page page = BufferGetPage(buf);
369 320 PrismPostingPageOpaque *op = prism_posting_opaque(page);
370 320 bool already_fastscan = (op->flags & PRISM_POSTING_PAGE_FASTSCAN) != 0;
371 320 UnlockReleaseBuffer(buf);
372
373
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 319 times.
320 if (already_fastscan)
374 {
375 1 relation_close(index, PRISM_MAINT_LOCK);
376
1/2
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
1 ereport(NOTICE,
377 (errmsg("cluster %d already fastscan (head block %u)",
378 cluster_id,
379 old_head)));
380 1 PG_RETURN_VOID();
381 }
382 }
383
384 /* Convert the posting chain */
385 319 VsPgStorage storage;
386 319 vs_pg_storage_init(&storage, index, NULL, DISTANCE_L2);
387
388 /*
389 * Online conversion, unlike a full index build, has no closing
390 * log_newpage_range() to blanket-WAL the new pages. Keep build_mode off
391 * so each fastscan page is WAL-logged as it is committed (per-page
392 * GenericXLog full image). Otherwise the pages would be dirtied but never
393 * shipped, while the centroid repoint below *is* WAL-logged — leaving a
394 * standby whose centroid points at posting heads it never received.
395 */
396 319 storage.build_mode = false;
397
398 319 BlockNumber new_head =
399 319 prism_posting_convert_to_fastscan(&storage.base, old_head, dim);
400
401 /*
402 * Publish the new head, but only if a concurrent converter has not
403 * already moved it. If it has, our new_head chain is orphaned and the
404 * winner's head is returned instead.
405 */
406 319 BlockNumber current_head;
407
1/2
✗ Branch 1 not taken.
✓ Branch 2 taken 319 times.
319 if (!update_centroid_posting_head(
408 index,
409 centroid_page,
410 entry_idx,
411 old_head,
412 new_head,
413 dim,
414 &current_head))
415 {
416 ✗ relation_close(index, PRISM_MAINT_LOCK);
417 ✗ ereport(NOTICE,
418 (errmsg("cluster %d already converted by a concurrent "
419 "call (head block %u)",
420 cluster_id,
421 current_head)));
422 ✗ PG_RETURN_VOID();
423 }
424
425 319 ensure_meta_fastscan_flag(index);
426
427 /*
428 * Keep PRISM_MAINT_LOCK until end of transaction (NoLock here releases the
429 * reference, not the lock). maint_end queues a relcache invalidation for
430 * the new leaf count, and that is only delivered at commit -- release the
431 * lock now and the next maintenance call could take it, still holding a
432 * cached index base with the stale count, and mint cluster ids that
433 * collide with the ones just written.
434 */
435 319 relation_close(index, NoLock);
436
437
2/2
✓ Branch 1 taken 258 times.
✓ Branch 2 taken 61 times.
319 ereport(NOTICE,
438 (errmsg("converted cluster %d to fastscan (new head block %u)",
439 cluster_id,
440 new_head)));
441 PG_RETURN_VOID();
442 }
443
444 /* ----------------------------------------------------------------
445 * Heap vector fetch for re-clustering (the PrismSplitEnv seam)
446 * ---------------------------------------------------------------- */
447
448 typedef struct PgSplitFetchCtx
449 {
450 Relation heap;
451 AttrNumber attnum;
452 TupleTableSlot *slot;
453 /* How to read the indexed column as float32 -- the index's own type, not
454 * an assumed one. Carries the conversion buffer, so it outlives a fetch.
455 */
456 Vec32Access access;
457 /* For the reserve-nlist seam, which has only this context to work from. */
458 VsStorage *storage;
459 } PgSplitFetchCtx;
460
461 static bool
462 29412 pg_split_fetch_vector(
463 void *ctx, ItemPointerData tid, float *out, Dimension dim)
464 {
465 29412 PgSplitFetchCtx *c = (PgSplitFetchCtx *)ctx;
466
467 /* Runs in the walk's per-entry context (see ChainTidCb), which is reset
468 * after every entry -- so the detoast below, and a TOAST reassembly if
469 * the vector is stored out of line, need no cleanup of their own.
470 *
471 * SnapshotAny: the posting entry exists until VACUUM tombstones it, so we
472 * re-cluster against whatever it still points at; a fully-pruned tuple
473 * just drops out of the split (returns false). Same contract as rerank. */
474
2/2
✓ Branch 1 taken 29400 times.
✓ Branch 2 taken 12 times.
29412 if (!table_tuple_fetch_row_version(c->heap, &tid, SnapshotAny, c->slot))
475 return false;
476
477 29400 bool isnull;
478 29400 Datum val = slot_getattr(c->slot, c->attnum, &isnull);
479 29400 bool ok = false;
480
1/2
✓ Branch 0 taken 29400 times.
✗ Branch 1 not taken.
29400 if (!isnull)
481 {
482 /*
483 * Read through the index's own type descriptor, as the insert and
484 * rerank paths do. Reading the datum as a float32 vector directly
485 * would be wrong for any other indexed type: a vec16 column passes
486 * the shape check this maintenance requires, and its 16-bit payload
487 * read as `dim` floats runs off the end of the value and feeds the
488 * split whatever follows it.
489 *
490 * Detoast explicitly rather than leaving it to the descriptor, so the
491 * copy can be released here. A vector wide enough to be stored out of
492 * line is copied on every fetch, and this runs once per entry of every
493 * list a pass touches -- keeping them would cost the whole pass's
494 * worth of vector data on top of the list being clustered.
495 */
496 29400 struct varlena *raw = (struct varlena *)DatumGetPointer(val);
497 29400 struct varlena *flat = pg_detoast_datum(raw);
498 29400 Vec32Ref vref = vec32_read(&c->access, PointerGetDatum(flat));
499
500
2/2
✓ Branch 0 taken 25682 times.
✓ Branch 1 taken 3718 times.
29400 memcpy(out, vref.data, (size_t)dim * sizeof(float));
501 29400 ok = true;
502
503
2/2
✓ Branch 0 taken 25682 times.
✓ Branch 1 taken 3718 times.
29400 if (flat != raw)
504 25682 pfree(flat);
505 }
506 29400 ExecClearTuple(c->slot);
507 29400 return ok;
508 }
509
510 /*
511 * Retire the split's old chain (the PrismSplitEnv seam). Rather than
512 * tombstoning it now — which would make scans skip a head a concurrent query
513 * may still be about to read from a stale pre-flip pointer — mark each page
514 * DELETED and stamp the head with the current next-XID. The chain stays linked
515 * and readable; VACUUM physically retires it once that XID clears the global
516 * visibility horizon (see vs_rebalance). Scans read DELETED pages
517 * (only TOMBSTONED is skipped), so an in-flight scanner still sees the full
518 * old list.
519 */
520 /*
521 * Start the read for the heap block holding `tid`, one step ahead of the walk
522 * that is about to fetch it. PrefetchBuffer does nothing when the block is
523 * already resident, so a cached heap pays a buffer-table probe per distinct
524 * block and no I/O; a cold one gets the read started while the current entry
525 * is still being decoded.
526 */
527 static void
528 4114 pg_prefetch_vector(void *ctx, ItemPointerData tid)
529 {
530 4114 PgSplitFetchCtx *c = (PgSplitFetchCtx *)ctx;
531
532 4114 PrefetchBuffer(c->heap, MAIN_FORKNUM, ItemPointerGetBlockNumber(&tid));
533 4114 }
534
535 static void
536 179 retire_page(PrismPostingPageOpaque *op, void *state)
537 {
538 179 op->flags |= PRISM_POSTING_PAGE_DELETED;
539 /* delete_xid overlays live_count/tail_blkno, unused once retired. */
540 179 op->delete_xid = *(const uint64 *)state;
541 179 }
542
543 static void
544 98 pg_retire_chain(void *ctx, VsStorage *posting_storage, BlockNumber head)
545 {
546 98 (void)ctx;
547 /*
548 * Stamp with this transaction's XID — the one that made the chain
549 * unreachable by committing the centroid flip. Any scan that could still
550 * hold a stale pointer took its snapshot no later than this XID, so once
551 * the global horizon passes it no such scan remains and the chain is safe
552 * to reclaim (the invariant btree page deletion uses). The split has
553 * already written WAL, so an XID is assigned.
554 */
555 98 uint64 dxid = U64FromFullTransactionId(GetTopFullTransactionId());
556
557 98 prism_posting_chain_mutate(posting_storage, head, retire_page, &dxid);
558 98 }
559
560 /* ----------------------------------------------------------------
561 * Helpers
562 * ---------------------------------------------------------------- */
563
564 /* Persist an updated leaf count into the metapage (block 0), in place. */
565 static void
566 124 persist_nlist(VsStorage *storage, uint32_t nlist)
567 {
568 124 Page page = vs_storage_write_page(storage, 0);
569 124 PrismMetaPage *meta = (PrismMetaPage *)PageGetSpecialPointer(page);
570 124 meta->nlist = nlist;
571 124 vs_storage_commit_page(storage, 0);
572 124 }
573
574 /*
575 * Persist the centroid page count (block 0), in place.
576 *
577 * A split that runs out of room on a level-0 centroid page extends the
578 * relation, so the count cannot be recovered from the block layout
579 * afterwards -- see PrismMetaPage.ncentroid_pages. Written once per
580 * maintenance pass rather than per split, alongside the leaf count.
581 *
582 * Losing this write cannot corrupt anything: nothing reads the count to
583 * find a page. It would leave the planner's descent term low until the next
584 * pass.
585 */
586 static void
587 26 persist_ncentroid_pages(VsStorage *storage, uint32_t ncentroid_pages)
588 {
589 26 Page page = vs_storage_write_page(storage, 0);
590 26 PrismMetaPage *meta = (PrismMetaPage *)PageGetSpecialPointer(page);
591 26 meta->ncentroid_pages = ncentroid_pages;
592 26 vs_storage_commit_page(storage, 0);
593 26 }
594
595 /*
596 * Persist the leaf count, so the ids counted from it survive a crash that
597 * leaves the new leaves reachable -- see PrismSplitEnv.reserve_nlist. The
598 * storage handle is reached through the split's fetch context, which is the
599 * only context the seam carries.
600 */
601 static void
602 98 pg_reserve_nlist(void *ctx, uint32_t nlist)
603 {
604 98 PgSplitFetchCtx *c = (PgSplitFetchCtx *)ctx;
605
606 98 persist_nlist(c->storage, nlist);
607 98 }
608
609 static void
610 42 require_supported_shape(Relation index, PrismIndexBase *base)
611 {
612
2/2
✓ Branch 0 taken 41 times.
✓ Branch 1 taken 1 times.
42 if (base->nlevels != 1 ||
613
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 40 times.
41 base->centroid_format != PRISM_CENTROID_FMT_RABITQ)
614 {
615 2 char *name = pstrdup(RelationGetRelationName(index));
616
1/2
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
2 ereport(ERROR,
617 (errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
618 errmsg("incremental split is not supported for index \"%s\"",
619 name),
620 errdetail(
621 "Only flat (single-level) indexes with RaBitQ "
622 "centroid pages are supported in this release.")));
623 }
624 40 }
625
626 /*
627 * The memory a split may use, from maintenance_work_mem --
628 * the budget an operator already raises for index work, and the same knob the
629 * bulk build sizes its clustering sample from. A split streams the list, so
630 * this bounds its memory whatever the list's size -- see
631 * prism_split_sample_cap, which turns the budget into a sample size after
632 * reserving what clustering costs alongside it.
633 */
634 static uint64_t
635 138 maint_memory_budget(void)
636 {
637 138 return (uint64_t)maintenance_work_mem * UINT64CONST(1024);
638 }
639
640 /*
641 * Refuse the pass if the budget cannot pay for a split at this dimension,
642 * rather than quietly exceeding it. Checked once per pass: the answer depends
643 * only on the dimension and the setting, so it cannot change list by list.
644 */
645 static void
646 40 require_memory_budget(Relation index, Dimension dim)
647 {
648
2/2
✓ Branch 0 taken 39 times.
✓ Branch 1 taken 1 times.
40 if (prism_split_sample_cap(maint_memory_budget(), dim) > 0)
649 39 return;
650
651 1 uint64 need = prism_split_min_budget_bytes(dim);
652 1 char *name = pstrdup(RelationGetRelationName(index));
653
654
1/2
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
40 ereport(ERROR,
655 (errcode(ERRCODE_INSUFFICIENT_RESOURCES),
656 errmsg("maintenance_work_mem is too small to split a posting "
657 "list of index \"%s\"",
658 name),
659 errdetail(
660 "Splitting %u-dimension vectors needs at "
661 "least " UINT64_FORMAT
662 " kB, but maintenance_work_mem is %d kB.",
663 (uint32)dim,
664 (need + 1023) / 1024,
665 maintenance_work_mem),
666 errhint("Increase maintenance_work_mem and retry.")));
667 }
668
669 /*
670 * Is this posting page a live chain head -- reachable, not retired, not all
671 * dead? Written the same way wherever it is asked (the maintenance scan, the
672 * re-read under the head lock, vacuum's head recognition), so the three cannot
673 * drift.
674 */
675 static bool
676 100 posting_head_is_live(const PrismPostingPageOpaque *op)
677 {
678 100 return (op->flags & PRISM_POSTING_PAGE_FIRST) &&
679
2/2
✓ Branch 0 taken 98 times.
✓ Branch 1 taken 2 times.
100 !(op->flags & PRISM_POSTING_PAGE_TOMBSTONED) &&
680 !(op->flags & PRISM_POSTING_PAGE_DELETED);
681 }
682
683 /*
684 * Split the posting head at `head` if it is a live first page and, when
685 * target > 0, holds more entries than the split trigger
686 * (prism_split_trigger). Serialized against inserts to the same
687 * cluster by the head page lock. Returns true and fills *res if a split
688 * happened.
689 *
690 * The live_count test here is only a cheap early-out on a page already read.
691 * The authoritative check is inside prism_posting_split, against the entry
692 * count after collection: live_count does not account for entries whose vector
693 * can no longer be fetched, so a list can look oversized here and turn out not
694 * to be.
695 *
696 * target also sets the width -- round(count / target) parts, so each new list
697 * rests at the target with room to grow back to the trigger. With target == 0
698 * the split is unconditional and 2-way: the manual escape hatch.
699 */
700 static bool
701 100 split_one_head(
702 Relation index,
703 PrismIndexBase *base,
704 PgSplitFetchCtx *fc,
705 BlockNumber head,
706 uint32_t target,
707 PrismSplitResult *res)
708 {
709 100 LockPage(index, head, ExclusiveLock);
710
711 100 Page p = vs_storage_read_page(base->posting_storage, head);
712
1/2
✓ Branch 1 taken 100 times.
✗ Branch 2 not taken.
100 bool live_head = prism_page_is_posting(p) &&
713
1/2
✓ Branch 0 taken 100 times.
✗ Branch 1 not taken.
100 posting_head_is_live(prism_posting_opaque(p));
714 /* live_count is meaningful only when the head is live: delete_xid overlays
715 * it once DELETED, which posting_head_is_live excludes. */
716 98 uint32_t live = live_head ? prism_posting_opaque(p)->live_count : 0;
717 100 vs_storage_release_page(base->posting_storage, head);
718
719
4/4
✓ Branch 0 taken 98 times.
✓ Branch 1 taken 2 times.
✓ Branch 2 taken 95 times.
✓ Branch 3 taken 3 times.
100 if (!live_head ||
720
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 95 times.
95 (target > 0 && (uint64_t)live <= prism_split_trigger(target)))
721 {
722 2 UnlockPage(index, head, ExclusiveLock);
723 2 return false;
724 }
725
726 98 PrismSplitEnv env = {
727 .fetch_vector = pg_split_fetch_vector,
728 .prefetch_vector = pg_prefetch_vector,
729 .retire_chain = pg_retire_chain,
730 .reserve_nlist = pg_reserve_nlist,
731 .ctx = fc,
732 };
733 /*
734 * Test hook: fires holding the old head's page lock, with the centroid
735 * leaf still pointing at it. That is the window an isolation test needs --
736 * an insert routed now reaches this head, blocks on the page lock, and
737 * once the split below has flipped the leaf and retired the chain it wakes
738 * to find the head retired and has to route again. Firing after the split
739 * instead would prove nothing: the leaf would already point elsewhere, so
740 * the insert would route straight to a new head and never contend.
741 *
742 * No-op unless PG was built with injection points and a test attached an
743 * action.
744 */
745 98 INJECTION_POINT("prism-split-locked", NULL);
746
747 98 PrismSplitConfig cfg = {
748 .target_entries = target,
749 .sample_budget_bytes = maint_memory_budget(),
750 };
751 98 int rc = prism_posting_split(base, head, &cfg, &env, res);
752
753 98 UnlockPage(index, head, ExclusiveLock);
754
755
2/4
✓ Branch 0 taken 98 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 98 times.
98 return rc == 0 && res->did_split;
756 }
757
758 /*
759 * Resolve the resting list size maintenance should aim at.
760 *
761 * Derived from the row count alone, deliberately ignoring the nlist
762 * reloption. Above the dimension's per-list target squared that is the target
763 * itself; below it the sqrt floor in prism_auto_nlist makes it smaller, which
764 * is the regime where a hardcoded target would fight the build and merge a
765 * small index down to too few lists.
766 *
767 * It used to honour an explicit nlist instead, on the grounds that a
768 * rebalanced index should keep the shape the build chose -- the cost model
769 * and the automatic nprobe both key off nlist. That is a real property and
770 * this gives it up: re-partitioning changes how many lists a query probes.
771 *
772 * What it bought was worse. With target = rows / nlist the target scales with
773 * the table, so an index built with an explicit nlist never splits on growth
774 * at all: lists grow in proportion, the trigger is never reached, and a pass
775 * correctly reports splitting nothing while every probe scans more entries
776 * than the last time. A number typed once at CREATE INDEX would silently
777 * switch off maintenance for the life of the index -- and once maintenance is
778 * VACUUM-driven, nothing would ever revisit it.
779 *
780 * A list should rest at the size that keeps a probe's cost flat, which is
781 * what prism_target_entries_per_dim is for. nlist stays what the user asked
782 * the *build* for; it is not a maintenance policy.
783 *
784 * target_pages, by contrast, IS read here, from the index's current
785 * reloptions rather than from whatever the build saw. It names the resting
786 * shape directly, so ALTER INDEX ... SET (target_pages = ...) followed by
787 * rebalance() is the supported way to re-partition an index without
788 * rebuilding it -- deliberate, unlike the nlist case above, where honouring
789 * the option silently switched maintenance off.
790 */
791 static uint32_t
792 4 resolve_target_entries(Relation heap, Relation index, Dimension dim)
793 {
794 4 const PrismOptions *opts = (const PrismOptions *)index->rd_options;
795
1/2
✓ Branch 0 taken 4 times.
✗ Branch 1 not taken.
4 uint32_t tpages = (opts != NULL) ? (uint32_t)opts->target_pages : 0;
796
797 4 return prism_target_entries_per_list(
798 prism_estimate_heap_tuples(heap), 0, dim, tpages);
799 }
800
801 /* Common setup: base + storage + heap fetch context. */
802 typedef struct MaintCtx
803 {
804 PrismIndexBase base;
805 VsPgStorage storage;
806 Relation heap;
807 PgSplitFetchCtx fetch;
808 ResourceOwner params_owner;
809 /*
810 * Scratch for one list's split. Splitting a list holds every one of its
811 * vectors at full precision, so the peak is inherent -- but a pass over
812 * many lists must not stack those peaks. Reset between lists (see
813 * maint_split_done) so the cost is one list's worth, not the pass's.
814 */
815 MemoryContext split_ctx;
816 } MaintCtx;
817
818 static void
819 42 maint_begin(Relation index, MaintCtx *m)
820 {
821 42 m->params_owner = CurrentResourceOwner;
822 42 prism_index_base_init(index, &m->base);
823 42 require_supported_shape(index, &m->base);
824
825 40 vs_pg_storage_init(&m->storage, index, NULL, m->base.metric);
826 40 m->base.centroid_storage = &m->storage.base;
827 40 m->base.posting_storage = &m->storage.base;
828 40 m->base.page_base = NULL;
829
830 40 m->heap = table_open(index->rd_index->indrelid, AccessShareLock);
831 40 m->fetch.heap = m->heap;
832 40 m->fetch.attnum = index->rd_index->indkey.values[0];
833 40 m->fetch.slot = table_slot_create(m->heap, NULL);
834 40 m->fetch.access = vec32_access(
835 40 prism_cache_type_info(index), m->base.dim, CurrentMemoryContext);
836 40 m->fetch.storage = &m->storage.base;
837
838 /* Created here, but not switched into: everything above has to outlive the
839 * per-list resets. */
840 40 m->split_ctx = AllocSetContextCreate(
841 CurrentMemoryContext, "prism split", ALLOCSET_DEFAULT_SIZES);
842 40 }
843
844 /*
845 * Run one list's split with its scratch accounted separately, and release that
846 * scratch before returning. Splitting is where a maintenance pass allocates,
847 * and one list is the logical point at which none of it is needed any more:
848 * the result is returned by value and the index state the caller keeps reading
849 * lives in the pass's own context.
850 */
851 static bool
852 100 split_one_head_in_scratch(
853 Relation index,
854 MaintCtx *m,
855 BlockNumber head,
856 uint32_t target,
857 PrismSplitResult *res)
858 {
859 100 MemoryContext old = MemoryContextSwitchTo(m->split_ctx);
860 100 bool did = split_one_head(index, &m->base, &m->fetch, head, target, res);
861 100 MemoryContextSwitchTo(old);
862 100 MemoryContextReset(m->split_ctx);
863 100 return did;
864 }
865
866 /*
867 * Drop the nlist reloption once maintenance has moved the leaf count away
868 * from it.
869 *
870 * A split restructures the index the way an ALTER plus REINDEX would, only
871 * incrementally and without the exclusive lock -- so the declaration has to
872 * stop claiming a width the index no longer has. Otherwise a later REINDEX
873 * rebuilds at the number typed at CREATE INDEX: an index built with
874 * nlist = 100, grown a hundredfold and split to match, comes back with 100
875 * lists, and no user could reasonably be expected to ALTER the right value in
876 * beforehand.
877 *
878 * Cleared rather than set to the new count. Writing 10000 in would assert the
879 * user asked for exactly 10000, which they did not, and it would be a fresh
880 * pin that goes stale on the next growth cycle. Removing it lets every later
881 * rebuild derive from the rows as they then are.
882 *
883 * Only when the reloption is set. Left out, auto-derivation already lands
884 * where the splits were heading, and writing anything in would convert "size
885 * this for my data" into a permanent pin.
886 *
887 * The tuple lock is required, not decoration: reloptions live in a pg_class
888 * column that is also updated in place (ANALYZE's reltuples, say), so a plain
889 * heap_update here can lose a concurrent inplace update. PostgreSQL warns
890 * about exactly that -- "missing lock for relation ... @ TID" -- and holding
891 * AccessExclusiveLock on the index does not satisfy it, because the lock it
892 * wants is on the catalog row. The index-level lock the pass already holds
893 * (PRISM_MAINT_LOCK, ShareUpdateExclusiveLock) is what ALTER INDEX ... SET
894 * takes, so no escalation is needed for the index itself.
895 */
896 static void
897 26 clear_nlist_reloption(Relation index)
898 {
899 26 PrismOptions *opts = (PrismOptions *)index->rd_options;
900
901
3/4
✓ Branch 0 taken 26 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 22 times.
26 if (opts == NULL || opts->nlist <= 0)
902 4 return; /* never declared: nothing to clear */
903
904 22 Relation pgclass = table_open(RelationRelationId, RowExclusiveLock);
905 22 HeapTuple tuple = SearchSysCacheCopy1(
906 RELOID, ObjectIdGetDatum(RelationGetRelid(index)));
907
908
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 22 times.
22 if (!HeapTupleIsValid(tuple))
909 ✗ elog(ERROR,
910 "cache lookup failed for index %u",
911 RelationGetRelid(index));
912
913 22 bool isnull;
914 22 Datum old =
915 22 SysCacheGetAttr(RELOID, tuple, Anum_pg_class_reloptions, &isnull);
916 22 List *reset = list_make1(makeDefElem(pstrdup("nlist"), NULL, -1));
917
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
44 Datum newopts = transformRelOptions(
918 isnull ? (Datum)0 : old, reset, NULL, NULL, false, true);
919
920 22 Datum repl_val[Natts_pg_class] = {0};
921 22 bool repl_null[Natts_pg_class] = {false};
922 22 bool repl_repl[Natts_pg_class] = {false};
923
924
1/2
✓ Branch 0 taken 22 times.
✗ Branch 1 not taken.
22 if (DatumGetPointer(newopts) != NULL)
925 22 repl_val[Anum_pg_class_reloptions - 1] = newopts;
926 else
927 ✗ repl_null[Anum_pg_class_reloptions - 1] = true;
928 22 repl_repl[Anum_pg_class_reloptions - 1] = true;
929
930 22 HeapTuple newtuple = heap_modify_tuple(
931 tuple, RelationGetDescr(pgclass), repl_val, repl_null, repl_repl);
932
933 /*
934 * Keep the old tid: CatalogTupleUpdate rewrites newtuple->t_self to where
935 * the new version lands, so unlocking through it would release a lock on
936 * the wrong row and leave this one held at commit.
937 */
938 22 ItemPointerData otid = newtuple->t_self;
939
940 22 LockTuple(pgclass, &otid, InplaceUpdateTupleLock);
941 22 CatalogTupleUpdate(pgclass, &otid, newtuple);
942 22 UnlockTuple(pgclass, &otid, InplaceUpdateTupleLock);
943
944 22 heap_freetuple(newtuple);
945 22 heap_freetuple(tuple);
946 22 table_close(pgclass, RowExclusiveLock);
947 }
948
949 static void
950 39 maint_end(Relation index, MaintCtx *m, bool changed)
951 {
952
2/2
✓ Branch 0 taken 26 times.
✓ Branch 1 taken 13 times.
39 if (changed)
953 {
954 26 persist_nlist(&m->storage.base, m->base.nlist);
955 26 persist_ncentroid_pages(&m->storage.base, m->base.ncentroid_pages);
956 /* The declaration no longer describes the index -- see
957 * clear_nlist_reloption. */
958 26 clear_nlist_reloption(index);
959 /* Drop the cached metapage so later queries see the new leaf count. */
960 26 CacheInvalidateRelcache(index);
961 }
962 39 MemoryContextDelete(m->split_ctx);
963 39 ExecDropSingleTupleTableSlot(m->fetch.slot);
964 39 table_close(m->heap, AccessShareLock);
965 39 prism_release_params(m->base.dim, m->base.rabitq_seed, m->params_owner);
966 39 }
967
968 /* ----------------------------------------------------------------
969 * SQL entry points
970 * ---------------------------------------------------------------- */
971
972 /*
973 * CALL prism_split_posting_list(index regclass, head_blkno bigint)
974 *
975 * Split the single posting list whose head page is head_blkno. Reports via
976 * NOTICE whether a split happened or was declined (not a live head, too few
977 * entries, or degenerate data).
978 */
979 Datum
980 9 vs_split_posting_list(PG_FUNCTION_ARGS)
981 {
982 9 require_own_transaction(fcinfo, "prism_split_posting_list()");
983 9 reject_null_arg(fcinfo, 0, "index_oid");
984 8 reject_null_arg(fcinfo, 1, "head_blkno");
985
986 7 Oid indexoid = PG_GETARG_OID(0);
987 7 int64 blk64 = PG_GETARG_INT64(1);
988 7 Relation index = relation_open(indexoid, PRISM_MAINT_LOCK);
989
990
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 6 times.
7 if (index->rd_rel->relkind != RELKIND_INDEX)
991 {
992 1 relation_close(index, PRISM_MAINT_LOCK);
993
1/2
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
1 ereport(ERROR,
994 (errcode(ERRCODE_WRONG_OBJECT_TYPE),
995 errmsg("\"%s\" is not an index",
996 RelationGetRelationName(index))));
997 }
998 6 require_index_owner(index, PRISM_MAINT_LOCK);
999
1000 5 BlockNumber nblocks = RelationGetNumberOfBlocks(index);
1001
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 5 times.
5 if (blk64 < 1 || blk64 >= (int64)nblocks)
1002 {
1003 ✗ relation_close(index, PRISM_MAINT_LOCK);
1004 ✗ ereport(ERROR,
1005 (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
1006 errmsg("block number " INT64_FORMAT " out of range", blk64)));
1007 }
1008
1009 5 MaintCtx m;
1010 5 maint_begin(index, &m);
1011 5 require_memory_budget(index, m.base.dim);
1012
1013 5 PrismSplitResult res;
1014 5 bool did =
1015 5 split_one_head_in_scratch(index, &m, (BlockNumber)blk64, 0, &res);
1016
1017 5 maint_end(index, &m, did);
1018 /*
1019 * Keep PRISM_MAINT_LOCK until end of transaction (NoLock here releases the
1020 * reference, not the lock). maint_end queues a relcache invalidation for
1021 * the new leaf count, and that is only delivered at commit -- release the
1022 * lock now and the next maintenance call could take it, still holding a
1023 * cached index base with the stale count, and mint cluster ids that
1024 * collide with the ones just written.
1025 */
1026 5 relation_close(index, NoLock);
1027
1028
2/2
✓ Branch 0 taken 3 times.
✓ Branch 1 taken 2 times.
5 if (did)
1029
1/2
✓ Branch 1 taken 3 times.
✗ Branch 2 not taken.
3 ereport(NOTICE,
1030 (errmsg("split posting list at block " INT64_FORMAT
1031 " into %u lists",
1032 blk64,
1033 res.nparts)));
1034 else
1035 /*
1036 * split_one_head declines for several reasons -- not a live head, too
1037 * few entries, or degenerate data yielding an empty k-means cluster --
1038 * so report the outcome without guessing which.
1039 */
1040
1/2
✓ Branch 1 taken 2 times.
✗ Branch 2 not taken.
2 ereport(NOTICE,
1041 (errmsg("posting list at block " INT64_FORMAT " not split",
1042 blk64)));
1043
1044 5 PG_RETURN_VOID();
1045 }
1046
1047 /* ----------------------------------------------------------------
1048 * CALL prism_rebalance(index regclass, target_entries int4 DEFAULT NULL)
1049 *
1050 * Manual LIRE rebalancing entry point. Scans the index and splits every live
1051 * posting-list head that has grown past the split trigger into lists of about
1052 * target_entries entries each, and physically retires any already-DELETED old
1053 * split chain whose delete_xid has cleared the global visibility horizon.
1054 * Reports the number of lists split via NOTICE. New heads created during the
1055 * pass land past the snapshotted block count and are left for a later call.
1056 *
1057 * target_entries is the size a list *rests* at, not a bound it never crosses.
1058 * A list is left alone until it reaches target_entries *
1059 * PRISM_SPLIT_TRIGGER_FACTOR, so the operating band is
1060 * [target/factor, target*factor] with the target at its centre: room to absorb
1061 * inserts and deletes, and room for the unevenness of a k-means split.
1062 * Pinning the trigger at the target instead
1063 * would leave every fresh list one insert away from splitting again.
1064 *
1065 * NULL derives the target from the index itself (see resolve_target_entries),
1066 * which is what an operator should almost always want; passing a value is an
1067 * override.
1068 *
1069 * Runs in a single transaction, so a chain this pass retires does not become
1070 * reclaimable within it: the pass's own XID cannot clear the visibility
1071 * horizon while it is still running. Reclaiming those chains therefore falls
1072 * to a subsequent call.
1073 *
1074 * Splitting is the only rebalancing performed here, and it is driven by the
1075 * caller -- nothing schedules it.
1076 * ---------------------------------------------------------------- */
1077 Datum
1078 42 vs_rebalance(PG_FUNCTION_ARGS)
1079 {
1080 42 require_own_transaction(fcinfo, "prism_rebalance()");
1081 40 reject_null_arg(fcinfo, 0, "index_oid");
1082
1083
2/2
✓ Branch 0 taken 34 times.
✓ Branch 1 taken 5 times.
39 Oid indexoid = PG_GETARG_OID(0);
1084 /* SQL arg is integer (int4). Unlike the index, a null target is
1085 * meaningful: it asks for the size to be derived from the index. */
1086 39 bool target_given = !PG_ARGISNULL(1);
1087
2/2
✓ Branch 0 taken 34 times.
✓ Branch 1 taken 5 times.
39 int32 target_arg = target_given ? PG_GETARG_INT32(1) : 0;
1088 39 Relation index = relation_open(indexoid, PRISM_MAINT_LOCK);
1089
1090
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 39 times.
39 if (index->rd_rel->relkind != RELKIND_INDEX)
1091 {
1092 ✗ relation_close(index, PRISM_MAINT_LOCK);
1093 ✗ ereport(ERROR,
1094 (errcode(ERRCODE_WRONG_OBJECT_TYPE),
1095 errmsg("\"%s\" is not an index",
1096 RelationGetRelationName(index))));
1097 }
1098 39 require_index_owner(index, PRISM_MAINT_LOCK);
1099
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 37 times.
38 if (target_given && target_arg < 1)
1100 {
1101 1 relation_close(index, PRISM_MAINT_LOCK);
1102
1/2
✓ Branch 1 taken 1 times.
✗ Branch 2 not taken.
1 ereport(ERROR,
1103 (errcode(ERRCODE_INVALID_PARAMETER_VALUE),
1104 errmsg("target_entries must be positive")));
1105 }
1106
1107 37 MaintCtx m;
1108 37 maint_begin(index, &m);
1109 35 require_memory_budget(index, m.base.dim);
1110
1111 68 uint32_t target =
1112 target_given ? (uint32_t)target_arg
1113
2/2
✓ Branch 0 taken 4 times.
✓ Branch 1 taken 30 times.
34 : resolve_target_entries(m.heap, index, m.base.dim);
1114 34 uint64_t trigger = prism_split_trigger(target);
1115
1116 34 BlockNumber nblocks = RelationGetNumberOfBlocks(index);
1117 34 BlockNumber start = Max(m.base.first_posting, 1);
1118 34 int32 nsplits = 0;
1119 34 int32 nreclaimed = 0;
1120
1121
2/2
✓ Branch 0 taken 332 times.
✓ Branch 1 taken 34 times.
366 for (BlockNumber blk = start; blk < nblocks; blk++)
1122 {
1123
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 332 times.
332 CHECK_FOR_INTERRUPTS();
1124
1125 332 Page p = vs_storage_read_page(&m.storage.base, blk);
1126 332 bool posting = prism_page_is_posting(p);
1127 986 const PrismPostingPageOpaque *op = posting ? prism_posting_opaque(p)
1128
2/2
✓ Branch 0 taken 322 times.
✓ Branch 1 taken 10 times.
332 : NULL;
1129
1130
2/2
✓ Branch 0 taken 216 times.
✓ Branch 1 taken 106 times.
322 bool is_head = posting && (op->flags & PRISM_POSTING_PAGE_FIRST) &&
1131 !(op->flags & PRISM_POSTING_PAGE_TOMBSTONED);
1132
2/2
✓ Branch 0 taken 193 times.
✓ Branch 1 taken 23 times.
216 bool retired = is_head && (op->flags & PRISM_POSTING_PAGE_DELETED);
1133
2/2
✓ Branch 0 taken 193 times.
✓ Branch 1 taken 139 times.
332 bool candidate = is_head && !retired &&
1134
2/2
✓ Branch 0 taken 98 times.
✓ Branch 1 taken 95 times.
193 (uint64_t)op->live_count > trigger;
1135 /* delete_xid overlays live_count and is meaningful only when DELETED.
1136 */
1137
2/2
✓ Branch 0 taken 23 times.
✓ Branch 1 taken 309 times.
332 uint64 dxid = retired ? op->delete_xid : 0;
1138 332 vs_storage_release_page(&m.storage.base, blk);
1139
1140
2/2
✓ Branch 0 taken 95 times.
✓ Branch 1 taken 237 times.
332 if (candidate)
1141 {
1142 /* Pass the target so split_one_head re-checks under the head
1143 * lock and prism_posting_split re-checks again after collection --
1144 * the list may have shrunk since the unlocked read above, and
1145 * live_count does not see unfetchable entries at all. */
1146 95 PrismSplitResult res;
1147
1/2
✓ Branch 1 taken 95 times.
✗ Branch 2 not taken.
95 if (split_one_head_in_scratch(index, &m, blk, target, &res))
1148 95 nsplits++;
1149 }
1150
2/2
✓ Branch 0 taken 214 times.
✓ Branch 1 taken 23 times.
237 else if (
1151
2/2
✓ Branch 1 taken 3 times.
✓ Branch 2 taken 20 times.
23 retired && GlobalVisCheckRemovableFullXid(
1152 m.heap, FullTransactionIdFromU64(dxid)))
1153 {
1154 /* No snapshot can still hold a stale pointer into this chain, so
1155 * it is safe to physically retire it (its own commits are durable
1156 * independent of maint_end, which only persists nlist). */
1157 20 prism_posting_chain_tombstone(&m.storage.base, blk);
1158 20 nreclaimed++;
1159 }
1160 }
1161
1162 34 maint_end(index, &m, nsplits > 0);
1163 /*
1164 * Keep PRISM_MAINT_LOCK until end of transaction (NoLock here releases the
1165 * reference, not the lock). maint_end queues a relcache invalidation for
1166 * the new leaf count, and that is only delivered at commit -- release the
1167 * lock now and the next maintenance call could take it, still holding a
1168 * cached index base with the stale count, and mint cluster ids that
1169 * collide with the ones just written.
1170 */
1171 34 relation_close(index, NoLock);
1172
1173 /*
1174 * Report the reclaim count as well as the split count. A retired chain is
1175 * unreachable from the centroid tree, so nothing that inspects the index
1176 * can see it -- this NOTICE is the only way an operator can tell whether a
1177 * pass freed the chains an earlier one left behind, or whether a reader's
1178 * snapshot is still holding them.
1179 */
1180
2/2
✓ Branch 1 taken 30 times.
✓ Branch 2 taken 4 times.
34 ereport(NOTICE,
1181 (errmsg("rebalance: split %d posting list(s), reclaimed %d "
1182 "retired chain(s)",
1183 nsplits,
1184 nreclaimed)));
1185
1186 34 PG_RETURN_VOID();
1187 }
1188