diff --git a/src/backend/cluster/Makefile b/src/backend/cluster/Makefile index 4aafcf0b59..de8696a186 100644 --- a/src/backend/cluster/Makefile +++ b/src/backend/cluster/Makefile @@ -58,6 +58,9 @@ OBJS = \ cluster_cssd.o \ cluster_debug.o \ cluster_diag.o \ + cluster_drm_affinity.o \ + cluster_drm_decision.o \ + cluster_drm_scan.o \ cluster_elog.o \ cluster_epoch.o \ cluster_extend_gate.o \ diff --git a/src/backend/cluster/cluster_debug.c b/src/backend/cluster/cluster_debug.c index 1e8cd28be9..59ad06d933 100644 --- a/src/backend/cluster/cluster_debug.c +++ b/src/backend/cluster/cluster_debug.c @@ -119,7 +119,9 @@ PG_FUNCTION_INFO_V1(cluster_dump_state); #include "cluster/cluster_undo_cleaner.h" /* dump_undo_cleaner (spec-3.13 D1) */ #include "cluster/cluster_lmon.h" /* cluster_lmon_status (spec-1.11 Sprint B D12) */ #include "cluster/cluster_guc.h" -#include "catalog/pg_control.h" /* DBState (spec-4.3 plan dump) */ +#include "cluster/cluster_drm_affinity.h" /* spec-7.6 6.3b — drm_affinity dump */ +#include "cluster/cluster_drm_decision.h" /* spec-7.6 6.3c — reason names for scan tally */ +#include "catalog/pg_control.h" /* DBState (spec-4.3 plan dump) */ #include "cluster/cluster_recovery_plan.h" #include "cluster/cluster_recovery_worker.h" #include "cluster/cluster_recovery_merge.h" /* is_materialized (spec-4.5a D11) */ @@ -3150,6 +3152,57 @@ dump_xnode_profile(ReturnSetInfo *rsinfo) * (values stay 0) so the key surface is stable for tests and samplers; * later waves append keys, never rename (5.59 Q9-B category paradigm). */ +/* + * dump_drm_affinity -- spec-7.6 6.3b DRM affinity collection observability. + * Pure counter surface (append-only keys; later waves add decision / anti- + * thrash keys, never rename — 5.59 Q9-B category paradigm). + */ +static void +dump_drm_affinity(ReturnSetInfo *rsinfo) +{ + emit_row(rsinfo, "drm_affinity", "drm_enabled", fmt_int64(cluster_drm_enabled ? 1 : 0)); + emit_row(rsinfo, "drm_affinity", "sample_epoch", + fmt_int64((int64)cluster_drm_affinity_get_sample_epoch())); + emit_row(rsinfo, "drm_affinity", "samples_recorded", + fmt_int64((int64)cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_RECORDED))); + emit_row(rsinfo, "drm_affinity", "samples_local", + fmt_int64((int64)cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_LOCAL))); + emit_row(rsinfo, "drm_affinity", "samples_remote", + fmt_int64((int64)cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_REMOTE))); + emit_row( + rsinfo, "drm_affinity", "samples_skipped_off", + fmt_int64((int64)cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_SKIPPED_OFF))); + emit_row( + rsinfo, "drm_affinity", "samples_dropped_stale_epoch", + fmt_int64((int64)cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_DROPPED_STALE))); + emit_row( + rsinfo, "drm_affinity", "flush_batches", + fmt_int64((int64)cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_FLUSH_BATCHES))); + + /* spec-7.6 6.3c decision-scan surface: run / candidate / proposal totals plus + * a per-reason verdict tally (stable key "scan_" + ClusterDrmReason name). */ + emit_row( + rsinfo, "drm_affinity", "scans_run", + fmt_int64((int64)cluster_drm_affinity_get_scan_counter(CLUSTER_DRM_AFFINITY_SCAN_RUNS))); + emit_row(rsinfo, "drm_affinity", "scan_candidates", + fmt_int64((int64)cluster_drm_affinity_get_scan_counter( + CLUSTER_DRM_AFFINITY_SCAN_CANDIDATES))); + emit_row(rsinfo, "drm_affinity", "scan_proposed", + fmt_int64( + (int64)cluster_drm_affinity_get_scan_counter(CLUSTER_DRM_AFFINITY_SCAN_PROPOSED))); + { + int r; + + for (r = 0; r < DRM_REASON__COUNT; r++) { + char key[64]; + + snprintf(key, sizeof(key), "scan_%s", cluster_drm_reason_name(r)); + emit_row(rsinfo, "drm_affinity", key, + fmt_int64((int64)cluster_drm_affinity_get_scan_reason(r))); + } + } +} + static void dump_xnode_lever(ReturnSetInfo *rsinfo) { @@ -3321,6 +3374,7 @@ cluster_dump_state(PG_FUNCTION_ARGS) dump_write_fence(rsinfo); dump_hw(rsinfo); dump_dl(rsinfo); + dump_drm_affinity(rsinfo); /* spec-7.6 6.3b */ dump_ir(rsinfo); dump_ts(rsinfo); dump_ko(rsinfo); diff --git a/src/backend/cluster/cluster_drm_affinity.c b/src/backend/cluster/cluster_drm_affinity.c new file mode 100644 index 0000000000..d068fca169 --- /dev/null +++ b/src/backend/cluster/cluster_drm_affinity.c @@ -0,0 +1,643 @@ +/*------------------------------------------------------------------------- + * + * cluster_drm_affinity.c + * pgrac DRM per-shard access-affinity collection substrate. + * + * DRM (Dynamic Resource Mastering) migrates a hot shard's GES/PCM master + * to the node that accesses it most. This module is the collection half + * (spec-7.6 wave 6.3b): it samples first-logical lock requests and builds, + * ON THE CURRENT MASTER, a per-shard x per-node access matrix from which the + * decision engine (wave 6.3c) picks migration candidates. + * + * Design (spec-7.6 Amend v1.1-a / v1.1.2): + * - Current-master authoritative: only the node that masters a shard sees + * every node's requests for it (remote via env->source_node_id, local via + * cluster_node_id), so it alone can compute a dominant-node ratio. The + * admission-side call sites live in cluster_ges.c (D2); this module takes + * shard_id + requesting_node directly and has NO GRD/GES linkage. + * - INV-DRM9: pure statistics. Nothing here mutates master/lock/GRD state. + * - Per-backend sampling: a backend-local countdown + xorshift PRNG decides + * 1/N hits with no shared atomic or modulo on the hot path. Sampled hits + * buffer in a backend-local ring, batch-flushed into the shared matrix. + * - Full 4096-slot array is always resident (no candidate admission); a + * dirty-shard bitmap (atomic) bounds the LMON scan to touched shards. + * - sample_epoch (LMON single-writer) is bumped when the sample rate + * changes; ring entries carry the epoch they were taken at so a stale + * batch is dropped, and a shard's window is discarded when its sampled + * epoch no longer matches (Amend v1.1.2 R4/R5). + * + * See spec-7.6-drm-hot-resource-detection-remaster.md (§2.1/§3.1, Amend + * v1.1-a / v1.1.2) for the frozen contract. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/backend/cluster/cluster_drm_affinity.c + * + * NOTES + * pgrac-original file. All exported symbols use the cluster_drm_ prefix. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/xact.h" +#include "miscadmin.h" +#include "storage/ipc.h" +#include "storage/shmem.h" + +#include "cluster/cluster_drm_affinity.h" +#include "cluster/cluster_guc.h" +#include "cluster/cluster_shmem.h" + +/* Backend-local sample ring capacity (batched flush into the shared matrix). */ +#define DRM_LOCAL_RING 64 + +/* --- module shared state (bound in cluster_drm_affinity_shmem_init) --- */ +static ClusterDrmAffinityShared *drm_state = NULL; + +/* --- per-backend sampling state (no shared atomics on the decision) --- */ +typedef struct DrmRingEntry { + uint64 sample_epoch; + uint32 shard; + int32 node; + bool was_remote; +} DrmRingEntry; + +static DrmRingEntry drm_ring[DRM_LOCAL_RING]; +static int drm_ring_len = 0; +static int drm_countdown = 0; +static uint32 drm_prng = 0; +static bool drm_prng_seeded = false; +static bool drm_backend_hooks_registered = false; + +/* ---------- + * Shmem lifecycle + * ---------- + */ + +Size +cluster_drm_affinity_shmem_size(void) +{ + return MAXALIGN(sizeof(ClusterDrmAffinityShared)); +} + +void +cluster_drm_affinity_shmem_init(void) +{ + bool found; + + drm_state = (ClusterDrmAffinityShared *)ShmemInitStruct( + "pgrac cluster drm affinity", cluster_drm_affinity_shmem_size(), &found); + + if (!found) { + int nwords = (PGRAC_GRD_SHARD_COUNT + 63) / 64; + int initial_rate + = (cluster_drm_affinity_sample_rate < 1) ? 1 : cluster_drm_affinity_sample_rate; + int i; + + /* + * sample_epoch starts at 1 so a never-sampled slot (window_sample_epoch + * == 0) reads as stale and is skipped until its first real sample. + */ + pg_atomic_init_u64(&drm_state->sample_epoch, 1); + pg_atomic_init_u64(&drm_state->active_sample_rate, (uint64)initial_rate); + pg_atomic_init_u64(&drm_state->samples_recorded, 0); + pg_atomic_init_u64(&drm_state->samples_local, 0); + pg_atomic_init_u64(&drm_state->samples_remote, 0); + pg_atomic_init_u64(&drm_state->samples_skipped_off, 0); + pg_atomic_init_u64(&drm_state->samples_dropped_stale_epoch, 0); + pg_atomic_init_u64(&drm_state->flush_batches, 0); + + /* spec-7.6 6.3c decision-scan observability counters. */ + pg_atomic_init_u64(&drm_state->scans_run, 0); + pg_atomic_init_u64(&drm_state->scan_candidates, 0); + pg_atomic_init_u64(&drm_state->scan_proposed, 0); + for (i = 0; i < CLUSTER_DRM_SCAN_REASON_SLOTS; i++) + pg_atomic_init_u64(&drm_state->scan_reason[i], 0); + + for (i = 0; i < nwords; i++) + pg_atomic_init_u64(&drm_state->dirty_shard_bitmap[i], 0); + + for (i = 0; i < PGRAC_GRD_SHARD_COUNT; i++) { + ClusterDrmShardAffinity *slot = &drm_state->shard[i]; + int k; + + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_init_u32(&slot->access_count[k], 0); + pg_atomic_init_u32(&slot->ewma_total, 0); + pg_atomic_init_u64(&slot->window_start_ts, 0); + pg_atomic_init_u64(&slot->window_cluster_epoch, 0); + pg_atomic_init_u32(&slot->window_master_generation, 0); + pg_atomic_init_u64(&slot->window_sample_epoch, 0); + slot->consecutive_hot_windows = 0; + slot->last_migration_ts = 0; + } + } +} + +static const ClusterShmemRegion cluster_drm_affinity_region = { + .name = "pgrac cluster drm affinity", + .size_fn = cluster_drm_affinity_shmem_size, + .init_fn = cluster_drm_affinity_shmem_init, + .lwlock_count = 0, /* lock-free: atomic matrix + atomic dirty bitmap */ + .owner_subsys = "spec-7.6 DRM affinity", + .reserved_flags = 0, +}; + +void +cluster_drm_affinity_shmem_register(void) +{ + cluster_shmem_register_region(&cluster_drm_affinity_region); +} + +/* ---------- + * Per-backend flush lifecycle (Amend v1.1.2 R4.3) + * ---------- + */ + +static void +drm_xact_callback(XactEvent event pg_attribute_unused(), void *arg pg_attribute_unused()) +{ + /* Flush at every transaction boundary so buffered samples never linger + * across a rate change longer than one transaction. */ + cluster_drm_affinity_flush_local_ring(); +} + +static void +drm_backend_exit_callback(int code pg_attribute_unused(), Datum arg pg_attribute_unused()) +{ + cluster_drm_affinity_flush_local_ring(); +} + +void +cluster_drm_affinity_register_backend_hooks(void) +{ + if (drm_backend_hooks_registered) + return; + if (!IsUnderPostmaster) + return; /* real backends only */ + if (MyBackendId <= 0) + return; /* not aux processes */ + + RegisterXactCallback(drm_xact_callback, NULL); + before_shmem_exit(drm_backend_exit_callback, (Datum)0); + drm_backend_hooks_registered = true; +} + +/* ---------- + * Sampling (admission hot path, spec-7.6 D2 calls this) + * ---------- + */ + +void +cluster_drm_affinity_reset_local_sampling(void) +{ + drm_ring_len = 0; + drm_countdown = 0; + drm_prng_seeded = false; +} + +/* xorshift32 — per-backend PRNG, never a shared/global RNG (Amend v1.1-a③). */ +static inline uint32 +drm_prng_next(void) +{ + drm_prng ^= drm_prng << 13; + drm_prng ^= drm_prng >> 17; + drm_prng ^= drm_prng << 5; + return drm_prng; +} + +void +cluster_drm_affinity_sample(uint32 shard_id, int32 requesting_node, bool was_remote) +{ + int rate; + + if (!cluster_drm_enabled) { + if (drm_state != NULL) + pg_atomic_fetch_add_u64(&drm_state->samples_skipped_off, 1); + return; + } + if (drm_state == NULL) + return; + if (shard_id >= PGRAC_GRD_SHARD_COUNT || requesting_node < 0 + || requesting_node >= CLUSTER_MAX_NODES) + return; + + if (!drm_prng_seeded) { + /* Seed per-backend so different backends sample independent hits. */ + drm_prng = ((uint32)MyBackendId * 2654435761u) ^ 0x9e3779b9u; + if (drm_prng == 0) + drm_prng = 0x1234567u; + drm_prng_seeded = true; + drm_countdown = 0; /* first eligible hit records */ + + /* Lazily arm the flush hooks the first time this backend samples (no-op + * in aux processes, which flush on ring-full / the LMON drain instead). */ + cluster_drm_affinity_register_backend_hooks(); + } + + /* Cheapest common path: not a sample tick. */ + if (--drm_countdown > 0) + return; + + /* Reseed the countdown to average ~rate hits between samples (1/N). */ + rate = cluster_drm_affinity_sample_rate; + if (rate < 1) + rate = 1; + drm_countdown = 1 + (int)(drm_prng_next() % (uint32)(2 * rate - 1)); + + if (drm_ring_len >= DRM_LOCAL_RING) + cluster_drm_affinity_flush_local_ring(); + + drm_ring[drm_ring_len].sample_epoch = pg_atomic_read_u64(&drm_state->sample_epoch); + drm_ring[drm_ring_len].shard = shard_id; + drm_ring[drm_ring_len].node = requesting_node; + drm_ring[drm_ring_len].was_remote = was_remote; + drm_ring_len++; +} + +/* ---------- + * Flush the backend-local ring into the shared matrix + * ---------- + */ + +static void +drm_apply_sample(uint64 cur_epoch, uint32 shard, int32 node, bool was_remote) +{ + ClusterDrmShardAffinity *slot = &drm_state->shard[shard]; + uint64 wse = pg_atomic_read_u64(&slot->window_sample_epoch); + + /* + * Window rolled (sample_epoch bumped): the first flusher to swap the slot's + * window identity to the current epoch clears its stale per-node counts. + * Lazy reset — reconcile does not sweep 2 MiB of counters (Amend v1.1.2 R4). + */ + if (wse != cur_epoch) { + if (pg_atomic_compare_exchange_u64(&slot->window_sample_epoch, &wse, cur_epoch)) { + int k; + + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_write_u32(&slot->access_count[k], 0); + } + } + + pg_atomic_fetch_add_u32(&slot->access_count[node], 1); + pg_atomic_fetch_or_u64(&drm_state->dirty_shard_bitmap[shard / 64], UINT64CONST(1) + << (shard % 64)); + pg_atomic_fetch_add_u64(&drm_state->samples_recorded, 1); + /* was_remote splits the counters for observability only (Amend v1.1-a①). */ + if (was_remote) + pg_atomic_fetch_add_u64(&drm_state->samples_remote, 1); + else + pg_atomic_fetch_add_u64(&drm_state->samples_local, 1); +} + +void +cluster_drm_affinity_flush_local_ring(void) +{ + uint64 cur_epoch; + int i; + + if (drm_state == NULL || drm_ring_len == 0) + return; + + cur_epoch = pg_atomic_read_u64(&drm_state->sample_epoch); + pg_atomic_fetch_add_u64(&drm_state->flush_batches, 1); + + for (i = 0; i < drm_ring_len; i++) { + if (drm_ring[i].sample_epoch != cur_epoch) { + /* Stale batch: the rate changed after this sample was taken. */ + pg_atomic_fetch_add_u64(&drm_state->samples_dropped_stale_epoch, 1); + continue; + } + drm_apply_sample(cur_epoch, drm_ring[i].shard, drm_ring[i].node, drm_ring[i].was_remote); + } + + drm_ring_len = 0; +} + +/* ---------- + * LMON single-writer: reconcile the sample rate (Amend v1.1.2 R4.2) + * ---------- + */ + +bool +cluster_drm_affinity_reconcile_sample_rate(void) +{ + int cur_rate; + uint64 last_rate; + int nwords; + int w; + + if (drm_state == NULL) + return false; + + cur_rate = cluster_drm_affinity_sample_rate; + if (cur_rate < 1) + cur_rate = 1; + last_rate = pg_atomic_read_u64(&drm_state->active_sample_rate); + + if ((uint64)cur_rate == last_rate) + return false; + + /* + * Rate changed. Bump sample_epoch (in-flight backend batches at the old + * rate are dropped on flush) and clear the dirty bitmap so the scan does + * not revisit slots whose windows are now stale; per-shard counts are reset + * lazily on their next sample (drm_apply_sample). Single LMON writer, so no + * lock is needed for the epoch bump / rate store. + */ + pg_atomic_fetch_add_u64(&drm_state->sample_epoch, 1); + nwords = (PGRAC_GRD_SHARD_COUNT + 63) / 64; + for (w = 0; w < nwords; w++) + pg_atomic_write_u64(&drm_state->dirty_shard_bitmap[w], 0); + pg_atomic_write_u64(&drm_state->active_sample_rate, (uint64)cur_rate); + return true; +} + +/* ---------- + * Candidate collection (pure read — INV-DRM9) + * ---------- + */ + +int +cluster_drm_affinity_collect_candidates(uint32 *out_shard_ids, int max) +{ + uint64 cur_epoch; + int rate; + int min_access; + int nwords; + int n = 0; + int w; + + if (drm_state == NULL || out_shard_ids == NULL || max <= 0) + return 0; + + cur_epoch = pg_atomic_read_u64(&drm_state->sample_epoch); + rate = cluster_drm_affinity_sample_rate; + if (rate < 1) + rate = 1; + min_access = cluster_drm_min_access_count; + nwords = (PGRAC_GRD_SHARD_COUNT + 63) / 64; + + for (w = 0; w < nwords && n < max; w++) { + uint64 word = pg_atomic_read_u64(&drm_state->dirty_shard_bitmap[w]); + int b; + + if (word == 0) + continue; + + for (b = 0; b < 64 && n < max; b++) { + uint32 shard; + ClusterDrmShardAffinity *slot; + uint64 total = 0; + int k; + + if ((word & (UINT64CONST(1) << b)) == 0) + continue; + + shard = (uint32)(w * 64 + b); + if (shard >= PGRAC_GRD_SHARD_COUNT) + continue; + + slot = &drm_state->shard[shard]; + /* Skip a shard whose window predates the current sample_epoch. */ + if (pg_atomic_read_u64(&slot->window_sample_epoch) != cur_epoch) + continue; + + for (k = 0; k < CLUSTER_MAX_NODES; k++) + total += pg_atomic_read_u32(&slot->access_count[k]); + + /* Normalize the sampled total by the sample rate (Amend v1.1-a⑤). */ + if (total * (uint64)rate >= (uint64)min_access) + out_shard_ids[n++] = shard; + } + } + + return n; +} + +/* ---------- + * Read accessors (dump category + unit tests) — pure reads + * ---------- + */ + +uint32 +cluster_drm_affinity_access_count(uint32 shard_id, int32 node) +{ + if (drm_state == NULL || shard_id >= PGRAC_GRD_SHARD_COUNT || node < 0 + || node >= CLUSTER_MAX_NODES) + return 0; + return pg_atomic_read_u32(&drm_state->shard[shard_id].access_count[node]); +} + +uint64 +cluster_drm_affinity_get_sample_epoch(void) +{ + if (drm_state == NULL) + return 0; + return pg_atomic_read_u64(&drm_state->sample_epoch); +} + +uint64 +cluster_drm_affinity_get_counter(int which) +{ + if (drm_state == NULL) + return 0; + switch (which) { + case CLUSTER_DRM_AFFINITY_CTR_RECORDED: + return pg_atomic_read_u64(&drm_state->samples_recorded); + case CLUSTER_DRM_AFFINITY_CTR_SKIPPED_OFF: + return pg_atomic_read_u64(&drm_state->samples_skipped_off); + case CLUSTER_DRM_AFFINITY_CTR_DROPPED_STALE: + return pg_atomic_read_u64(&drm_state->samples_dropped_stale_epoch); + case CLUSTER_DRM_AFFINITY_CTR_FLUSH_BATCHES: + return pg_atomic_read_u64(&drm_state->flush_batches); + case CLUSTER_DRM_AFFINITY_CTR_LOCAL: + return pg_atomic_read_u64(&drm_state->samples_local); + case CLUSTER_DRM_AFFINITY_CTR_REMOTE: + return pg_atomic_read_u64(&drm_state->samples_remote); + default: + return 0; + } +} + +/* ---------- + * Tumbling window management (spec-7.6 6.3c, Amend v1.1-b / R5) + * + * These operate on a PASSED slot and mutate only that slot (INV-DRM9: they never + * touch master/lock/GRD state), so they are unit-testable with a stack slot and + * add no GRD/decision linkage to this module. The LMON scan driver + * (cluster_drm_scan.c) supplies now_us / window_us / thresholds / identity. + * ---------- + */ + +/* + * A window not judged within this many window widths means the shard went cold + * and dropped out of the candidate scan; treat it as a streak discontinuity. + */ +#define DRM_WINDOW_OVERDUE_FACTOR 2 + +bool +cluster_drm_window_is_open(const ClusterDrmShardAffinity *slot) +{ + if (slot == NULL) + return false; + return pg_atomic_read_u64(unconstify(pg_atomic_uint64 *, &slot->window_start_ts)) != 0; +} + +bool +cluster_drm_window_due(const ClusterDrmShardAffinity *slot, uint64 now_us, uint64 window_us) +{ + uint64 ws; + + if (slot == NULL) + return false; + ws = pg_atomic_read_u64(unconstify(pg_atomic_uint64 *, &slot->window_start_ts)); + if (ws == 0) + return false; + return now_us > ws && (now_us - ws) >= window_us; +} + +void +cluster_drm_window_open(ClusterDrmShardAffinity *slot, uint64 now_us, uint64 cluster_epoch, + uint32 master_generation) +{ + if (slot == NULL) + return; + pg_atomic_write_u64(&slot->window_start_ts, now_us); + pg_atomic_write_u64(&slot->window_cluster_epoch, cluster_epoch); + pg_atomic_write_u32(&slot->window_master_generation, master_generation); +} + +bool +cluster_drm_window_judge(ClusterDrmShardAffinity *slot, uint64 now_us, uint64 window_us, + int sample_rate, int min_access, int ratio_pct, uint64 cluster_epoch, + uint32 master_generation) +{ + uint64 ws; + uint64 total = 0; + uint32 dom_access = 0; + int rate; + int k; + bool hot; + + if (slot == NULL) + return false; + + /* + * Discontinuity: a remaster moved the shard's {cluster_epoch, + * master_generation} identity, or the window is grossly overdue (the shard + * went cold and stopped being scanned). Either way the sustained-hotness + * streak must not carry across — clear it and do not count this window as hot. + */ + ws = pg_atomic_read_u64(&slot->window_start_ts); + if (pg_atomic_read_u64(&slot->window_cluster_epoch) != cluster_epoch + || pg_atomic_read_u32(&slot->window_master_generation) != master_generation + || (ws != 0 && now_us > ws + && (now_us - ws) >= (uint64)DRM_WINDOW_OVERDUE_FACTOR * window_us)) { + slot->consecutive_hot_windows = 0; + return false; + } + + for (k = 0; k < CLUSTER_MAX_NODES; k++) { + uint32 c = pg_atomic_read_u32(&slot->access_count[k]); + + total += c; + if (c > dom_access) + dom_access = c; + } + + rate = (sample_rate < 1) ? 1 : sample_rate; + + /* Same dual threshold the decision engine applies (absolute + dominant ratio). */ + hot = total > 0 && (total * (uint64)rate) >= (uint64)min_access + && (uint64)dom_access * 100 >= total * (uint64)ratio_pct; + + if (hot) { + if (slot->consecutive_hot_windows < CLUSTER_DRM_WINDOW_CONSECUTIVE_MAX) + slot->consecutive_hot_windows++; + } else + slot->consecutive_hot_windows = 0; + + return hot; +} + +void +cluster_drm_window_reset(ClusterDrmShardAffinity *slot, uint64 now_us, uint64 cluster_epoch, + uint32 master_generation) +{ + int k; + + if (slot == NULL) + return; + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_write_u32(&slot->access_count[k], 0); + pg_atomic_write_u64(&slot->window_start_ts, now_us); + pg_atomic_write_u64(&slot->window_cluster_epoch, cluster_epoch); + pg_atomic_write_u32(&slot->window_master_generation, master_generation); +} + +ClusterDrmShardAffinity * +cluster_drm_affinity_slot(uint32 shard_id) +{ + if (drm_state == NULL || shard_id >= PGRAC_GRD_SHARD_COUNT) + return NULL; + return &drm_state->shard[shard_id]; +} + +/* ---------- + * Decision-scan observability (dump category drm_affinity) + * ---------- + */ + +void +cluster_drm_affinity_record_scan_run(void) +{ + if (drm_state == NULL) + return; + pg_atomic_fetch_add_u64(&drm_state->scans_run, 1); +} + +void +cluster_drm_affinity_record_verdict(int reason, bool proposed) +{ + if (drm_state == NULL) + return; + pg_atomic_fetch_add_u64(&drm_state->scan_candidates, 1); + if (reason >= 0 && reason < CLUSTER_DRM_SCAN_REASON_SLOTS) + pg_atomic_fetch_add_u64(&drm_state->scan_reason[reason], 1); + if (proposed) + pg_atomic_fetch_add_u64(&drm_state->scan_proposed, 1); +} + +uint64 +cluster_drm_affinity_get_scan_counter(int which) +{ + if (drm_state == NULL) + return 0; + switch (which) { + case CLUSTER_DRM_AFFINITY_SCAN_RUNS: + return pg_atomic_read_u64(&drm_state->scans_run); + case CLUSTER_DRM_AFFINITY_SCAN_CANDIDATES: + return pg_atomic_read_u64(&drm_state->scan_candidates); + case CLUSTER_DRM_AFFINITY_SCAN_PROPOSED: + return pg_atomic_read_u64(&drm_state->scan_proposed); + default: + return 0; + } +} + +uint64 +cluster_drm_affinity_get_scan_reason(int reason) +{ + if (drm_state == NULL || reason < 0 || reason >= CLUSTER_DRM_SCAN_REASON_SLOTS) + return 0; + return pg_atomic_read_u64(&drm_state->scan_reason[reason]); +} diff --git a/src/backend/cluster/cluster_drm_decision.c b/src/backend/cluster/cluster_drm_decision.c new file mode 100644 index 0000000000..dbc9baf32c --- /dev/null +++ b/src/backend/cluster/cluster_drm_decision.c @@ -0,0 +1,195 @@ +/*------------------------------------------------------------------------- + * + * cluster_drm_decision.c + * pgrac DRM hotness decision engine (spec-7.6 wave 6.3c). + * + * cluster_drm_evaluate_shard is a PURE predicate (INV-DRM9 / L409): given a + * shard's per-node affinity slot and a caller-supplied context, it proposes + * whether the shard's master should migrate and to which node. It reads + * nothing time- or topology-dependent itself (the caller passes now_us, the + * live-member bitmap, the current master and the anti-thrash counters), so it + * never mutates lock/master/GRD state and is directly unit-testable. + * + * Net-benefit model (Amend v1.1-c): under a binary hop cost (local grant = 0, + * remote GES round-trip = 1) the frozen + * benefit_rate(target) = Σ access[n] × (cost(n→current) − cost(n→target)) + * collapses to access[target] − access[current_master]. Migrate iff + * benefit_rate × expected_residence_windows > cluster.drm_migration_cost, + * where expected_residence_windows = min(cooldown/window, consecutive_hot). + * Per-node-pair cost differentiation is a forward optimization; the ratio + * gate (cluster.drm_affinity_ratio_pct) is retained as a confidence shell. + * + * Gates run in a fixed order; the first that fires names the skip reason. + * + * See spec-7.6-drm-hot-resource-detection-remaster.md (§2.2/§3.2, Amend + * v1.1-b/c/d). + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/backend/cluster/cluster_drm_decision.c + * + * NOTES + * pgrac-original file. All exported symbols use the cluster_drm_ prefix. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "cluster/cluster_conf.h" /* CLUSTER_MAX_NODES */ +#include "cluster/cluster_drm_decision.h" +#include "cluster/cluster_guc.h" + +/* Adaptive-cooldown backoff is capped so the shift never overflows. */ +#define DRM_COOLDOWN_MAX_SHIFT 6 /* up to 64x the base cooldown */ + +static bool +drm_bitmap_test(const uint8 *bitmap, int32 node) +{ + if (bitmap == NULL || node < 0 || node >= CLUSTER_MAX_NODES) + return false; + return (bitmap[node >> 3] & (1 << (node & 7))) != 0; +} + +ClusterDrmVerdict +cluster_drm_evaluate_shard(const ClusterDrmShardAffinity *a, const ClusterDrmDecisionCtx *ctx) +{ + ClusterDrmVerdict v = { false, -1, DRM_SKIP_BELOW_MIN_ACCESS }; + uint64 total = 0; + int32 dom = -1; + uint32 dom_access = 0; + uint32 master_access = 0; + int rate; + uint64 norm_total; + int k; + + if (a == NULL || ctx == NULL) + return v; + + /* Dominant node + total (both are pure reads). */ + for (k = 0; k < CLUSTER_MAX_NODES; k++) { + uint32 c = pg_atomic_read_u32(unconstify(pg_atomic_uint32 *, &a->access_count[k])); + + total += c; + if (c > dom_access) { + dom_access = c; + dom = k; + } + } + if (ctx->current_master >= 0 && ctx->current_master < CLUSTER_MAX_NODES) + master_access = pg_atomic_read_u32( + unconstify(pg_atomic_uint32 *, &a->access_count[ctx->current_master])); + + v.target_node = dom; + + rate = (ctx->active_sample_rate < 1) ? 1 : ctx->active_sample_rate; + norm_total = total * (uint64)rate; + + /* Gate 1 — absolute (sample-normalized) access floor. */ + if (norm_total < (uint64)cluster_drm_min_access_count) { + v.reason = DRM_SKIP_BELOW_MIN_ACCESS; + return v; + } + + /* Gate 2 — dominant-node ratio confidence shell. */ + if (dom < 0 || total == 0 + || (uint64)dom_access * 100 < total * (uint64)cluster_drm_affinity_ratio_pct) { + v.reason = DRM_SKIP_RATIO_LOW; + return v; + } + + /* Gate 3 — sustained hotness (tumbling windows, Amend v1.1-b). */ + if (a->consecutive_hot_windows < (uint32)cluster_drm_consecutive_triggers) { + v.reason = DRM_SKIP_NOT_SUSTAINED; + return v; + } + + /* Gate 4 — dominant already masters the shard: nothing to gain. */ + if (dom == ctx->current_master) { + v.reason = DRM_SKIP_ALREADY_MASTER; + return v; + } + + /* Gate 5 — INV-DRM1: only migrate to a live published MEMBER. */ + if (!drm_bitmap_test(ctx->member_bitmap, dom)) { + v.reason = DRM_SKIP_TARGET_NOT_MEMBER; + return v; + } + + /* Gate 6 — DBA affinity pin (feature-083 forward; v1 false). */ + if (ctx->pinned) { + v.reason = DRM_SKIP_PINNED; + return v; + } + + /* Gate 7 — anti-thrash cooldown (adaptive backoff, Amend v1.1-d). */ + if (a->last_migration_ts != 0) { + uint32 shift = (ctx->cooldown_shift > DRM_COOLDOWN_MAX_SHIFT) ? DRM_COOLDOWN_MAX_SHIFT + : ctx->cooldown_shift; + uint64 cooldown_us = (uint64)cluster_drm_cooldown_ms * 1000 * ((uint64)1 << shift); + + if (ctx->now_us > a->last_migration_ts + && (ctx->now_us - a->last_migration_ts) < cooldown_us) { + v.reason = DRM_SKIP_COOLDOWN; + return v; + } + } + + /* Gate 8 — per-scan migration rate limit. */ + if (ctx->migrations_this_scan >= cluster_drm_max_migrations_per_scan) { + v.reason = DRM_SKIP_RATE_LIMIT; + return v; + } + + /* Gate 9 — net benefit (Amend v1.1-c). Binary-cost benefit rate credited + * over the expected residence, capped by the cooldown horizon. */ + { + int64 benefit_rate = (int64)dom_access - (int64)master_access; + int window_ms = (cluster_drm_affinity_window_ms < 1) ? 1 : cluster_drm_affinity_window_ms; + uint64 cooldown_windows = (uint64)cluster_drm_cooldown_ms / (uint64)window_ms; + uint64 residence_windows = a->consecutive_hot_windows; + uint64 total_benefit; + + if (residence_windows > cooldown_windows) + residence_windows = cooldown_windows; + + total_benefit = (benefit_rate > 0) ? (uint64)benefit_rate * residence_windows : 0; + + if (benefit_rate <= 0 || total_benefit <= (uint64)cluster_drm_migration_cost) { + v.reason = DRM_SKIP_NO_NET_BENEFIT; + return v; + } + } + + /* All gates passed — propose migration to the dominant node. */ + v.migrate = true; + v.reason = DRM_REASON_MIGRATE; + return v; +} + +bool +cluster_drm_is_shard_pinned(uint32 shard_id pg_attribute_unused()) +{ + /* feature-083 DBA instance affinity is not yet shipped; DRM never skips for + * a pin in v1. The gate exists so the pin implementation is a one-line + * change here + a real predicate. */ + return false; +} + +const char * +cluster_drm_reason_name(int reason) +{ + static const char *const names[DRM_REASON__COUNT] = { + "migrate", "below_min_access", "ratio_low", "not_sustained", "already_master", + "target_not_member", "pinned", "cooldown", "rate_limit", "no_net_benefit", + }; + + if (reason < 0 || reason >= DRM_REASON__COUNT) + return "unknown"; + return names[reason]; +} diff --git a/src/backend/cluster/cluster_drm_scan.c b/src/backend/cluster/cluster_drm_scan.c new file mode 100644 index 0000000000..ddbc9dd704 --- /dev/null +++ b/src/backend/cluster/cluster_drm_scan.c @@ -0,0 +1,189 @@ +/*------------------------------------------------------------------------- + * + * cluster_drm_scan.c + * pgrac DRM decision scan driver (spec-7.6 wave 6.3c). + * + * cluster_drm_lmon_scan_tick() runs on the LMON aux process's main-loop tick. + * It is the driver that bridges the three DRM halves: + * - collection substrate (cluster_drm_affinity.c, 6.3b): per-shard x per-node + * access matrix + tumbling-window state (this driver rolls the windows); + * - decision predicate (cluster_drm_decision.c, pure, INV-DRM9): proposes a + * migration + names the skip reason; + * - live topology (cluster_grd.c current master + per-shard master + * generation, cluster_epoch.c, cluster_membership.c live-member set). + * + * Keeping the driver in its own file lets the collection + decision modules + * stay dependency-clean (no GRD/epoch linkage there) so their standalone + * cluster_unit tests do not need extra stubs. + * + * Flow per scan (self-gated to cluster.drm_scan_interval_ms): + * 1. reconcile the sample rate (LMON single writer, Amend v1.1.2 R4.2); + * 2. snapshot the live-member bitmap once (INV-DRM1 target check); + * 3. collect candidate shards (above the normalized access floor); + * 4. for each candidate: open its first window, or — once the window is due — + * judge the completed window (updating consecutive_hot_windows), run the + * decision predicate on the completed-window counts, count the verdict, + * then reset the window. + * + * Wave 6.3c PROPOSES only. Nothing here mutates lock/master/GRD state; the + * live single-shard remaster executor is wave 6.3d. + * + * See spec-7.6-drm-hot-resource-detection-remaster.md (§3.2, Amend v1.1-b/c/d). + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/backend/cluster/cluster_drm_scan.c + * + * NOTES + * pgrac-original file. All exported symbols use the cluster_drm_ prefix. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "utils/timestamp.h" /* GetCurrentTimestamp, TimestampTz */ + +#include "cluster/cluster_drm_affinity.h" +#include "cluster/cluster_drm_decision.h" +#include "cluster/cluster_drm_scan.h" +#include "cluster/cluster_epoch.h" +#include "cluster/cluster_grd.h" +#include "cluster/cluster_guc.h" +#include "cluster/cluster_membership.h" + +/* + * The shared per-reason counter array (cluster_drm_affinity.h) is sized without + * referencing ClusterDrmReason to avoid an include cycle; assert here — where + * both headers are visible — that it is wide enough. + */ +StaticAssertDecl(DRM_REASON__COUNT <= CLUSTER_DRM_SCAN_REASON_SLOTS, + "drm scan reason-counter array too small for ClusterDrmReason"); + +/* + * Per-scan candidate cap. collect_candidates returns only shards above the + * normalized access floor, so the working set is small in practice; this bounds + * the stack array and one scan's work. If it is hit, the remaining hot shards + * stay dirty + hot and are picked up on later scans (logged once — no silent cap). + */ +#define DRM_SCAN_MAX_CANDIDATES 1024 + +void +cluster_drm_lmon_scan_tick(void) +{ + static TimestampTz last_scan_at = 0; + TimestampTz now; + uint64 now_us; + uint64 cluster_epoch; + uint64 window_us; + uint8 members[(CLUSTER_MAX_NODES + 7) / 8]; + uint32 candidates[DRM_SCAN_MAX_CANDIDATES]; + int ncand; + int migrations_this_scan = 0; + int rate; + int i; + + if (!cluster_drm_enabled) + return; + + /* Self-gate to the configured scan interval (both LMON drain sites call us). */ + now = GetCurrentTimestamp(); + if (last_scan_at != 0 && now - last_scan_at < (TimestampTz)cluster_drm_scan_interval_ms * 1000) + return; + last_scan_at = now; + + /* + * LMON is the single writer of the sample-rate reconcile (Amend v1.1.2 R4.2): + * a rate change bumps sample_epoch + clears the dirty bitmap so the scan does + * not consume stale windows. + */ + (void)cluster_drm_affinity_reconcile_sample_rate(); + cluster_drm_affinity_record_scan_run(); + + now_us = (uint64)now; + cluster_epoch = cluster_epoch_get_current(); + rate = (cluster_drm_affinity_sample_rate < 1) ? 1 : cluster_drm_affinity_sample_rate; + window_us = (uint64)((cluster_drm_affinity_window_ms < 1) ? 1 : cluster_drm_affinity_window_ms) + * UINT64CONST(1000); + + /* Snapshot the live-member set once per scan (INV-DRM1 dominant-is-member gate). */ + memset(members, 0, sizeof(members)); + for (i = 0; i < CLUSTER_MAX_NODES; i++) + if (cluster_membership_is_member(i)) + members[i >> 3] |= (uint8)(1 << (i & 7)); + + ncand = cluster_drm_affinity_collect_candidates(candidates, DRM_SCAN_MAX_CANDIDATES); + if (ncand >= DRM_SCAN_MAX_CANDIDATES) { + static bool warned = false; + + if (!warned) { + ereport(LOG, + (errmsg("cluster drm: decision scan candidate set hit the %d cap; remaining " + "hot shards are deferred to later scans", + DRM_SCAN_MAX_CANDIDATES))); + warned = true; + } + } + + for (i = 0; i < ncand; i++) { + uint32 shard = candidates[i]; + ClusterDrmShardAffinity *slot = cluster_drm_affinity_slot(shard); + int32 master; + uint32 mgen; + ClusterDrmDecisionCtx ctx; + ClusterDrmVerdict v; + bool proposed; + + if (slot == NULL) + continue; + + master = cluster_grd_shard_master(shard); + mgen = cluster_grd_shard_master_generation(shard); + + /* First observation of this shard: open the window, decide next boundary. */ + if (!cluster_drm_window_is_open(slot)) { + cluster_drm_window_open(slot, now_us, cluster_epoch, mgen); + continue; + } + /* Tumbling windows are judged only once, at their boundary. */ + if (!cluster_drm_window_due(slot, now_us, window_us)) + continue; + + /* + * Judge the completed window (updates consecutive_hot_windows), THEN run + * the decision on the completed-window counts + the fresh streak, THEN + * reset the counts for the next window. + */ + (void)cluster_drm_window_judge(slot, now_us, window_us, rate, cluster_drm_min_access_count, + cluster_drm_affinity_ratio_pct, cluster_epoch, mgen); + + ctx.current_master = master; + ctx.now_us = now_us; + ctx.active_sample_rate = rate; + ctx.migrations_this_scan = migrations_this_scan; + ctx.pinned = cluster_drm_is_shard_pinned(shard); + ctx.member_bitmap = members; + ctx.cooldown_shift = 0; /* per-shard backoff is migration-driven (wave 6.3d) */ + + v = cluster_drm_evaluate_shard(slot, &ctx); + + /* + * Wave 6.3c PROPOSES only — nothing is executed here. drm_manual_only + * still tallies the reason (observability) but suppresses the auto- + * actionable proposal count + rate-limit consumption. + */ + proposed = v.migrate && !cluster_drm_manual_only; + cluster_drm_affinity_record_verdict(v.reason, proposed); + if (proposed) + migrations_this_scan++; + + cluster_drm_window_reset(slot, now_us, cluster_epoch, mgen); + } +} diff --git a/src/backend/cluster/cluster_ges.c b/src/backend/cluster/cluster_ges.c index f68e407239..39f8a4e6ab 100644 --- a/src/backend/cluster/cluster_ges.c +++ b/src/backend/cluster/cluster_ges.c @@ -38,6 +38,7 @@ #include "cluster/cluster_ges.h" #include "cluster/cluster_ges_mode.h" /* spec-5.1b D1: ges_modes_compatible (frozen matrix) */ #include "cluster/cluster_ges_reply_wait.h" /* spec-2.23 D1 5-tuple wait HTAB */ +#include "cluster/cluster_drm_affinity.h" /* spec-7.6 6.3b D2 — DRM affinity sample */ #include "cluster/cluster_grd.h" #include "cluster/cluster_grd_outbound.h" #include "cluster/cluster_lmd.h" /* cluster_lmd_is_ready */ @@ -756,6 +757,28 @@ cluster_ges_request_handler(const ClusterICEnvelope *env, const void *payload) } } + /* + * PGRAC: spec-7.6 6.3b (D2) — DRM affinity admission sample (remote path). + * This is the first-logical-request boundary on the CURRENT MASTER: the + * dedup MISS above rejects retransmits, and a remote request only reaches + * this node because it masters the shard. Record which requesting node + * (env->source_node_id) accessed which shard so DRM can later migrate the + * master to the hottest node. Only real demand — REQUEST / REQUEST_NOWAIT / + * CONVERT — counts (RELEASE / REDECLARE / rollback are not access demand, + * Amend v1.1.2 R2). Guarded on cluster_drm_enabled for zero cost when off. + */ + if (cluster_drm_enabled + && (req->opcode == GES_REQ_OPCODE_REQUEST || req->opcode == GES_REQ_OPCODE_REQUEST_NOWAIT + || req->opcode == GES_REQ_OPCODE_CONVERT)) { + ClusterResId drm_resid; + uint32 drm_shard; + + memcpy(&drm_resid, req->resid, sizeof(drm_resid)); + drm_shard = cluster_grd_shard_for_resource(&drm_resid); + if (cluster_grd_is_local_master(drm_shard)) + cluster_drm_affinity_sample(drm_shard, env->source_node_id, true /* remote */); + } + /* Phase 1 (handler): enqueue into work_queue. Grant decision runs * Phase 2 in LMON tick body (Step 4 D9 wires). work_queue full → * REJECT_BUSY reply via reserved pool (I46 nofail). */ @@ -1597,6 +1620,21 @@ ges_send_request_opcode_and_wait(const struct ClusterResId *resid, uint32 lockmo volatile bool is_victim = false; master_gen = cluster_lms_get_shard_master_generation(); + + /* + * PGRAC: spec-7.6 6.3b (D2) — DRM affinity admission sample (local + * path). This node grants without the wire, so record the access under + * cluster_node_id. Only when this node truly masters the shard + * (master == self, not the -1 "no master yet" sentinel) and only real + * REQUEST / CONVERT demand (Amend v1.1.2 R2). Zero cost when DRM off. + */ + if (cluster_drm_enabled && master == cluster_node_id + && (send_opcode == GES_REQ_OPCODE_REQUEST + || send_opcode == GES_REQ_OPCODE_REQUEST_NOWAIT + || send_opcode == GES_REQ_OPCODE_CONVERT)) + cluster_drm_affinity_sample(cluster_grd_shard_for_resource(resid), cluster_node_id, + false /* local */); + /* spec-5.5 D5 — local-master try-lock: conditional grant, never enqueue. */ action = conditional diff --git a/src/backend/cluster/cluster_guc.c b/src/backend/cluster/cluster_guc.c index 643ec959d6..f48b13100f 100644 --- a/src/backend/cluster/cluster_guc.c +++ b/src/backend/cluster/cluster_guc.c @@ -175,7 +175,8 @@ bool cluster_smgr_user_relations = false; */ bool cluster_shared_catalog = false; int cluster_oid_lease_size = 8192; -int cluster_shmem_max_regions = 80; /* spec-5.56: 64 -> 80 (cr relgen region; restore margin) */ +int cluster_shmem_max_regions + = 96; /* spec-5.56: 64 -> 80; spec-7.6 6.3b: 80 -> 96 (drm affinity region + margin) */ /* spec-3.18 D1: undo block buffer pool slot count (0 = disabled). */ int cluster_undo_buffers = 2048; @@ -231,6 +232,22 @@ int cluster_tm_convert_mode = CLUSTER_TM_CONVERT_MODE_CONVERT; int cluster_grd_remaster_wait_ms = 200; /* frozen-shard short wait before 53R9I */ int cluster_grd_rebuild_timeout_ms = 5000; /* holder-rebuild barrier deadline */ int cluster_hw_remaster_retry_backoff_ms = 1000; + +/* spec-7.6 6.3b — DRM per-shard affinity collection (default off / conservative). */ +bool cluster_drm_enabled = false; +int cluster_drm_affinity_sample_rate = 16; +int cluster_drm_min_access_count = 1000; +/* spec-7.6 6.3c: DRM hotness decision + anti-thrash GUCs (conservative defaults; + * mechanism aligns to Oracle _gc_affinity_*, numeric values are this project's + * own — "not verified against Oracle", tune by measurement (rule 3)). */ +int cluster_drm_affinity_ratio_pct = 70; +int cluster_drm_consecutive_triggers = 3; +int cluster_drm_affinity_window_ms = 10000; +int cluster_drm_cooldown_ms = 600000; +int cluster_drm_max_migrations_per_scan = 2; +int cluster_drm_scan_interval_ms = 5000; +int cluster_drm_migration_cost = 1000; +bool cluster_drm_manual_only = false; int cluster_hw_remaster_retry_max_attempts = 16; /* spec-5.4 D8 — SQ sequence lock tunables. */ @@ -2167,7 +2184,7 @@ cluster_init_guc(void) "registers one region. Raise if FATAL on startup with " "errcode 53400 \"cluster shmem registry capacity " "exceeded\"."), - &cluster_shmem_max_regions, 80, CLUSTER_SHMEM_MIN_REGIONS, 256, + &cluster_shmem_max_regions, 96, CLUSTER_SHMEM_MIN_REGIONS, 256, PGC_POSTMASTER, /* registry array is palloc'd once at init */ 0, /* flags */ NULL, /* check_hook */ @@ -2426,6 +2443,102 @@ cluster_init_guc(void) "request with a fresh deadline."), &cluster_grd_rebuild_timeout_ms, 5000, 100, 600000, PGC_SIGHUP, GUC_UNIT_MS, NULL, NULL, NULL); + + /* + * spec-7.6 6.3b — DRM per-shard affinity collection. Master switch defaults + * OFF (upgrade behaviour == Stage 5, zero hot-path cost); the decision + + * anti-thrash + live-remaster GUCs land in waves 6.3c/6.3f. + */ + DefineCustomBoolVariable( + "cluster.drm_enabled", gettext_noop("Enable DRM per-shard access-affinity collection."), + gettext_noop("Off (default) keeps Stage 5 behaviour with zero cost on the lock " + "admission path. On samples first-logical lock requests on the shard's " + "current master to build the per-node access matrix that the DRM decision " + "engine (a later wave) uses to migrate hot shards' masters."), + &cluster_drm_enabled, false, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_affinity_sample_rate", + gettext_noop("Sample 1 in N lock-admission hits for DRM affinity (N)."), + gettext_noop("Range [1, 1000000]. Default 16. Higher values lower the collection " + "cost at the price of coarser affinity resolution. min_access_count is " + "normalized by this rate, so changing it reopens the current window."), + &cluster_drm_affinity_sample_rate, 16, 1, 1000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_min_access_count", + gettext_noop( + "Absolute (sample-normalized) access floor before a shard is a DRM candidate."), + gettext_noop("Range [0, 2000000000]. Default 1000. Mirrors Oracle _gc_affinity_minimum " + "in spirit (value not verified against Oracle). A shard whose sampled " + "access total * sample_rate stays below this is never considered for " + "remaster."), + &cluster_drm_min_access_count, 1000, 0, 2000000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + /* + * spec-7.6 6.3c decision + anti-thrash GUCs. The decision engine + LMON scan + * only PROPOSE remaster (report-only) in this wave; the live-migration + * executor (a later wave) consumes the same knobs. Mechanism mirrors Oracle + * _gc_affinity_*; the numeric defaults are this project's conservative + * starting points, explicitly NOT verified against Oracle (rule 3). + */ + DefineCustomIntVariable( + "cluster.drm_affinity_ratio_pct", + gettext_noop("Minimum dominant-node access share (percent) to remaster a shard."), + gettext_noop("Range [1, 100]. Default 70. A shard is only a migration candidate " + "when its busiest node holds at least this share of the sampled access " + "(confidence shell before the net-benefit test). Mirrors Oracle " + "_gc_affinity_limit in spirit (value not verified against Oracle)."), + &cluster_drm_affinity_ratio_pct, 70, 1, 100, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_consecutive_triggers", + gettext_noop("Consecutive hot windows required before a shard is remastered."), + gettext_noop("Range [1, 1000000]. Default 3. Anti-thrash: a shard must read hot for " + "this many consecutive tumbling windows (drm_affinity_window_ms) before it " + "is proposed, so a short burst does not move a master."), + &cluster_drm_consecutive_triggers, 3, 1, 1000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_affinity_window_ms", + gettext_noop("Tumbling affinity observation window width (ms)."), + gettext_noop("Range [100, 2000000000]. Default 10000. Non-overlapping windows: at each " + "boundary the completed window's hotness updates the consecutive-hot counter " + "and the per-node counts reset. Mirrors Oracle _gc_affinity_time in spirit " + "(value not verified against Oracle)."), + &cluster_drm_affinity_window_ms, 10000, 100, 2000000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_cooldown_ms", + gettext_noop("Minimum time between remasters of the same shard (ms)."), + gettext_noop("Range [0, 2000000000]. Default 600000 (10 min, aligned to Oracle's " + "reputed cooldown; value not verified). Anti-thrash: a shard remastered " + "within this window is not moved again; oscillation extends it adaptively."), + &cluster_drm_cooldown_ms, 600000, 0, 2000000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_max_migrations_per_scan", + gettext_noop("Maximum shard remasters proposed per LMON decision scan."), + gettext_noop("Range [0, 1000000]. Default 2. Rate-limits how many shards can be " + "proposed for remaster in a single scan so a mass affinity shift cannot " + "storm the cluster. 0 proposes none (observe only)."), + &cluster_drm_max_migrations_per_scan, 2, 0, 1000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_scan_interval_ms", + gettext_noop("Interval between DRM decision scans on the LMON tick (ms)."), + gettext_noop("Range [100, 2000000000]. Default 5000. The LMON aux process evaluates the " + "candidate shards this often. Independent of drm_affinity_window_ms: a " + "window is judged at most once even if scanned several times."), + &cluster_drm_scan_interval_ms, 5000, 100, 2000000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( + "cluster.drm_migration_cost", + gettext_noop("Net-benefit threshold (access-equivalent units) to remaster a shard."), + gettext_noop("Range [0, 2000000000]. Default 1000. A shard migrates only when " + "(dominant_access - current_master_access) * expected_residence_windows " + "exceeds this, so a small or short-lived edge does not pay for the move. " + "This project's own knob (no Oracle equivalent); tune by measurement."), + &cluster_drm_migration_cost, 1000, 0, 2000000000, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomBoolVariable( + "cluster.drm_manual_only", + gettext_noop("Observe and propose remasters but never auto-execute them."), + gettext_noop("Off (default) lets the decision scan auto-propose (and, in a later wave, " + "execute). On keeps the full observability surface but suppresses " + "auto-actionable proposals, leaving remaster to the manual interface."), + &cluster_drm_manual_only, false, PGC_SIGHUP, 0, NULL, NULL, NULL); + DefineCustomIntVariable( "cluster.hw_remaster_retry_backoff_ms", gettext_noop("Initial backoff before retrying a BLOCKED HW remaster worker (ms)."), diff --git a/src/backend/cluster/cluster_lmon.c b/src/backend/cluster/cluster_lmon.c index 9b8e9381d8..f7b36a6a62 100644 --- a/src/backend/cluster/cluster_lmon.c +++ b/src/backend/cluster/cluster_lmon.c @@ -65,6 +65,8 @@ #include "cluster/cluster_gcs.h" /* cluster_gcs_register_msg_types (spec-2.32 D4) */ #include "cluster/cluster_gcs_block.h" /* cluster_gcs_register_block_msg_types (spec-2.33 D4) */ #include "cluster/cluster_gcs_block_dedup.h" /* cluster_gcs_block_dedup_sweep_expired (spec-2.34 D6) */ +#include "cluster/cluster_drm_affinity.h" /* spec-7.6 6.3b — DRM ring drain */ +#include "cluster/cluster_drm_scan.h" /* spec-7.6 6.3c — DRM decision scan */ #include "cluster/cluster_grd.h" /* cluster_grd_lmon_tick_dead_sweep (spec-2.16 D8) */ #include "cluster/cluster_lms.h" /* cluster_lms_owns_grant (spec-2.18 Sprint A Step 3 D8 HC4) */ #include "cluster/cluster_native_lock_probe.h" @@ -1182,6 +1184,15 @@ LmonMain(void) /* spec-5.10 fix-forward — runtime-off starvation sweep (no-op * unless cluster.ges_starvation_protection was just turned off). */ (void)cluster_grd_lmon_tick_starvation_sweep(); + /* spec-7.6 6.3b: drain this LMON process's DRM affinity sample ring + * (remote requests are sampled in cluster_ges_request_handler, which + * runs here, and buffer per-process). No-op when off / ring empty. */ + if (cluster_drm_enabled) { + cluster_drm_affinity_flush_local_ring(); + /* spec-7.6 6.3c: periodic decision scan (self-gated to + * drm_scan_interval_ms; PROPOSES only, no execution). */ + cluster_drm_lmon_scan_tick(); + } cluster_grd_deadlock_lmon_tick(); /* spec-2.17 Step 5 */ /* @@ -1862,6 +1873,15 @@ LmonMain(void) /* spec-5.10 fix-forward — runtime-off starvation sweep. */ (void)cluster_grd_lmon_tick_starvation_sweep(); + /* spec-7.6 6.3b: drain this LMON process's DRM affinity sample ring + * (remote GES requests sampled here buffer per-process). */ + if (cluster_drm_enabled) { + cluster_drm_affinity_flush_local_ring(); + /* spec-7.6 6.3c: periodic decision scan (self-gated to + * drm_scan_interval_ms; PROPOSES only, no execution). */ + cluster_drm_lmon_scan_tick(); + } + /* spec-5.13 D6: clean-leave orchestration before the reconfig tick. */ cluster_clean_leave_lmon_tick(); diff --git a/src/backend/cluster/cluster_lock_acquire.c b/src/backend/cluster/cluster_lock_acquire.c index 280d15da80..917baed06a 100644 --- a/src/backend/cluster/cluster_lock_acquire.c +++ b/src/backend/cluster/cluster_lock_acquire.c @@ -52,6 +52,7 @@ #include "cluster/cluster_ges.h" #include "cluster/cluster_ges_reply_wait.h" /* cluster_ges_reply_wait_next_request_id (spec-5.16: node-global request_id) */ #include "cluster/cluster_ges_mode.h" /* spec-5.3 D1 — ges_mode_convert_class for UPGRADE filter */ +#include "cluster/cluster_drm_affinity.h" /* spec-7.6 6.3b D2 — local-master DRM sample */ #include "cluster/cluster_grd.h" #include "cluster/cluster_guc.h" #include "cluster/cluster_ir.h" /* spec-5.7 D8 — CLUSTER_IR_RESID_TYPE (freeze-gate bypass belt) */ @@ -267,6 +268,20 @@ cluster_lock_acquire_s3_partition_reservation(const ClusterLockAcquireRequest *r return CLUSTER_LOCK_ACQUIRE_FAIL_TIMEOUT; } pg_atomic_fetch_add_u64(&stub_local_fast_path_count, 1); + + /* + * PGRAC: spec-7.6 6.3b (D2) — DRM affinity admission sample, local-master + * FAST PATH. This node masters the resource and grants it without the + * wire (S4 is skipped for NEED_PG_NATIVE_LOCK), so the local-master hook + * in ges_send_request_opcode_and_wait never runs on this path — sample + * here instead, under self. (When the local fast path is disabled the + * request falls through to S4 and that hook covers it; the two paths are + * mutually exclusive.) Zero cost when DRM is off. Amend v1.1.2 R2/R3. + */ + if (cluster_drm_enabled) + cluster_drm_affinity_sample(cluster_grd_shard_for_resource(&req->resid), self_node, + false /* local */); + return CLUSTER_LOCK_ACQUIRE_NEED_PG_NATIVE_LOCK; } return CLUSTER_LOCK_ACQUIRE_OK_GRANTED; /* dispatch to S4 */ diff --git a/src/backend/cluster/cluster_shmem.c b/src/backend/cluster/cluster_shmem.c index d67ad677b9..851e2214ba 100644 --- a/src/backend/cluster/cluster_shmem.c +++ b/src/backend/cluster/cluster_shmem.c @@ -87,10 +87,11 @@ #include "cluster/cluster_advisory.h" /* cluster_advisory_shmem_register (spec-5.5 D8) */ #include "cluster/cluster_cf_stats.h" /* cluster_cf_stats_shmem_register (spec-5.6 Dc4) */ #include "cluster/cluster_ges_reply_wait.h" /* cluster_ges_reply_wait_shmem_register (spec-2.23 D1) */ -#include "cluster/cluster_grd.h" /* cluster_grd_shmem_register (spec-2.14) */ -#include "cluster/cluster_grd_pending.h" /* cluster_grd_pending_shmem_register (spec-2.16 D3) */ -#include "cluster/cluster_ges_dedup.h" /* cluster_ges_dedup_shmem_register (spec-2.27 D2) */ -#include "cluster/cluster_wal_thread.h" /* cluster_wal_thread_shmem_register (spec-4.1 D7) */ +#include "cluster/cluster_drm_affinity.h" /* cluster_drm_affinity_shmem_register (spec-7.6 6.3b) */ +#include "cluster/cluster_grd.h" /* cluster_grd_shmem_register (spec-2.14) */ +#include "cluster/cluster_grd_pending.h" /* cluster_grd_pending_shmem_register (spec-2.16 D3) */ +#include "cluster/cluster_ges_dedup.h" /* cluster_ges_dedup_shmem_register (spec-2.27 D2) */ +#include "cluster/cluster_wal_thread.h" /* cluster_wal_thread_shmem_register (spec-4.1 D7) */ #include "cluster/cluster_recovery_plan.h" /* cluster_recovery_plan_shmem_register (spec-4.3 D4) */ #include "cluster/cluster_block_recovery.h" /* cluster_block_recovery_shmem_register (spec-4.10 D6) */ #include "cluster/cluster_grd_outbound.h" /* cluster_grd_outbound_shmem_register (spec-2.16 D4) */ @@ -663,6 +664,11 @@ cluster_init_shmem_module(void) if (cluster_shmem_lookup_region("pgrac cluster ges reply wait") == NULL) cluster_ges_reply_wait_shmem_register(); + /* spec-7.6 6.3b: register the DRM per-shard affinity region (full 4096-slot + * access matrix + atomic dirty bitmap; lock-free, no LWLock tranche). */ + if (cluster_shmem_lookup_region("pgrac cluster drm affinity") == NULL) + cluster_drm_affinity_shmem_register(); + /* spec-2.14 D5: register cluster_grd shmem region (GRD routing * substrate; 4096 atomic master[] + 5 atomic uint64 counters; * lock-free per Q9). */ diff --git a/src/include/cluster/cluster_drm_affinity.h b/src/include/cluster/cluster_drm_affinity.h new file mode 100644 index 0000000000..a3b8d98cab --- /dev/null +++ b/src/include/cluster/cluster_drm_affinity.h @@ -0,0 +1,224 @@ +/*------------------------------------------------------------------------- + * + * cluster_drm_affinity.h + * Public interface for pgrac DRM per-shard access-affinity collection. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/include/cluster/cluster_drm_affinity.h + * + *------------------------------------------------------------------------- + */ +#ifndef CLUSTER_DRM_AFFINITY_H +#define CLUSTER_DRM_AFFINITY_H + +#include "c.h" +#include "port/atomics.h" +#include "cluster/cluster_conf.h" /* CLUSTER_MAX_NODES */ +#include "cluster/cluster_grd.h" /* PGRAC_GRD_SHARD_COUNT */ + +/* + * Reason-counter slots reserved in the shared scan observability array. This + * must be >= the number of ClusterDrmReason values (cluster_drm_decision.h), + * but that header includes THIS one, so the compile-time equality is asserted in + * the scan driver (cluster_drm_scan.c) rather than referenced here (avoids the + * include cycle). 16 leaves head-room for future skip reasons. + */ +#define CLUSTER_DRM_SCAN_REASON_SLOTS 16 + +/* + * Cap on consecutive_hot_windows so an uninterrupted hot streak cannot overflow + * the counter. The decision engine already caps expected residence at + * cooldown/window, so this bound is purely counter hygiene. + */ +#define CLUSTER_DRM_WINDOW_CONSECUTIVE_MAX 65535u + +/* + * Per-shard access-affinity slot (shmem). Populated ONLY on the node that + * currently masters the shard (spec-7.6 Amend v1.1.2 R2, current-master + * authoritative collection): remote requests land here via env->source_node_id + * and local requests via cluster_node_id, so the master accumulates the full + * per-node access matrix. A non-master node's slot for a shard stays cold — + * it never handles that shard's first-logical grants. + * + * INV-DRM9: pure statistics. Nothing in this module mutates master/lock/GRD + * state; it only touches its own shmem region. + * + * The window_* identity tuple (spec-7.6 Amend v1.1.2 R5) lets a scan discard a + * stale window when {cluster_epoch, master_generation, sample_epoch} changes, + * so a shard remastered away and back does not consume stale heat. The tumbling + * window ROLL logic itself lands in wave 6.3c (D3); wave 6.3b only records the + * matrix + identity and answers candidate queries. + */ +typedef struct ClusterDrmShardAffinity { + pg_atomic_uint32 access_count[CLUSTER_MAX_NODES]; /* sampled first-logical req per node */ + pg_atomic_uint32 ewma_total; /* time-decayed total (D3 consumes) */ + pg_atomic_uint64 window_start_ts; /* tumbling window anchor us (D3) */ + pg_atomic_uint64 window_cluster_epoch; /* window identity (R5) */ + pg_atomic_uint32 window_master_generation; /* window identity (R5) */ + pg_atomic_uint64 window_sample_epoch; /* window identity (R5) */ + uint32 consecutive_hot_windows; /* anti-thrash — LMON-only writer (D4) */ + uint64 last_migration_ts; /* cooldown anchor — LMON-only (D4) */ +} ClusterDrmShardAffinity; + +/* + * Shmem region layout. Full 4096-slot array is always resident when the + * cluster is enabled (spec-7.6 Amend v1.1-a④) — no candidate admission, so + * there is no chicken-and-egg about where to count a not-yet-tracked shard. + * dirty_shard_bitmap is atomic (Amend v1.1.2 R4.1): backends set bits with + * fetch_or on flush, the LMON scan drains them with atomic exchange. + */ +typedef struct ClusterDrmAffinityShared { + pg_atomic_uint64 sample_epoch; /* LMON single-writer; bumped on sample_rate change (R4.2) */ + pg_atomic_uint64 + active_sample_rate; /* last rate the LMON reconciled (R4.2) */ + /* observability counters (dump category "drm_affinity") */ + pg_atomic_uint64 samples_recorded; + pg_atomic_uint64 samples_local; /* recorded, requesting_node == self */ + pg_atomic_uint64 samples_remote; /* recorded, requesting_node == a peer */ + pg_atomic_uint64 samples_skipped_off; + pg_atomic_uint64 samples_dropped_stale_epoch; + pg_atomic_uint64 flush_batches; + /* --- decision-scan observability (spec-7.6 6.3c, LMON writer) --- */ + pg_atomic_uint64 scans_run; /* LMON scan ticks that actually ran */ + pg_atomic_uint64 scan_candidates; /* candidate shards evaluated (windows due) */ + pg_atomic_uint64 scan_proposed; /* migrate verdicts (auto-actionable only) */ + pg_atomic_uint64 scan_reason[CLUSTER_DRM_SCAN_REASON_SLOTS]; /* per-reason tally */ + pg_atomic_uint64 dirty_shard_bitmap[(PGRAC_GRD_SHARD_COUNT + 63) / 64]; + ClusterDrmShardAffinity shard[PGRAC_GRD_SHARD_COUNT]; +} ClusterDrmAffinityShared; + +/* --- shmem lifecycle (wired via cluster_shmem.c registry) --- */ +extern Size cluster_drm_affinity_shmem_size(void); +extern void cluster_drm_affinity_shmem_init(void); +extern void cluster_drm_affinity_shmem_register(void); + +/* + * Admission-side sample hook (spec-7.6 D2). Called at the first-logical-request + * boundary ON THE CURRENT MASTER — remote path from the GES dedup MISS branch, + * local path from the local-master grant branch (both in cluster_ges.c). NOT + * called from cluster_grd_lookup_master (Amend v1.1.2 R3): that primitive is + * also driven by probe / remaster housekeeping / release and would double-count + * and pollute. requesting_node = env->source_node_id (remote) or cluster_node_id + * (local). Cheap: the caller guards on cluster_drm_enabled first, then a + * per-backend countdown decides whether this hit is sampled (no shared atomic, + * no modulo on the hot path — Amend v1.1-a③). + * + * was_remote is observability ONLY (splits samples_local / samples_remote): + * BOTH local and remote hits are recorded into the matrix — the flag never + * filters (Amend v1.1-a①; recording only remote would blind the master to its + * own local heat and mis-migrate the shard it accesses most). + */ +extern void cluster_drm_affinity_sample(uint32 shard_id, int32 requesting_node, bool was_remote); + +/* + * Flush the per-backend sample ring into the shared matrix (spec-7.6 Amend + * v1.1.2 R4.3, explicit lifecycle). Called on ring-full, transaction end, + * backend exit, and the LMON drain. Ring entries carrying a sample_epoch + * other than the current shared sample_epoch are dropped as a stale batch. + */ +extern void cluster_drm_affinity_flush_local_ring(void); + +/* Register the per-backend flush hooks (xact-end + before_shmem_exit). Idempotent. */ +extern void cluster_drm_affinity_register_backend_hooks(void); + +/* + * Drop this backend's un-flushed buffered samples and re-arm its sampling + * cadence from scratch. The per-backend countdown is calibrated for the sample + * rate in effect when it was last reseeded, so after a large rate change it can + * carry a stale budget; this re-syncs it. Also used by unit tests for + * deterministic per-test sampling. + */ +extern void cluster_drm_affinity_reset_local_sampling(void); + +/* + * LMON single-writer reconciliation (Amend v1.1.2 R4.2): if cluster.drm_ + * affinity_sample_rate changed since the last reconcile, reset counts + dirty, + * bump sample_epoch (so in-flight backend batches at the old rate are dropped + * on flush) and reopen the window. Returns true when a reopen happened. + */ +extern bool cluster_drm_affinity_reconcile_sample_rate(void); + +/* + * Collect the shard ids that currently cross the sample-rate-normalized + * min_access_count on THIS (master) node (Amend v1.1-a④/a⑤). The candidate + * table is just this returned index list — a decision working set, not a copy. + * Returns the number written to out_shard_ids (<= max). Pure read (INV-DRM9). + */ +extern int cluster_drm_affinity_collect_candidates(uint32 *out_shard_ids, int max); + +/* --- read accessors for the dump category + unit tests (pure reads) --- */ +extern uint32 cluster_drm_affinity_access_count(uint32 shard_id, int32 node); +extern uint64 cluster_drm_affinity_get_sample_epoch(void); +extern uint64 cluster_drm_affinity_get_counter(int which); + +/* cluster_drm_affinity_get_counter() selectors. */ +#define CLUSTER_DRM_AFFINITY_CTR_RECORDED 0 +#define CLUSTER_DRM_AFFINITY_CTR_SKIPPED_OFF 1 +#define CLUSTER_DRM_AFFINITY_CTR_DROPPED_STALE 2 +#define CLUSTER_DRM_AFFINITY_CTR_FLUSH_BATCHES 3 +#define CLUSTER_DRM_AFFINITY_CTR_LOCAL 4 +#define CLUSTER_DRM_AFFINITY_CTR_REMOTE 5 + +/* + * --- tumbling window management (spec-7.6 6.3c, Amend v1.1-b / R5) --- + * + * The window_* functions operate ON A PASSED SLOT and mutate only that slot + * (they are pure with respect to lock/master/GRD — INV-DRM9), so they are unit- + * testable with a stack slot. The LMON scan driver (cluster_drm_scan.c) drives + * them: for each candidate shard it opens the first window, waits until the + * window is due, judges the completed window (updating consecutive_hot_windows), + * lets the decision engine read the completed-window counts, then resets. + * cluster_epoch + master_generation are the R5 window identity: a window whose + * identity changed spanned a remaster and must not carry stale heat forward. + */ + +/* True once the window anchor is set (open) AND window_us has elapsed. */ +extern bool cluster_drm_window_is_open(const ClusterDrmShardAffinity *slot); +extern bool cluster_drm_window_due(const ClusterDrmShardAffinity *slot, uint64 now_us, + uint64 window_us); + +/* Open the first window (window_start_ts == 0): anchor now + stamp identity. */ +extern void cluster_drm_window_open(ClusterDrmShardAffinity *slot, uint64 now_us, + uint64 cluster_epoch, uint32 master_generation); + +/* + * Judge a COMPLETED window and update consecutive_hot_windows (does NOT reset + * the per-node counts — the caller evaluates first, then resets). Returns true + * iff the window was hot. A window whose identity changed (remaster) or that is + * grossly overdue (the shard went cold and stopped being scanned) breaks the + * streak: consecutive_hot_windows := 0, returns false. + */ +extern bool cluster_drm_window_judge(ClusterDrmShardAffinity *slot, uint64 now_us, uint64 window_us, + int sample_rate, int min_access, int ratio_pct, + uint64 cluster_epoch, uint32 master_generation); + +/* Reset per-node counts + re-anchor + re-stamp identity: opens the next window. */ +extern void cluster_drm_window_reset(ClusterDrmShardAffinity *slot, uint64 now_us, + uint64 cluster_epoch, uint32 master_generation); + +/* Bounds-checked slot accessor for the scan driver (NULL if not resident / OOB). */ +extern ClusterDrmShardAffinity *cluster_drm_affinity_slot(uint32 shard_id); + +/* + * --- decision-scan observability (dump category drm_affinity, 6.3c) --- + * The LMON scan driver counts one scan run + one verdict per evaluated candidate + * (per-reason), and the migrate verdicts it would auto-execute (proposed). + */ +extern void cluster_drm_affinity_record_scan_run(void); +extern void cluster_drm_affinity_record_verdict(int reason, bool proposed); +extern uint64 cluster_drm_affinity_get_scan_counter(int which); +extern uint64 cluster_drm_affinity_get_scan_reason(int reason); + +/* cluster_drm_affinity_get_scan_counter() selectors. */ +#define CLUSTER_DRM_AFFINITY_SCAN_RUNS 0 +#define CLUSTER_DRM_AFFINITY_SCAN_CANDIDATES 1 +#define CLUSTER_DRM_AFFINITY_SCAN_PROPOSED 2 + +#endif /* CLUSTER_DRM_AFFINITY_H */ diff --git a/src/include/cluster/cluster_drm_decision.h b/src/include/cluster/cluster_drm_decision.h new file mode 100644 index 0000000000..87686903ef --- /dev/null +++ b/src/include/cluster/cluster_drm_decision.h @@ -0,0 +1,80 @@ +/*------------------------------------------------------------------------- + * + * cluster_drm_decision.h + * Public interface for the pgrac DRM hotness decision engine. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/include/cluster/cluster_drm_decision.h + * + *------------------------------------------------------------------------- + */ +#ifndef CLUSTER_DRM_DECISION_H +#define CLUSTER_DRM_DECISION_H + +#include "c.h" +#include "cluster/cluster_drm_affinity.h" /* ClusterDrmShardAffinity */ + +/* + * ClusterDrmReason — why a shard was (or was not) proposed for remaster. + * Every skip path is named so it can be counted and observed (L13). The + * gates are evaluated in this order; the first that fires wins. + */ +typedef enum ClusterDrmReason { + DRM_REASON_MIGRATE = 0, /* proposed for migration to target_node */ + DRM_SKIP_BELOW_MIN_ACCESS, /* sample-normalized total < min_access */ + DRM_SKIP_RATIO_LOW, /* dominant/total < drm_affinity_ratio_pct */ + DRM_SKIP_NOT_SUSTAINED, /* consecutive_hot_windows < triggers */ + DRM_SKIP_ALREADY_MASTER, /* dominant node already masters the shard */ + DRM_SKIP_TARGET_NOT_MEMBER, /* INV-DRM1: dominant is not a live MEMBER */ + DRM_SKIP_PINNED, /* feature-083 DBA affinity pin (v1 false) */ + DRM_SKIP_COOLDOWN, /* within the (adaptive) cooldown window */ + DRM_SKIP_RATE_LIMIT, /* drm_max_migrations_per_scan reached */ + DRM_SKIP_NO_NET_BENEFIT, /* benefit*residence <= drm_migration_cost */ + DRM_REASON__COUNT +} ClusterDrmReason; + +typedef struct ClusterDrmVerdict { + bool migrate; + int32 target_node; /* dominant accessing node, -1 if none */ + int32 reason; /* ClusterDrmReason */ +} ClusterDrmVerdict; + +/* + * Inputs the caller (LMON scan) supplies so cluster_drm_evaluate_shard stays a + * pure predicate (L409): no clock reads, no membership calls, no shmem writes + * inside — everything time/topology-dependent arrives here, which also makes + * the predicate directly unit-testable. + */ +typedef struct ClusterDrmDecisionCtx { + int32 current_master; /* cluster_grd_shard_master(shard) */ + uint64 now_us; /* GetCurrentTimestamp() at scan time */ + int active_sample_rate; /* for min-access normalization */ + int migrations_this_scan; /* D4 rate-limit counter (caller keeps) */ + bool pinned; /* cluster_drm_is_shard_pinned(shard) */ + const uint8 *member_bitmap; /* uint8[16] CLUSTER_MAX_NODES-bit live set */ + uint32 cooldown_shift; /* D4 adaptive-cooldown backoff exponent */ +} ClusterDrmDecisionCtx; + +/* + * Pure predicate (Amend v1.1-c / INV-DRM9): proposes only, never mutates. Net + * benefit uses a binary hop cost (local=0 / remote=1), so under that cost the + * frozen benefit_rate collapses to access[target]-access[current_master]; + * migrate iff benefit_rate * expected_residence_windows > drm_migration_cost. + */ +extern ClusterDrmVerdict cluster_drm_evaluate_shard(const ClusterDrmShardAffinity *a, + const ClusterDrmDecisionCtx *ctx); + +/* DBA affinity-pin guard hook (feature-083 forward; v1 always false). */ +extern bool cluster_drm_is_shard_pinned(uint32 shard_id); + +/* Observability: stable name for a ClusterDrmReason. */ +extern const char *cluster_drm_reason_name(int reason); + +#endif /* CLUSTER_DRM_DECISION_H */ diff --git a/src/include/cluster/cluster_drm_scan.h b/src/include/cluster/cluster_drm_scan.h new file mode 100644 index 0000000000..de7628cb31 --- /dev/null +++ b/src/include/cluster/cluster_drm_scan.h @@ -0,0 +1,34 @@ +/*------------------------------------------------------------------------- + * + * cluster_drm_scan.h + * LMON-side DRM decision scan driver (spec-7.6 wave 6.3c). + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/include/cluster/cluster_drm_scan.h + * + *------------------------------------------------------------------------- + */ +#ifndef CLUSTER_DRM_SCAN_H +#define CLUSTER_DRM_SCAN_H + +/* + * Periodic DRM decision scan, driven from the LMON main-loop tick. It self- + * gates to cluster.drm_scan_interval_ms and is a cheap no-op when + * cluster.drm_enabled is off, so both LMON drain sites can call it every tick. + * + * Wave 6.3c PROPOSES only (INV-DRM9 pure): it reconciles the sample rate, rolls + * each candidate shard's tumbling window, runs the pure decision predicate on + * the completed-window counts and counts per-reason verdicts + auto-actionable + * proposals for observability. It never mutates lock/master/GRD state — the + * live single-shard remaster executor is a later wave (6.3d). + */ +extern void cluster_drm_lmon_scan_tick(void); + +#endif /* CLUSTER_DRM_SCAN_H */ diff --git a/src/include/cluster/cluster_guc.h b/src/include/cluster/cluster_guc.h index 2e2dc478e6..e4b0fc237c 100644 --- a/src/include/cluster/cluster_guc.h +++ b/src/include/cluster/cluster_guc.h @@ -405,6 +405,23 @@ extern int cluster_ges_reply_wait_max_entries; * 5000ms; SIGHUP). */ extern int cluster_grd_remaster_wait_ms; extern int cluster_grd_rebuild_timeout_ms; + +/* spec-7.6 6.3b: DRM per-shard affinity collection GUCs (default off). The + * remaining ~8 drm.* GUCs (decision + anti-thrash + live remaster) land in + * waves 6.3c/6.3f. */ +extern bool cluster_drm_enabled; +extern int cluster_drm_affinity_sample_rate; +extern int cluster_drm_min_access_count; + +/* spec-7.6 6.3c: DRM hotness decision + anti-thrash GUCs. */ +extern int cluster_drm_affinity_ratio_pct; +extern int cluster_drm_consecutive_triggers; +extern int cluster_drm_affinity_window_ms; +extern int cluster_drm_cooldown_ms; +extern int cluster_drm_max_migrations_per_scan; +extern int cluster_drm_scan_interval_ms; +extern int cluster_drm_migration_cost; +extern bool cluster_drm_manual_only; extern int cluster_hw_remaster_retry_backoff_ms; extern int cluster_hw_remaster_retry_max_attempts; diff --git a/src/test/cluster_tap/t/017_debug.pl b/src/test/cluster_tap/t/017_debug.pl index eaaf1d5170..e03a28e744 100644 --- a/src/test/cluster_tap/t/017_debug.pl +++ b/src/test/cluster_tap/t/017_debug.pl @@ -63,8 +63,8 @@ 'postgres', q{SELECT string_agg(DISTINCT category, ',' ORDER BY category) FROM pg_cluster_state}), - 'advisory,block_format,buffer_format,catalog,cf,cluster_cssd,cluster_stats,conf,cr,cr_coord,cr_pool,diag,dl,gcs,gcs_recovery,ges,grd,grd_recovery,guc,hang,hw,ic,inject,ir,ko,lck,lmd,lmon,lms,pcm,pgstat,phase,reconfig,reconfig_join,reconfig_touched,recovery,resolver_cache,scn,sequence,shared_fs,shmem,sinval,smart_fusion,ts,tt_2pc,tt_recovery,tt_status,tt_status_hint,undo,undo_cleaner,visibility,wal_thread,write_fence,xid_stripe,xnode_lever,xnode_profile', - 'all 56 categories appear (spec-2.29a adds reconfig marker telemetry; spec-6.15 adds xid_stripe; spec-6.2 adds smart_fusion; spec-6.12 adds xnode_lever; spec-6.14 adds catalog; L122 alphabetic verify)'); + 'advisory,block_format,buffer_format,catalog,cf,cluster_cssd,cluster_stats,conf,cr,cr_coord,cr_pool,diag,dl,drm_affinity,gcs,gcs_recovery,ges,grd,grd_recovery,guc,hang,hw,ic,inject,ir,ko,lck,lmd,lmon,lms,pcm,pgstat,phase,reconfig,reconfig_join,reconfig_touched,recovery,resolver_cache,scn,sequence,shared_fs,shmem,sinval,smart_fusion,ts,tt_2pc,tt_recovery,tt_status,tt_status_hint,undo,undo_cleaner,visibility,wal_thread,write_fence,xid_stripe,xnode_lever,xnode_profile', + 'all 57 categories appear (spec-2.29a adds reconfig marker telemetry; spec-6.15 adds xid_stripe; spec-6.2 adds smart_fusion; spec-6.12 adds xnode_lever; spec-6.14 adds catalog; spec-7.6 adds drm_affinity; L122 alphabetic verify)'); # ---------- diff --git a/src/test/cluster_tap/t/020_shmem_registry.pl b/src/test/cluster_tap/t/020_shmem_registry.pl index 653707ac01..334fd84dc1 100644 --- a/src/test/cluster_tap/t/020_shmem_registry.pl +++ b/src/test/cluster_tap/t/020_shmem_registry.pl @@ -109,9 +109,11 @@ # between "pgrac cluster cr relgen" and "pgrac cluster cr tuple stats"). # spec-6.12h D-h3a: +1 "pgrac cluster pi shadow" (PI ship-SCN shadow table; # sorts between "pgrac cluster oid lease,pgrac cluster pcm grd" and "pgrac cluster qvotec"). -my $expected_region_count = $has_visibility_inject ? '80' : '79'; +# spec-7.6 6.3b: +1 "pgrac cluster drm affinity" (DRM per-shard access matrix; +# sorts between "pgrac cluster dl" and "pgrac cluster durable tt counters"). +my $expected_region_count = $has_visibility_inject ? '81' : '80'; my $expected_regions = - 'pgrac block recovery,pgrac cluster advisory,pgrac cluster backup,pgrac cluster catalog stats,pgrac cluster cf stats,pgrac cluster clean_leave,pgrac cluster conf,pgrac cluster control,pgrac cluster cr admit stats,pgrac cluster cr coordinator,pgrac cluster cr counters,pgrac cluster cr pool,pgrac cluster cr relgen,pgrac cluster cr server,pgrac cluster cr tuple stats,pgrac cluster cssd,pgrac cluster diag,pgrac cluster dl,pgrac cluster durable tt counters,pgrac cluster epoch,pgrac cluster fence,pgrac cluster gcs,pgrac cluster gcs block,pgrac cluster gcs block dedup,pgrac cluster ges,pgrac cluster ges dedup,pgrac cluster ges reply wait,pgrac cluster grd,pgrac cluster grd outbound,pgrac cluster grd pending,pgrac cluster grd work queue,pgrac cluster hw,pgrac cluster hw lease,pgrac cluster ir,pgrac cluster ko,pgrac cluster lck,pgrac cluster lmd,pgrac cluster lmd graph,pgrac cluster lmd probe,pgrac cluster lmon,pgrac cluster lms,pgrac cluster lms data outbound,pgrac cluster lock-path counters,pgrac cluster mrp,pgrac cluster multixact overlay,pgrac cluster node_remove,pgrac cluster oid lease,pgrac cluster pcm grd,pgrac cluster pi shadow,pgrac cluster qvotec,pgrac cluster reconfig,pgrac cluster resolver cache,pgrac cluster scn,pgrac cluster sequence,pgrac cluster sinval ack outbound,pgrac cluster sinval ack wait,pgrac cluster sinval inbound,pgrac cluster sinval outbound,pgrac cluster smart fusion deps,pgrac cluster smgr,pgrac cluster startup phase,pgrac cluster stats,pgrac cluster subtrans state,pgrac cluster ts,pgrac cluster tt local seq,pgrac cluster tt slot allocator,pgrac cluster tt status hint outbound,pgrac cluster tt status overlay,pgrac cluster tx enqueue,pgrac cluster undo cleaner,pgrac cluster undo record cursor'; + 'pgrac block recovery,pgrac cluster advisory,pgrac cluster backup,pgrac cluster catalog stats,pgrac cluster cf stats,pgrac cluster clean_leave,pgrac cluster conf,pgrac cluster control,pgrac cluster cr admit stats,pgrac cluster cr coordinator,pgrac cluster cr counters,pgrac cluster cr pool,pgrac cluster cr relgen,pgrac cluster cr server,pgrac cluster cr tuple stats,pgrac cluster cssd,pgrac cluster diag,pgrac cluster dl,pgrac cluster drm affinity,pgrac cluster durable tt counters,pgrac cluster epoch,pgrac cluster fence,pgrac cluster gcs,pgrac cluster gcs block,pgrac cluster gcs block dedup,pgrac cluster ges,pgrac cluster ges dedup,pgrac cluster ges reply wait,pgrac cluster grd,pgrac cluster grd outbound,pgrac cluster grd pending,pgrac cluster grd work queue,pgrac cluster hw,pgrac cluster hw lease,pgrac cluster ir,pgrac cluster ko,pgrac cluster lck,pgrac cluster lmd,pgrac cluster lmd graph,pgrac cluster lmd probe,pgrac cluster lmon,pgrac cluster lms,pgrac cluster lms data outbound,pgrac cluster lock-path counters,pgrac cluster mrp,pgrac cluster multixact overlay,pgrac cluster node_remove,pgrac cluster oid lease,pgrac cluster pcm grd,pgrac cluster pi shadow,pgrac cluster qvotec,pgrac cluster reconfig,pgrac cluster resolver cache,pgrac cluster scn,pgrac cluster sequence,pgrac cluster sinval ack outbound,pgrac cluster sinval ack wait,pgrac cluster sinval inbound,pgrac cluster sinval outbound,pgrac cluster smart fusion deps,pgrac cluster smgr,pgrac cluster startup phase,pgrac cluster stats,pgrac cluster subtrans state,pgrac cluster ts,pgrac cluster tt local seq,pgrac cluster tt slot allocator,pgrac cluster tt status hint outbound,pgrac cluster tt status overlay,pgrac cluster tx enqueue,pgrac cluster undo cleaner,pgrac cluster undo record cursor'; $expected_regions .= ',pgrac cluster visibility inject' if $has_visibility_inject; # spec-4.12 D7: cooperative write-fence region; always registered. Sorts after @@ -256,14 +258,14 @@ FROM pg_settings WHERE name = 'cluster.shmem_max_regions' }), - qr/^integer\|postmaster\|80\|(40|41)\|256$/, - 'L12 cluster.shmem_max_regions default 80 (spec-5.56: 64 -> 80 for cr relgen region)'); + qr/^integer\|postmaster\|96\|(40|41)\|256$/, + 'L12 cluster.shmem_max_regions default 96 (spec-5.56: 64 -> 80; spec-7.6 6.3b: 80 -> 96 for drm affinity region)'); is($node->safe_psql( 'postgres', q{SELECT value FROM pg_cluster_state WHERE category = 'guc' AND key = 'cluster.shmem_max_regions'}), - '80', + '96', 'L13 pg_cluster_state.guc.cluster.shmem_max_regions reflects live GUC'); diff --git a/src/test/cluster_tap/t/030_acceptance.pl b/src/test/cluster_tap/t/030_acceptance.pl index 7fa47b5e54..2b54c3c078 100644 --- a/src/test/cluster_tap/t/030_acceptance.pl +++ b/src/test/cluster_tap/t/030_acceptance.pl @@ -395,8 +395,8 @@ is($node->safe_psql('postgres', q{SELECT string_agg(DISTINCT category, ',' ORDER BY category) FROM pg_cluster_state}), - 'advisory,block_format,buffer_format,catalog,cf,cluster_cssd,cluster_stats,conf,cr,cr_coord,cr_pool,diag,dl,gcs,gcs_recovery,ges,grd,grd_recovery,guc,hang,hw,ic,inject,ir,ko,lck,lmd,lmon,lms,pcm,pgstat,phase,reconfig,reconfig_join,reconfig_touched,recovery,resolver_cache,scn,sequence,shared_fs,shmem,sinval,smart_fusion,ts,tt_2pc,tt_recovery,tt_status,tt_status_hint,undo,undo_cleaner,visibility,wal_thread,write_fence,xid_stripe,xnode_lever,xnode_profile', - 'O2 pg_cluster_state has all 56 categories (spec-2.29a adds reconfig marker telemetry; spec-6.15 adds xid_stripe; spec-6.12 adds xnode_lever; spec-6.2 adds smart_fusion; spec-6.14 adds catalog)'); + 'advisory,block_format,buffer_format,catalog,cf,cluster_cssd,cluster_stats,conf,cr,cr_coord,cr_pool,diag,dl,drm_affinity,gcs,gcs_recovery,ges,grd,grd_recovery,guc,hang,hw,ic,inject,ir,ko,lck,lmd,lmon,lms,pcm,pgstat,phase,reconfig,reconfig_join,reconfig_touched,recovery,resolver_cache,scn,sequence,shared_fs,shmem,sinval,smart_fusion,ts,tt_2pc,tt_recovery,tt_status,tt_status_hint,undo,undo_cleaner,visibility,wal_thread,write_fence,xid_stripe,xnode_lever,xnode_profile', + 'O2 pg_cluster_state has all 57 categories (spec-2.29a adds reconfig marker telemetry; spec-6.15 adds xid_stripe; spec-6.12 adds xnode_lever; spec-6.2 adds smart_fusion; spec-6.14 adds catalog; spec-7.6 adds drm_affinity)'); is($node->safe_psql('postgres', q{SELECT count(*) FROM pg_cluster_state WHERE value IS NULL}), diff --git a/src/test/cluster_tap/t/370_drm_affinity_collection_2node.pl b/src/test/cluster_tap/t/370_drm_affinity_collection_2node.pl new file mode 100644 index 0000000000..421d8a31a8 --- /dev/null +++ b/src/test/cluster_tap/t/370_drm_affinity_collection_2node.pl @@ -0,0 +1,147 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 370_drm_affinity_collection_2node.pl +# spec-7.6 6.3b — DRM per-shard affinity collection, 2-node leg. +# +# Validates the D2 admission hooks fire on a live 2-node pair and that the +# current master accumulates BOTH its own (local) and its peer's (remote) +# access samples — the "current-master authoritative" property (Amend +# v1.1.2 R2). A requester-side hook would only ever see access_count[self]; +# only the master, which every node's requests for its shards converge on, +# can build the full per-node matrix a dominant-node ratio needs. +# +# Both nodes take a broad range of distinct advisory locks. With the +# shipped round-robin master map (shard % declared_count) each node masters +# roughly half the shards, so each node records: +# - samples_local from its own requests for shards IT masters +# - samples_remote from the PEER's requests for shards IT masters +# (remote requests are sampled in cluster_ges_request_handler, which runs in +# the LMON process, and drained by the LMON tick — spec-7.6 6.3b R4.3). +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../lib"; + +use Time::HiRes qw(usleep); +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Test::More; + + +# drm_affinity dump counter accessor. +sub drm_ctr +{ + my ($node, $key) = @_; + my $v = $node->safe_psql('postgres', + "SELECT value FROM cluster_dump_state() " + . "WHERE category = 'drm_affinity' AND key = '$key'"); + return defined($v) && $v ne '' ? $v + 0 : -1; +} + +# Take a broad range of distinct advisory locks in one transaction so every key +# issues a first-logical GES REQUEST (peer-mastered keys route cross-node). +sub drive_locks +{ + my ($node, $base, $count) = @_; + $node->safe_psql('postgres', qq{ + DO \$\$ + DECLARE i int; + BEGIN + FOR i IN 0..$count LOOP + PERFORM pg_advisory_xact_lock($base + i); + END LOOP; + END \$\$; + }); +} + + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'drm370', + # DRM affinity collection needs a coordinated live pair (GES routing over a + # real quorum + GRD) exactly like the other live 2-node GES tests. + quorum_voting_disks => 3, + extra_conf => [ + 'cluster.shared_storage_backend = local', + 'cluster.grd_max_entries = 4096', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 10', + # DRM collection ON, sample every hit, count every access (deterministic + # for the test; production defaults stay off / 1-in-16 / 1000). + 'cluster.drm_enabled = on', + 'cluster.drm_affinity_sample_rate = 1', + 'cluster.drm_min_access_count = 1', + ]); +$pair->start_pair; + +$pair->wait_for_peer_state(0, 1, 'connected', 30) + or BAIL_OUT('node0 did not observe peer 1 connected'); +$pair->wait_for_peer_state(1, 0, 'connected', 30) + or BAIL_OUT('node1 did not observe peer 0 connected'); +usleep(3_000_000); # CSSD READY + quorum + LMS warmup settle + +is($pair->node0->safe_psql('postgres', 'SELECT 1'), '1', 'node0 accepts SQL'); +is($pair->node1->safe_psql('postgres', 'SELECT 1'), '1', 'node1 accepts SQL'); + +# The dump category is wired (D9 observability surface for 6.3b). +is($pair->node0->safe_psql('postgres', + "SELECT count(*) > 0 FROM cluster_dump_state() WHERE category = 'drm_affinity'"), + 't', 'drm_affinity dump category present on node0'); + +# Baseline: nothing sampled yet across the pair (both nodes just started). +# (Background lock traffic may exist, so we only require the DELTA below.) +my $n0_local0 = drm_ctr($pair->node0, 'samples_local'); +my $n0_remote0 = drm_ctr($pair->node0, 'samples_remote'); +my $n1_local0 = drm_ctr($pair->node1, 'samples_local'); +my $n1_remote0 = drm_ctr($pair->node1, 'samples_remote'); + +# Drive cross-node access demand from BOTH nodes over disjoint key ranges. A +# wide range guarantees each node both masters some of its own keys (local) and +# receives the peer's requests for shards it masters (remote), and enough remote +# requests to fill + drain the LMON sample ring. +drive_locks($pair->node0, 3_700_000, 400); +drive_locks($pair->node1, 3_800_000, 400); + +# Let the LMON tick drain the remote-sample ring on both nodes. +usleep(4_000_000); + +# Nudge the tick along with a little more traffic + settle. +drive_locks($pair->node0, 3_710_000, 100); +drive_locks($pair->node1, 3_810_000, 100); +usleep(4_000_000); + +my $n0_local = drm_ctr($pair->node0, 'samples_local'); +my $n0_remote = drm_ctr($pair->node0, 'samples_remote'); +my $n1_local = drm_ctr($pair->node1, 'samples_local'); +my $n1_remote = drm_ctr($pair->node1, 'samples_remote'); + +note("node0 local $n0_local0 -> $n0_local, remote $n0_remote0 -> $n0_remote"); +note("node1 local $n1_local0 -> $n1_local, remote $n1_remote0 -> $n1_remote"); + +# L1/L3 — local admission hook fires: each node records its own requests for +# shards it masters. +cmp_ok($n0_local, '>', $n0_local0, 'L1 node0 samples_local increased (local admission hook)'); +cmp_ok($n1_local, '>', $n1_local0, 'L3 node1 samples_local increased (local admission hook)'); + +# L2/L4 — remote admission hook fires: each node records the PEER's requests for +# shards it masters (current-master authoritative sees self + peer, Amend R2). +cmp_ok($n0_remote, '>', $n0_remote0, 'L2 node0 samples_remote increased (peer requests, R2 self+peer)'); +cmp_ok($n1_remote, '>', $n1_remote0, 'L4 node1 samples_remote increased (peer requests, R2 self+peer)'); + +# L5 — internal consistency: recorded == local + remote on each node (no path +# double-counts or drops; INV-DRM9 pure accounting). +my $n0_rec = drm_ctr($pair->node0, 'samples_recorded'); +is($n0_rec, $n0_local + $n0_remote, 'L5 node0 samples_recorded == local + remote'); + +$pair->stop_pair if $pair->can('stop_pair'); + +done_testing(); diff --git a/src/test/cluster_tap/t/388_drm_decision_scan_2node.pl b/src/test/cluster_tap/t/388_drm_decision_scan_2node.pl new file mode 100644 index 0000000000..23d338323a --- /dev/null +++ b/src/test/cluster_tap/t/388_drm_decision_scan_2node.pl @@ -0,0 +1,173 @@ +#!/usr/bin/env perl +#------------------------------------------------------------------------- +# +# 388_drm_decision_scan_2node.pl +# spec-7.6 6.3c — DRM hotness decision scan, 2-node value leg. +# +# End-to-end proof that the decision half runs on top of the 6.3b collection +# substrate: under a SKEWED workload (node1 hammers a wide key range while +# node0 stays idle), the shards node0 masters accumulate node1 as the sole +# dominant accessor. Once that dominance is sustained across the configured +# consecutive tumbling windows, node0's LMON decision scan PROPOSES migrating +# those masters to node1 (dominant != current master, benefit > cost). +# +# Wave 6.3c proposes only — nothing is executed here (the live remaster +# executor is 6.3d), so the assertions read the report-only observability +# surface (dump category drm_affinity: scans_run / scan_proposed / per-reason). +# +# A short-window profile (window 500 ms, scan 200 ms, 2 consecutive windows, +# cost 1) makes the first proposal land within a couple of seconds; these are +# test-fixture values, not the conservative production defaults. +# +# Legs: +# L1 — the decision scan actually runs on node0 (scans_run advances). +# L2 — sustained skew yields at least one auto-actionable proposal. +# L3 — with drm_manual_only off, every migrate verdict is a proposal +# (scan_migrate == scan_proposed: no double-count / no drop). +# L4 — drm_manual_only ON suppresses auto-proposals while the decision keeps +# firing (proposed delta 0, migrate-reason delta > 0). +# +# Author: SqlRush +# +# Portions Copyright (c) 2026, pgrac contributors +# +#------------------------------------------------------------------------- + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::RealBin/../lib"; + +use Time::HiRes qw(usleep); +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::ClusterPair; +use PostgreSQL::Test::Utils; +use Test::More; + +# drm_affinity dump counter accessor (-1 when the key is absent). +sub drm_ctr +{ + my ($node, $key) = @_; + my $v = $node->safe_psql('postgres', + "SELECT value FROM cluster_dump_state() " + . "WHERE category = 'drm_affinity' AND key = '$key'"); + return defined($v) && $v ne '' ? $v + 0 : -1; +} + +# One transaction that takes a small, fixed set of distinct advisory locks: every +# key issues a first-logical GES REQUEST, and the roughly-half that node0 masters +# route to it, where node1 becomes the dominant (only) accessor for those shards. +# Kept intentionally small + concentrated (hammered every round) so the per-shard +# heat builds fast without storming the 2-node GES with a wide lock set. +sub drive_round +{ + my ($node, $base, $count) = @_; + $node->safe_psql('postgres', qq{ + DO \$\$ + DECLARE i int; + BEGIN + FOR i IN 0..$count LOOP + PERFORM pg_advisory_xact_lock($base + i); + END LOOP; + END \$\$; + }); +} + +my $SKEW_BASE = 5_100_000; +my $SKEW_KEYS = 24; + +my $pair = PostgreSQL::Test::ClusterPair->new_pair( + 'drm388', + quorum_voting_disks => 3, + extra_conf => [ + 'cluster.shared_storage_backend = local', + 'cluster.grd_max_entries = 4096', + 'cluster.cssd_heartbeat_interval_ms = 2000', + 'cluster.cssd_dead_deadband_factor = 10', + # DRM decision ON with a deterministic short-window test profile (NOT the + # conservative production defaults). + 'cluster.drm_enabled = on', + 'cluster.drm_affinity_sample_rate = 1', + 'cluster.drm_min_access_count = 1', + 'cluster.drm_affinity_ratio_pct = 60', + 'cluster.drm_consecutive_triggers = 2', + 'cluster.drm_affinity_window_ms = 800', + 'cluster.drm_scan_interval_ms = 300', + # cooldown must be >> window: the net-benefit gate caps expected residence + # at cooldown/window windows, so a cooldown < window would floor it to 0. + 'cluster.drm_cooldown_ms = 30000', + 'cluster.drm_migration_cost = 1', + 'cluster.drm_max_migrations_per_scan = 64', + 'cluster.drm_manual_only = off', + ]); +$pair->start_pair; + +$pair->wait_for_peer_state(0, 1, 'connected', 30) + or BAIL_OUT('node0 did not observe peer 1 connected'); +$pair->wait_for_peer_state(1, 0, 'connected', 30) + or BAIL_OUT('node1 did not observe peer 0 connected'); +usleep(3_000_000); # CSSD READY + quorum + LMS warmup settle + +is($pair->node0->safe_psql('postgres', 'SELECT 1'), '1', 'node0 accepts SQL'); +is($pair->node1->safe_psql('postgres', 'SELECT 1'), '1', 'node1 accepts SQL'); + +# ---- Phase 1: sustained skew from node1, poll node0's cumulative proposals ---- +my $proposed = 0; +for my $round (1 .. 80) { + drive_round($pair->node1, $SKEW_BASE, $SKEW_KEYS); + usleep(250_000); + $proposed = drm_ctr($pair->node0, 'scan_proposed'); + last if $proposed > 0; +} + +my $scans_run = drm_ctr($pair->node0, 'scans_run'); +my $migrate = drm_ctr($pair->node0, 'scan_migrate'); +note("node0 scans_run=$scans_run scan_proposed=$proposed scan_migrate=$migrate"); + +# Diagnostic: full drm_affinity surface on both nodes (why did/didn't we propose). +for my $nlabel ([$pair->node0, 'node0'], [$pair->node1, 'node1']) { + my ($n, $lbl) = @$nlabel; + my $dump = $n->safe_psql('postgres', + "SELECT string_agg(key || '=' || value, ' ' ORDER BY key) " + . "FROM cluster_dump_state() WHERE category = 'drm_affinity'"); + diag("$lbl drm_affinity: $dump"); +} + +# L1 — the LMON decision scan runs on node0. +cmp_ok($scans_run, '>', 0, 'L1 node0 DRM decision scan runs (scans_run advanced)'); + +# L2 — sustained skew produces at least one auto-actionable proposal (full +# pipeline: collect -> tumbling window -> decision -> propose). +cmp_ok($proposed, '>', 0, 'L2 node0 proposes a remaster under sustained node1 skew'); + +# L3 — manual_only off: every migrate verdict is an auto-proposal (no drop / no +# double-count in the accounting). +is($migrate, $proposed, 'L3 scan_migrate == scan_proposed while drm_manual_only is off'); + +# ---- Phase 2: manual_only ON suppresses auto-proposals, decision still fires ---- +$pair->node0->safe_psql('postgres', 'ALTER SYSTEM SET cluster.drm_manual_only = on'); +$pair->node0->reload; +usleep(1_500_000); # let the LMON process re-read the SIGHUP'd GUC + +my $p_before = drm_ctr($pair->node0, 'scan_proposed'); +my $m_before = drm_ctr($pair->node0, 'scan_migrate'); + +for my $round (1 .. 40) { + drive_round($pair->node1, $SKEW_BASE, $SKEW_KEYS); + usleep(250_000); +} + +my $p_after = drm_ctr($pair->node0, 'scan_proposed'); +my $m_after = drm_ctr($pair->node0, 'scan_migrate'); +note("manual_only: proposed $p_before -> $p_after, migrate $m_before -> $m_after"); + +# L4a — no new auto-actionable proposal while manual_only is on. +is($p_after - $p_before, 0, 'L4a drm_manual_only suppresses auto-proposals'); + +# L4b — the decision still fires (observability retained): migrate reason advances. +cmp_ok($m_after - $m_before, '>', 0, 'L4b decision still evaluates MIGRATE under manual_only'); + +$pair->stop_pair if $pair->can('stop_pair'); + +done_testing(); diff --git a/src/test/cluster_unit/Makefile b/src/test/cluster_unit/Makefile index 094792a98f..75f0308d24 100644 --- a/src/test/cluster_unit/Makefile +++ b/src/test/cluster_unit/Makefile @@ -92,7 +92,9 @@ TESTS = test_cluster_basic test_cluster_version test_cluster_backend_types \ test_cluster_xid_stripe \ test_cluster_lms_shard \ test_cluster_gcs_block_dedup \ - test_cluster_gcs_block_shard + test_cluster_gcs_block_shard \ + test_cluster_drm_affinity \ + test_cluster_drm_decision # Path to the cluster_version object (no PG deps, safe to link standalone). CLUSTER_VERSION_O = $(top_builddir)/src/backend/cluster/cluster_version.o @@ -219,7 +221,7 @@ test_cluster_backup: test_cluster_backup.c unit_test.h $(CLUSTER_VERSION_O) \ # separate rules because they also link additional cluster_*.o # objects (the test files stub the PG backend symbols those # objects reference). -SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_mxid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_block_dedup_reclaim test_cluster_gcs_block_dedup_htab test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_multixact_served test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority test_cluster_lms_shard test_cluster_gcs_block_dedup test_cluster_gcs_block_shard,$(TESTS)) +SIMPLE_TESTS = $(filter-out test_cluster_guc test_cluster_shmem test_cluster_signal test_cluster_views test_cluster_gviews test_cluster_ic test_cluster_conf test_cluster_ic_mock test_cluster_inject test_cluster_pgstat test_cluster_debug test_cluster_shared_fs test_cluster_shared_fs_sharedfs test_cluster_shared_fs_block_device test_cluster_smgr test_cluster_startup_phase test_cluster_lmon test_cluster_lck test_cluster_diag test_cluster_stats test_cluster_cssd test_cluster_qvotec test_cluster_voting_disk_io test_cluster_quorum_decision test_cluster_scn test_cluster_adg test_cluster_epoch test_cluster_fence test_cluster_reconfig test_cluster_ges test_cluster_grd test_cluster_grd_starvation test_cluster_lmd test_cluster_lmd_graph test_cluster_lmd_wait_state test_cluster_cancel_token test_cluster_lmd_probe_collector test_cluster_lock_acquire test_cluster_advisory test_cluster_terminal_authority test_cluster_retention test_cluster_visibility_variants test_cluster_writer_chain test_cluster_tt_2pc test_cluster_stage3_acceptance test_cluster_undo_buf test_cluster_block_apply test_cluster_thread_apply test_cluster_thread_replay test_cluster_thread_driver test_cluster_thread_orchestrator test_cluster_write_fence test_cluster_stage4_acceptance test_cluster_stage5_integrated_acceptance test_cluster_stage5_beta_acceptance test_cluster_ges_mode test_cluster_sequence test_cluster_shared_catalog test_cluster_hw test_cluster_dl test_cluster_extend_gate test_cluster_ir test_cluster_ts test_cluster_ko test_cluster_hw_snapshot test_cluster_cf_authority test_cluster_cf_storage test_cluster_cf_enqueue test_cluster_cf_phase2 test_cluster_cf_stats test_cluster_hang test_cluster_hang_resolve test_cluster_cr_server_policy test_cluster_touched_peers test_cluster_clean_leave test_cluster_membership test_cluster_node_remove test_cluster_resolver_cache test_cluster_backup test_cluster_hang_acceptance test_cluster_gcs_reqid test_cluster_runtime_visibility test_cluster_xid_stripe test_cluster_mxid_stripe test_cluster_bufmgr_pcm_hook test_cluster_cr test_cluster_cr_admit test_cluster_cr_admit_stat test_cluster_cr_cache test_cluster_cr_coordinator test_cluster_cr_key test_cluster_cr_lifecycle test_cluster_cr_pool test_cluster_cr_tuple test_cluster_cr_tuple_stat test_cluster_gcs_block test_cluster_gcs_block_2way test_cluster_gcs_block_3way test_cluster_gcs_block_lost_write test_cluster_gcs_block_retransmit test_cluster_gcs_block_dedup_reclaim test_cluster_gcs_block_dedup_htab test_cluster_gcs_dispatch test_cluster_ges_handoff test_cluster_heap_lock_tuple test_cluster_hw_lease test_cluster_ic_envelope test_cluster_ic_router test_cluster_itl_cleanout test_cluster_itl_cleanout_perf test_cluster_itl_reader_real_triple test_cluster_itl_touch test_cluster_itl_wal test_cluster_multixact test_cluster_multixact_served test_cluster_pcm_lock test_cluster_perf_gates test_cluster_recovery_merge test_cluster_recovery_plan test_cluster_recovery_worker test_cluster_reverse_key test_cluster_sinval test_cluster_sinval_ack test_cluster_snapshot_source test_cluster_stage2_acceptance test_cluster_stage5_5_cr_acceptance test_cluster_subtrans test_cluster_tt_durable test_cluster_tt_slot_allocator test_cluster_tt_status test_cluster_tt_status_hint test_cluster_uba test_cluster_undo_format test_cluster_undo_lifecycle test_cluster_undo_record test_cluster_visibility_decide_scn test_cluster_visibility_fork test_cluster_visibility_inject test_cluster_wal_state test_cluster_wal_thread test_cluster_xnode_lever test_cluster_xnode_profile test_cluster_pi_shadow test_cluster_oid_lease test_cluster_xid_authority test_cluster_recovery_anchor test_cluster_relmap_authority test_cluster_lms_shard test_cluster_gcs_block_dedup test_cluster_gcs_block_shard test_cluster_drm_affinity test_cluster_drm_decision,$(TESTS)) # spec-2.4 D16: test_cluster_epoch links cluster_epoch.o standalone. # cluster_epoch.c references ShmemInitStruct + cluster_shmem_register_region # + pg_atomic_*; the test stubs all of these and exercises only the @@ -1947,3 +1949,26 @@ test_cluster_multixact_served: test_cluster_multixact_served.c unit_test.h \ $(CLUSTER_VIS_VERDICT_O) \ $(top_builddir)/src/common/libpgcommon_srv.a \ $(top_builddir)/src/port/libpgport_srv.a -o $@ + +# spec-7.6 6.3b: test_cluster_drm_affinity links cluster_drm_affinity.o standalone. +# The test stubs ShmemInitStruct / cluster_shmem_register_region / before_shmem_exit / +# RegisterXactCallback and defines the drm GUC globals, exercising the real +# sample/flush/collect/reconcile statistics paths standalone. +CLUSTER_DRM_AFFINITY_O = $(top_builddir)/src/backend/cluster/cluster_drm_affinity.o +test_cluster_drm_affinity: test_cluster_drm_affinity.c unit_test.h \ + $(CLUSTER_VERSION_O) $(CLUSTER_DRM_AFFINITY_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_VERSION_O) $(CLUSTER_DRM_AFFINITY_O) \ + $(top_builddir)/src/common/libpgcommon_srv.a \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ + +# spec-7.6 6.3c: test_cluster_drm_decision links cluster_drm_decision.o standalone. +# cluster_drm_evaluate_shard is a pure predicate; the test defines the drm.* GUC +# globals it reads and exercises the gate matrix + net-benefit model. +CLUSTER_DRM_DECISION_O = $(top_builddir)/src/backend/cluster/cluster_drm_decision.o +test_cluster_drm_decision: test_cluster_drm_decision.c unit_test.h \ + $(CLUSTER_VERSION_O) $(CLUSTER_DRM_DECISION_O) + $(CC) $(CFLAGS) $(CPPFLAGS) $< \ + $(CLUSTER_VERSION_O) $(CLUSTER_DRM_DECISION_O) \ + $(top_builddir)/src/common/libpgcommon_srv.a \ + $(top_builddir)/src/port/libpgport_srv.a -o $@ diff --git a/src/test/cluster_unit/test_cluster_debug.c b/src/test/cluster_unit/test_cluster_debug.c index d41a5a04c3..71cc554130 100644 --- a/src/test/cluster_unit/test_cluster_debug.c +++ b/src/test/cluster_unit/test_cluster_debug.c @@ -115,6 +115,36 @@ int cluster_smart_fusion_origin_durable_gossip_ms = 50; bool cluster_xnode_profile_enabled = false; ClusterXnodeProfileShared *ClusterXnodeProfileCtl = NULL; +/* spec-7.6 6.3b stubs: cluster_debug.c dump_drm_affinity reads these; the SRF + * body is never invoked by the unit test (same as the file's other SRF stubs). */ +bool cluster_drm_enabled = false; +uint64 +cluster_drm_affinity_get_sample_epoch(void) +{ + return 0; +} +uint64 +cluster_drm_affinity_get_counter(int which pg_attribute_unused()) +{ + return 0; +} +/* spec-7.6 6.3c stubs: dump_drm_affinity's decision-scan surface. */ +uint64 +cluster_drm_affinity_get_scan_counter(int which pg_attribute_unused()) +{ + return 0; +} +uint64 +cluster_drm_affinity_get_scan_reason(int reason pg_attribute_unused()) +{ + return 0; +} +const char * +cluster_drm_reason_name(int reason pg_attribute_unused()) +{ + return "stub"; +} + const char * cluster_xp_bucket_name(ClusterXnodeBucket b pg_attribute_unused()) { diff --git a/src/test/cluster_unit/test_cluster_drm_affinity.c b/src/test/cluster_unit/test_cluster_drm_affinity.c new file mode 100644 index 0000000000..0dda47a051 --- /dev/null +++ b/src/test/cluster_unit/test_cluster_drm_affinity.c @@ -0,0 +1,581 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_drm_affinity.c + * Standalone unit tests for pgrac DRM per-shard affinity collection. + * + * Links cluster_drm_affinity.o; ShmemInitStruct + region register + + * backend-hook registration are stubbed, and the GUC globals the module + * reads (cluster_drm_enabled / _sample_rate / _min_access_count) are + * defined here so the real sample/flush/collect/reconcile paths run + * standalone. Wave 6.3b (spec-7.6): collection substrate only — no GRD / + * GES linkage (the module takes shard_id + node directly), so these tests + * exercise the statistics logic in isolation. U-noselffeed (retry not + * counted) is an admission-gating property of D2 and is covered by the + * 2-node cluster_tap collection leg, not here. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/test/cluster_unit/test_cluster_drm_affinity.c + * + * NOTES + * pgrac-original test. Spec: spec-7.6-drm-hot-resource-detection-remaster.md + * (wave 6.3b; Amend v1.1-a / v1.1.2). + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "access/xact.h" /* XactCallback / XactEvent */ +#include "storage/ipc.h" /* pg_on_exit_callback */ + +#include "cluster/cluster_drm_affinity.h" +#include "unit_test.h" + +/* ============================================================ + * PG runtime + cross-module stubs. + * ============================================================ */ + +void +ExceptionalCondition(const char *conditionName pg_attribute_unused(), + const char *fileName pg_attribute_unused(), + int lineNumber pg_attribute_unused()) +{ + abort(); +} + +/* + * ShmemInitStruct stub — a single static ClusterDrmAffinityShared instance + * (BSS, naturally aligned) backs the one region this module registers. + */ +void * +ShmemInitStruct(const char *name, Size size, bool *foundPtr) +{ + if (name != NULL && strcmp(name, "pgrac cluster drm affinity") == 0) { + static ClusterDrmAffinityShared drm_buf; + + Assert(size <= sizeof(drm_buf)); /* catch shmem layout growth */ + /* Always report "not found" so each drm_test_reset() re-inits the region + * to a clean slate — the tests want per-test isolation, not shmem- + * persistence semantics. */ + *foundPtr = false; + return &drm_buf; + } + + *foundPtr = true; + return NULL; +} + +void +cluster_shmem_register_region(const void *r pg_attribute_unused()) +{} + +/* backend-hook registration deps (register_backend_hooks pulls these in). */ +bool IsUnderPostmaster = true; +int MyBackendId = 1; + +void +before_shmem_exit(pg_on_exit_callback function pg_attribute_unused(), + Datum arg pg_attribute_unused()) +{} + +void +RegisterXactCallback(XactCallback callback pg_attribute_unused(), void *arg pg_attribute_unused()) +{} + +/* GUC globals the module reads. */ +bool cluster_drm_enabled = false; +int cluster_drm_affinity_sample_rate = 16; +int cluster_drm_min_access_count = 1000; + +/* ============================================================ + * Test helpers. + * ============================================================ */ + +/* + * Fresh region + deterministic sampling: rate = 1 makes every sample() hit + * record (the per-backend countdown fires each call), so tests are exact. + */ +static void +drm_test_reset(void) +{ + cluster_drm_enabled = true; + cluster_drm_affinity_sample_rate = 1; + cluster_drm_min_access_count = 1; + cluster_drm_affinity_shmem_init(); /* re-inits the region to a clean slate */ + cluster_drm_affinity_reset_local_sampling(); /* drop carried-over per-backend cadence */ +} + +/* + * Window-primitive helpers. The window_* functions are pure with respect to a + * PASSED slot (no drm_state needed), so these tests build a stack slot directly. + */ +#define WIN_US 10000000ULL /* 10 s window in microseconds */ + +static void +init_window_slot(ClusterDrmShardAffinity *slot) +{ + int k; + + memset(slot, 0, sizeof(*slot)); + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_init_u32(&slot->access_count[k], 0); + pg_atomic_init_u32(&slot->ewma_total, 0); + pg_atomic_init_u64(&slot->window_start_ts, 0); + pg_atomic_init_u64(&slot->window_cluster_epoch, 0); + pg_atomic_init_u32(&slot->window_master_generation, 0); + pg_atomic_init_u64(&slot->window_sample_epoch, 0); + slot->consecutive_hot_windows = 0; + slot->last_migration_ts = 0; +} + +/* node 0 (master) = 10, node 1 (dominant) = 100: total 110, ratio 90% — hot. */ +static void +set_hot_counts(ClusterDrmShardAffinity *slot) +{ + int k; + + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_write_u32(&slot->access_count[k], 0); + pg_atomic_write_u32(&slot->access_count[0], 10); + pg_atomic_write_u32(&slot->access_count[1], 100); +} + +/* total 1 (< min_access 50 at rate 1) — cold. */ +static void +set_cold_counts(ClusterDrmShardAffinity *slot) +{ + int k; + + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_write_u32(&slot->access_count[k], 0); + pg_atomic_write_u32(&slot->access_count[0], 1); +} + +/* Judge + reset n consecutive hot windows; leaves consecutive_hot_windows == n. */ +static void +build_hot_streak(ClusterDrmShardAffinity *slot, uint64 *now, int n, uint64 cepoch, uint32 mgen) +{ + int i; + + init_window_slot(slot); + cluster_drm_window_open(slot, *now, cepoch, mgen); + for (i = 0; i < n; i++) { + set_hot_counts(slot); + *now += WIN_US; + cluster_drm_window_judge(slot, *now, WIN_US, 1, 50, 70, cepoch, mgen); + cluster_drm_window_reset(slot, *now, cepoch, mgen); + } +} + +/* ============================================================ + * U-sample-basic — sample() + flush() records into the master matrix. + * ============================================================ */ +UT_TEST(test_drm_sample_records_to_master_matrix) +{ + drm_test_reset(); + + cluster_drm_affinity_sample(5, 2, false); + cluster_drm_affinity_sample(5, 2, false); + cluster_drm_affinity_sample(5, 3, false); + cluster_drm_affinity_flush_local_ring(); + + UT_ASSERT_EQ(cluster_drm_affinity_access_count(5, 2), 2); + UT_ASSERT_EQ(cluster_drm_affinity_access_count(5, 3), 1); + UT_ASSERT_EQ(cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_RECORDED), 3); +} + +/* ============================================================ + * U5 — INV-DRM9 pure statistics: no cross-shard contamination + off = no record. + * (The stronger "master[]/shard_phase[] byte-unchanged" invariant is + * structural — this module links no GRD writer — and is asserted end-to-end + * by the 2-node collection cluster_tap leg.) + * ============================================================ */ +UT_TEST(test_drm_sample_pure_stats) +{ + drm_test_reset(); + + cluster_drm_affinity_sample(5, 2, false); + cluster_drm_affinity_flush_local_ring(); + + /* Neighbour shards untouched — per-shard isolation. */ + UT_ASSERT_EQ(cluster_drm_affinity_access_count(6, 2), 0); + UT_ASSERT_EQ(cluster_drm_affinity_access_count(4, 2), 0); + + /* drm_enabled = off → sample is a no-op (Amend v1.1-a④ cheap-exit). */ + cluster_drm_enabled = false; + cluster_drm_affinity_sample(7, 1, false); + cluster_drm_affinity_flush_local_ring(); + UT_ASSERT_EQ(cluster_drm_affinity_access_count(7, 1), 0); + UT_ASSERT(cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_SKIPPED_OFF) >= 1); +} + +/* ============================================================ + * U-collect — candidate = shard crossing (normalized) min_access on this node. + * ============================================================ */ +UT_TEST(test_drm_collect_candidates_threshold) +{ + uint32 out[16]; + int n; + int i; + bool saw10 = false; + bool saw11 = false; + + drm_test_reset(); + cluster_drm_min_access_count = 3; /* rate = 1 → normalized == raw */ + + cluster_drm_affinity_sample(10, 1, false); + cluster_drm_affinity_sample(10, 1, false); + cluster_drm_affinity_sample(10, 1, false); /* shard 10: 3 hits → candidate */ + cluster_drm_affinity_sample(11, 1, false); /* shard 11: 1 hit → below */ + cluster_drm_affinity_flush_local_ring(); + + n = cluster_drm_affinity_collect_candidates(out, 16); + for (i = 0; i < n; i++) { + if (out[i] == 10) + saw10 = true; + if (out[i] == 11) + saw11 = true; + } + UT_ASSERT(saw10); + UT_ASSERT(!saw11); +} + +/* ============================================================ + * U-normalize — collect compares raw*sample_rate vs min_access (Amend v1.1-a⑤). + * Tests the normalization formula: a single raw hit at rate 4 counts as 4. + * ============================================================ */ +UT_TEST(test_drm_collect_normalized_by_rate) +{ + uint32 out[16]; + int n; + int i; + bool saw20 = false; + + drm_test_reset(); + + cluster_drm_affinity_sample(20, 1, false); + cluster_drm_affinity_sample(20, 1, false); /* raw = 2 recorded at rate 1 */ + cluster_drm_affinity_flush_local_ring(); + + /* Now evaluate as if the active sample rate were 4: normalized = 2*4 = 8. */ + cluster_drm_affinity_sample_rate = 4; + + cluster_drm_min_access_count = 8; /* 8 >= 8 → candidate */ + n = cluster_drm_affinity_collect_candidates(out, 16); + for (i = 0; i < n; i++) + if (out[i] == 20) + saw20 = true; + UT_ASSERT(saw20); + + cluster_drm_min_access_count = 9; /* 8 < 9 → not a candidate */ + saw20 = false; + n = cluster_drm_affinity_collect_candidates(out, 16); + for (i = 0; i < n; i++) + if (out[i] == 20) + saw20 = true; + UT_ASSERT(!saw20); +} + +/* ============================================================ + * U-reconcile — sample_rate change reopens the window exactly ONCE and bumps + * the shared sample_epoch (Amend v1.1.2 R4.2, LMON single-writer). + * ============================================================ */ +UT_TEST(test_drm_reconcile_reopens_window_once) +{ + uint64 epoch0; + uint64 epoch1; + + drm_test_reset(); + epoch0 = cluster_drm_affinity_get_sample_epoch(); + + /* No change yet → reconcile is a no-op. */ + UT_ASSERT(!cluster_drm_affinity_reconcile_sample_rate()); + UT_ASSERT_EQ(cluster_drm_affinity_get_sample_epoch(), epoch0); + + /* Operator changes the rate → first reconcile reopens + bumps epoch. */ + cluster_drm_affinity_sample_rate = 8; + UT_ASSERT(cluster_drm_affinity_reconcile_sample_rate()); + epoch1 = cluster_drm_affinity_get_sample_epoch(); + UT_ASSERT(epoch1 > epoch0); + + /* Same rate again → no second reopen (idempotent). */ + UT_ASSERT(!cluster_drm_affinity_reconcile_sample_rate()); + UT_ASSERT_EQ(cluster_drm_affinity_get_sample_epoch(), epoch1); +} + +/* ============================================================ + * U-ring-lifecycle — a flush after a sample_epoch bump drops the stale batch + * (Amend v1.1.2 R4.3): ring entries carry the sample_epoch they were taken at. + * ============================================================ */ +UT_TEST(test_drm_ring_flush_drops_stale_epoch) +{ + uint64 dropped0; + + drm_test_reset(); + + /* Sample under the current epoch but DON'T flush yet. */ + cluster_drm_affinity_sample(30, 1, false); + + /* Operator bumps the rate → LMON reconcile bumps sample_epoch. */ + cluster_drm_affinity_sample_rate = 8; + UT_ASSERT(cluster_drm_affinity_reconcile_sample_rate()); + + /* The buffered sample is now stale → flush drops it, matrix unchanged. */ + dropped0 = cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_DROPPED_STALE); + cluster_drm_affinity_flush_local_ring(); + UT_ASSERT_EQ(cluster_drm_affinity_access_count(30, 1), 0); + UT_ASSERT(cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_DROPPED_STALE) > dropped0); +} + +/* ============================================================ + * U-window-identity — after a sample_epoch bump, a shard sampled under the old + * epoch is not a candidate until re-sampled at the new epoch (Amend v1.1.2 R5). + * ============================================================ */ +UT_TEST(test_drm_window_identity_discards_stale) +{ + uint32 out[16]; + int n; + int i; + bool saw40; + + drm_test_reset(); + cluster_drm_min_access_count = 1; + + cluster_drm_affinity_sample(40, 1, false); + cluster_drm_affinity_flush_local_ring(); + + /* Candidate under the current epoch. */ + saw40 = false; + n = cluster_drm_affinity_collect_candidates(out, 16); + for (i = 0; i < n; i++) + if (out[i] == 40) + saw40 = true; + UT_ASSERT(saw40); + + /* Bump sample_epoch → shard 40's window is stale → not a candidate. */ + cluster_drm_affinity_sample_rate = 8; + UT_ASSERT(cluster_drm_affinity_reconcile_sample_rate()); + saw40 = false; + n = cluster_drm_affinity_collect_candidates(out, 16); + for (i = 0; i < n; i++) + if (out[i] == 40) + saw40 = true; + UT_ASSERT(!saw40); + + /* Re-sample at the new epoch → candidate again. */ + cluster_drm_affinity_sample(40, 1, false); + cluster_drm_affinity_flush_local_ring(); + saw40 = false; + n = cluster_drm_affinity_collect_candidates(out, 16); + for (i = 0; i < n; i++) + if (out[i] == 40) + saw40 = true; + UT_ASSERT(saw40); +} + +/* ============================================================ + * U-local-remote-split — was_remote splits samples_local / samples_remote for + * observability, but BOTH are recorded into the matrix (Amend v1.1-a①: never + * filter to remote-only, else the master is blind to its own local heat). + * This is the counter surface the 2-node collection cluster_tap leg asserts + * self + peer on the current master with. + * ============================================================ */ +UT_TEST(test_drm_local_remote_split) +{ + drm_test_reset(); + + cluster_drm_affinity_sample(50, 0, false); /* local (self) */ + cluster_drm_affinity_sample(50, 1, true); /* remote (peer) */ + cluster_drm_affinity_flush_local_ring(); + + /* Both recorded — the master sees its own AND the peer's access. */ + UT_ASSERT_EQ(cluster_drm_affinity_access_count(50, 0), 1); + UT_ASSERT_EQ(cluster_drm_affinity_access_count(50, 1), 1); + UT_ASSERT_EQ(cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_LOCAL), 1); + UT_ASSERT_EQ(cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_REMOTE), 1); + UT_ASSERT_EQ(cluster_drm_affinity_get_counter(CLUSTER_DRM_AFFINITY_CTR_RECORDED), 2); +} + +/* ============================================================ + * U-window-open-due — a fresh slot is not open; open() anchors + stamps + * identity; the window is due only once window_us has elapsed (Amend v1.1-b). + * ============================================================ */ +UT_TEST(test_drm_window_open_and_due) +{ + ClusterDrmShardAffinity slot; + uint64 now = 1000000000ULL; + + init_window_slot(&slot); + UT_ASSERT(!cluster_drm_window_is_open(&slot)); + + cluster_drm_window_open(&slot, now, 7, 3); + UT_ASSERT(cluster_drm_window_is_open(&slot)); + UT_ASSERT(!cluster_drm_window_due(&slot, now, WIN_US)); /* just opened */ + UT_ASSERT(!cluster_drm_window_due(&slot, now + WIN_US / 2, WIN_US)); /* half window */ + UT_ASSERT(cluster_drm_window_due(&slot, now + WIN_US, WIN_US)); /* full window */ + + UT_ASSERT_EQ(pg_atomic_read_u64(&slot.window_cluster_epoch), 7); + UT_ASSERT_EQ(pg_atomic_read_u32(&slot.window_master_generation), 3); +} + +/* ============================================================ + * U-tumbling — each completed hot window increments consecutive_hot_windows + * exactly once (tumbling / non-overlapping); reset clears the per-node counts. + * ============================================================ */ +UT_TEST(test_drm_window_judge_hot_streak) +{ + ClusterDrmShardAffinity slot; + uint64 now = 1000000000ULL; + bool hot; + + init_window_slot(&slot); + cluster_drm_window_open(&slot, now, 7, 3); + + set_hot_counts(&slot); + now += WIN_US; + hot = cluster_drm_window_judge(&slot, now, WIN_US, 1, 50, 70, 7, 3); + UT_ASSERT(hot); + UT_ASSERT_EQ(slot.consecutive_hot_windows, 1); + cluster_drm_window_reset(&slot, now, 7, 3); + UT_ASSERT_EQ(pg_atomic_read_u32(&slot.access_count[1]), 0); /* reset cleared counts */ + + set_hot_counts(&slot); + now += WIN_US; + hot = cluster_drm_window_judge(&slot, now, WIN_US, 1, 50, 70, 7, 3); + UT_ASSERT(hot); + UT_ASSERT_EQ(slot.consecutive_hot_windows, 2); +} + +/* ============================================================ + * U-tumbling-cold — a cold window (below min_access) clears the streak to 0. + * ============================================================ */ +UT_TEST(test_drm_window_judge_cold_clears_streak) +{ + ClusterDrmShardAffinity slot; + uint64 now = 1000000000ULL; + bool hot; + + build_hot_streak(&slot, &now, 2, 7, 3); + UT_ASSERT_EQ(slot.consecutive_hot_windows, 2); + + set_cold_counts(&slot); + now += WIN_US; + hot = cluster_drm_window_judge(&slot, now, WIN_US, 1, 50, 70, 7, 3); + UT_ASSERT(!hot); + UT_ASSERT_EQ(slot.consecutive_hot_windows, 0); +} + +/* ============================================================ + * U-window-identity (R5) — a window whose {cluster_epoch, master_generation} + * identity changed spanned a remaster: the streak breaks (no stale heat). + * ============================================================ */ +UT_TEST(test_drm_window_judge_identity_change_breaks) +{ + ClusterDrmShardAffinity slot; + uint64 now = 1000000000ULL; + bool hot; + + build_hot_streak(&slot, &now, 2, 7, 3); + + set_hot_counts(&slot); + now += WIN_US; + /* master generation moved 3 -> 4 while the window was open. */ + hot = cluster_drm_window_judge(&slot, now, WIN_US, 1, 50, 70, 7, 4); + UT_ASSERT(!hot); + UT_ASSERT_EQ(slot.consecutive_hot_windows, 0); +} + +/* ============================================================ + * U-window-overdue — a grossly overdue window means the shard went cold and + * stopped being scanned: the streak breaks even if the counts now look hot. + * ============================================================ */ +UT_TEST(test_drm_window_judge_overdue_breaks) +{ + ClusterDrmShardAffinity slot; + uint64 now = 1000000000ULL; + bool hot; + + build_hot_streak(&slot, &now, 2, 7, 3); + + set_hot_counts(&slot); + now += 3 * WIN_US; /* anchor is > 2*window in the past */ + hot = cluster_drm_window_judge(&slot, now, WIN_US, 1, 50, 70, 7, 3); + UT_ASSERT(!hot); + UT_ASSERT_EQ(slot.consecutive_hot_windows, 0); +} + +/* ============================================================ + * U-window-cap — consecutive_hot_windows saturates, never overflows. + * ============================================================ */ +UT_TEST(test_drm_window_consecutive_cap) +{ + ClusterDrmShardAffinity slot; + uint64 now = 1000000000ULL; + bool hot; + + init_window_slot(&slot); + cluster_drm_window_open(&slot, now, 7, 3); + slot.consecutive_hot_windows = CLUSTER_DRM_WINDOW_CONSECUTIVE_MAX; + + set_hot_counts(&slot); + now += WIN_US; + hot = cluster_drm_window_judge(&slot, now, WIN_US, 1, 50, 70, 7, 3); + UT_ASSERT(hot); + UT_ASSERT_EQ(slot.consecutive_hot_windows, CLUSTER_DRM_WINDOW_CONSECUTIVE_MAX); +} + +/* ============================================================ + * U-scan-counters — the LMON scan observability surface tallies runs, + * candidates, per-reason verdicts, and auto-actionable proposals. + * ============================================================ */ +UT_TEST(test_drm_scan_observability_counters) +{ + drm_test_reset(); + + cluster_drm_affinity_record_scan_run(); + cluster_drm_affinity_record_scan_run(); + cluster_drm_affinity_record_verdict(0, true); /* migrate, proposed */ + cluster_drm_affinity_record_verdict(1, false); /* a skip */ + cluster_drm_affinity_record_verdict(1, false); /* a skip */ + + UT_ASSERT_EQ(cluster_drm_affinity_get_scan_counter(CLUSTER_DRM_AFFINITY_SCAN_RUNS), 2); + UT_ASSERT_EQ(cluster_drm_affinity_get_scan_counter(CLUSTER_DRM_AFFINITY_SCAN_CANDIDATES), 3); + UT_ASSERT_EQ(cluster_drm_affinity_get_scan_counter(CLUSTER_DRM_AFFINITY_SCAN_PROPOSED), 1); + UT_ASSERT_EQ(cluster_drm_affinity_get_scan_reason(0), 1); + UT_ASSERT_EQ(cluster_drm_affinity_get_scan_reason(1), 2); +} + +/* ============================================================ */ + +UT_DEFINE_GLOBALS(); + +int +main(int argc pg_attribute_unused(), char **const argv pg_attribute_unused()) +{ + UT_PLAN(15); + UT_RUN(test_drm_sample_records_to_master_matrix); + UT_RUN(test_drm_sample_pure_stats); + UT_RUN(test_drm_collect_candidates_threshold); + UT_RUN(test_drm_collect_normalized_by_rate); + UT_RUN(test_drm_reconcile_reopens_window_once); + UT_RUN(test_drm_ring_flush_drops_stale_epoch); + UT_RUN(test_drm_window_identity_discards_stale); + UT_RUN(test_drm_local_remote_split); + UT_RUN(test_drm_window_open_and_due); + UT_RUN(test_drm_window_judge_hot_streak); + UT_RUN(test_drm_window_judge_cold_clears_streak); + UT_RUN(test_drm_window_judge_identity_change_breaks); + UT_RUN(test_drm_window_judge_overdue_breaks); + UT_RUN(test_drm_window_consecutive_cap); + UT_RUN(test_drm_scan_observability_counters); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +} diff --git a/src/test/cluster_unit/test_cluster_drm_decision.c b/src/test/cluster_unit/test_cluster_drm_decision.c new file mode 100644 index 0000000000..cee496139d --- /dev/null +++ b/src/test/cluster_unit/test_cluster_drm_decision.c @@ -0,0 +1,337 @@ +/*------------------------------------------------------------------------- + * + * test_cluster_drm_decision.c + * Standalone unit tests for the pgrac DRM hotness decision engine. + * + * Links cluster_drm_decision.o. cluster_drm_evaluate_shard is a pure + * predicate (spec-7.6 6.3c / Amend v1.1-c), so the only externals are the + * drm.* GUC globals it reads — defined here — and pg_atomic (header inline). + * Each gate is exercised by perturbing ONE field of an all-gates-pass base + * scenario. + * + * + * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * Portions Copyright (c) 2026, pgrac contributors + * + * Author: SqlRush + * + * IDENTIFICATION + * src/test/cluster_unit/test_cluster_drm_decision.c + * + * NOTES + * pgrac-original test. Spec: spec-7.6-drm-hot-resource-detection-remaster.md + * (wave 6.3c; Amend v1.1-b/c/d). + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "cluster/cluster_drm_decision.h" +#include "unit_test.h" + +/* PG runtime stub. */ +void +ExceptionalCondition(const char *conditionName pg_attribute_unused(), + const char *fileName pg_attribute_unused(), + int lineNumber pg_attribute_unused()) +{ + abort(); +} + +/* drm.* GUC globals the decision engine reads. */ +int cluster_drm_min_access_count = 50; +int cluster_drm_affinity_ratio_pct = 70; +int cluster_drm_consecutive_triggers = 3; +int cluster_drm_affinity_window_ms = 10000; +int cluster_drm_cooldown_ms = 600000; +int cluster_drm_max_migrations_per_scan = 2; +int cluster_drm_migration_cost = 100; + +/* ============================================================ + * Helpers. + * ============================================================ */ + +static ClusterDrmShardAffinity g_slot; +static uint8 g_members[16]; + +/* Build a slot: node 0 (current master) = master_access, node 1 (dominant) = + * dom_access, everything else cold. */ +static void +build_slot(uint32 master_access, uint32 dom_access, uint32 consecutive, uint64 last_mig_ts) +{ + int k; + + memset(&g_slot, 0, sizeof(g_slot)); + for (k = 0; k < CLUSTER_MAX_NODES; k++) + pg_atomic_init_u32(&g_slot.access_count[k], 0); + pg_atomic_write_u32(&g_slot.access_count[0], master_access); + pg_atomic_write_u32(&g_slot.access_count[1], dom_access); + g_slot.consecutive_hot_windows = consecutive; + g_slot.last_migration_ts = last_mig_ts; +} + +static ClusterDrmDecisionCtx +base_ctx(void) +{ + ClusterDrmDecisionCtx ctx; + + memset(g_members, 0, sizeof(g_members)); + g_members[0] |= 1; /* node 0 member */ + g_members[0] |= 2; /* node 1 member */ + + ctx.current_master = 0; + ctx.now_us = 1000000000ULL; /* far past any last_migration_ts of 0 */ + ctx.active_sample_rate = 1; + ctx.migrations_this_scan = 0; + ctx.pinned = false; + ctx.member_bitmap = g_members; + ctx.cooldown_shift = 0; + return ctx; +} + +/* ============================================================ + * U-migrate — the all-gates-pass base scenario proposes MIGRATE to node 1. + * ============================================================ */ +UT_TEST(test_drm_decision_migrate_base) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10 /*master*/, 100 /*dominant*/, 5 /*consecutive*/, 0 /*never migrated*/); + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + + UT_ASSERT(v.migrate); + UT_ASSERT_EQ(v.reason, DRM_REASON_MIGRATE); + UT_ASSERT_EQ(v.target_node, 1); +} + +/* ============================================================ + * U-reason-matrix — one perturbation per gate (checked in gate order). + * ============================================================ */ +UT_TEST(test_drm_decision_skip_below_min_access) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 5, 0); + cluster_drm_min_access_count = 200; /* 110 total < 200 */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_BELOW_MIN_ACCESS); + cluster_drm_min_access_count = 50; +} + +UT_TEST(test_drm_decision_skip_ratio_low) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(50, 60, 5, 0); /* dominant 60/110 = 54% < 70% */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_RATIO_LOW); +} + +UT_TEST(test_drm_decision_skip_not_sustained) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 2, 0); /* consecutive 2 < triggers 3 */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_NOT_SUSTAINED); +} + +UT_TEST(test_drm_decision_skip_already_master) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 5, 0); + ctx.current_master = 1; /* dominant is already master */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_ALREADY_MASTER); +} + +UT_TEST(test_drm_decision_skip_target_not_member) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 5, 0); + g_members[0] = 1; /* only node 0 is a member; dominant node 1 is not */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_TARGET_NOT_MEMBER); +} + +UT_TEST(test_drm_decision_skip_pinned) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 5, 0); + ctx.pinned = true; + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_PINNED); +} + +UT_TEST(test_drm_decision_skip_cooldown) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + /* migrated 1 second ago; cooldown is 600 s → still cooling. */ + build_slot(10, 100, 5, ctx.now_us - 1000000ULL); + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_COOLDOWN); +} + +UT_TEST(test_drm_decision_skip_rate_limit) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 5, 0); + ctx.migrations_this_scan = 2; /* == max_migrations_per_scan */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_RATE_LIMIT); +} + +UT_TEST(test_drm_decision_skip_no_net_benefit) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + build_slot(10, 100, 5, 0); + cluster_drm_migration_cost = 1000000; /* benefit 90*5=450 << cost */ + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_NO_NET_BENEFIT); + cluster_drm_migration_cost = 100; +} + +/* ============================================================ + * U-benefit — binary-cost benefit = access[target] - access[current_master]; + * migrate iff benefit * residence_windows > migration_cost (Amend v1.1-c). + * ============================================================ */ +UT_TEST(test_drm_decision_benefit_residence) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + /* benefit = 100-10 = 90. residence_windows = min(cooldown/window, consecutive) + * = min(60, consecutive). Lower triggers to 1 so consecutive isolates the + * benefit gate. consecutive=1 → total 90*1=90 <= cost(100) → NO benefit; + * consecutive=5 → 90*5=450 > 100 → migrate. */ + cluster_drm_consecutive_triggers = 1; + + build_slot(10, 100, 1, 0); + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_NO_NET_BENEFIT); + + build_slot(10, 100, 5, 0); + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(v.migrate); + + cluster_drm_consecutive_triggers = 3; +} + +/* ============================================================ + * U-cooldown-backoff (Amend v1.1-d) — the adaptive cooldown_shift extends the + * base cooldown window by 2^shift; a migration that has cleared the base + * cooldown is still suppressed once the shard is on a backoff shift. + * ============================================================ */ +UT_TEST(test_drm_decision_cooldown_adaptive_backoff) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + ClusterDrmVerdict v; + + /* Base cooldown = 600000 ms = 600 s. Migrated 100 s ago → still cooling. */ + build_slot(10, 100, 5, ctx.now_us - 100000000ULL); + ctx.cooldown_shift = 0; + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_COOLDOWN); + + /* Migrated 700 s ago (> 600 s base) with shift 0 → cooldown cleared → migrate. */ + build_slot(10, 100, 5, ctx.now_us - 700000000ULL); + ctx.cooldown_shift = 0; + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(v.migrate); + + /* Same 700 s ago, but shift 1 doubles the window to 1200 s → still cooling. */ + ctx.cooldown_shift = 1; + v = cluster_drm_evaluate_shard(&g_slot, &ctx); + UT_ASSERT(!v.migrate); + UT_ASSERT_EQ(v.reason, DRM_SKIP_COOLDOWN); +} + +/* ============================================================ + * U-purity (L409 / INV-DRM9) — evaluate mutates nothing. + * ============================================================ */ +UT_TEST(test_drm_decision_pure_no_mutation) +{ + ClusterDrmDecisionCtx ctx = base_ctx(); + uint32 a0_before, a1_before; + uint32 consec_before; + + build_slot(10, 100, 5, 0); + a0_before = pg_atomic_read_u32(&g_slot.access_count[0]); + a1_before = pg_atomic_read_u32(&g_slot.access_count[1]); + consec_before = g_slot.consecutive_hot_windows; + + (void)cluster_drm_evaluate_shard(&g_slot, &ctx); + + UT_ASSERT_EQ(pg_atomic_read_u32(&g_slot.access_count[0]), a0_before); + UT_ASSERT_EQ(pg_atomic_read_u32(&g_slot.access_count[1]), a1_before); + UT_ASSERT_EQ(g_slot.consecutive_hot_windows, consec_before); +} + +/* ============================================================ + * U-pin-hook + reason-name — feature-083 hook is v1-false; names non-NULL. + * ============================================================ */ +UT_TEST(test_drm_decision_pin_hook_and_names) +{ + int r; + + UT_ASSERT(!cluster_drm_is_shard_pinned(0)); + UT_ASSERT(!cluster_drm_is_shard_pinned(4095)); + for (r = 0; r < DRM_REASON__COUNT; r++) + UT_ASSERT_NOT_NULL((void *)cluster_drm_reason_name(r)); +} + +/* ============================================================ */ + +UT_DEFINE_GLOBALS(); + +int +main(int argc pg_attribute_unused(), char **const argv pg_attribute_unused()) +{ + UT_PLAN(14); + UT_RUN(test_drm_decision_migrate_base); + UT_RUN(test_drm_decision_skip_below_min_access); + UT_RUN(test_drm_decision_skip_ratio_low); + UT_RUN(test_drm_decision_skip_not_sustained); + UT_RUN(test_drm_decision_skip_already_master); + UT_RUN(test_drm_decision_skip_target_not_member); + UT_RUN(test_drm_decision_skip_pinned); + UT_RUN(test_drm_decision_skip_cooldown); + UT_RUN(test_drm_decision_skip_rate_limit); + UT_RUN(test_drm_decision_skip_no_net_benefit); + UT_RUN(test_drm_decision_benefit_residence); + UT_RUN(test_drm_decision_cooldown_adaptive_backoff); + UT_RUN(test_drm_decision_pure_no_mutation); + UT_RUN(test_drm_decision_pin_hook_and_names); + UT_DONE(); + return ut_failed_count == 0 ? 0 : 1; +} diff --git a/src/test/cluster_unit/test_cluster_ges.c b/src/test/cluster_unit/test_cluster_ges.c index dd2a47185a..96999dacb6 100644 --- a/src/test/cluster_unit/test_cluster_ges.c +++ b/src/test/cluster_unit/test_cluster_ges.c @@ -218,6 +218,20 @@ cluster_shmem_register_region(const void *r pg_attribute_unused()) int cluster_node_id = 0; +/* spec-7.6 6.3b link stubs: cluster_ges.c's DRM admission hook references these + * (guarded by cluster_drm_enabled = false here, so they are never called). */ +bool cluster_drm_enabled = false; +void +cluster_drm_affinity_sample(uint32 shard_id pg_attribute_unused(), + int32 requesting_node pg_attribute_unused(), + bool was_remote pg_attribute_unused()) +{} +bool +cluster_grd_is_local_master(uint32 shard_id pg_attribute_unused()) +{ + return false; +} + bool cluster_qvotec_in_quorum(void) { diff --git a/src/test/cluster_unit/test_cluster_lmon.c b/src/test/cluster_unit/test_cluster_lmon.c index 598ca2b3d8..b295b5d25e 100644 --- a/src/test/cluster_unit/test_cluster_lmon.c +++ b/src/test/cluster_unit/test_cluster_lmon.c @@ -511,6 +511,17 @@ cluster_conf_lookup_node(int32 node_id pg_attribute_unused()) } int cluster_node_id = -1; +/* spec-7.6 6.3b link stubs: cluster_lmon.c drains the DRM ring (guarded by + * cluster_drm_enabled = false here, so the flush is never actually called). */ +bool cluster_drm_enabled = false; +void +cluster_drm_affinity_flush_local_ring(void) +{} +/* spec-7.6 6.3c: cluster_lmon.c also drives the DRM decision scan (guarded off). */ +void +cluster_drm_lmon_scan_tick(void) +{} + /* WaitEventSet API stubs (storage/latch.h). Never invoked at unit-test * runtime because the test doesn't call LmonMain. */ struct WaitEventSet; diff --git a/src/test/cluster_unit/test_cluster_lock_acquire.c b/src/test/cluster_unit/test_cluster_lock_acquire.c index a5ca5a719c..22039e3b88 100644 --- a/src/test/cluster_unit/test_cluster_lock_acquire.c +++ b/src/test/cluster_unit/test_cluster_lock_acquire.c @@ -204,6 +204,15 @@ struct PGPROC { struct PGPROC *MyProc = NULL; int cluster_node_id = 0; + +/* spec-7.6 6.3b link stubs: cluster_lock_acquire.c's S3 local-master DRM hook + * references these (guarded by cluster_drm_enabled = false, never called). */ +bool cluster_drm_enabled = false; +void +cluster_drm_affinity_sample(uint32 shard_id pg_attribute_unused(), + int32 requesting_node pg_attribute_unused(), + bool was_remote pg_attribute_unused()) +{} bool cluster_local_fast_path_enabled = true; /* spec-5.5 D7 — cluster.advisory_lock_enabled gate GUC (default on). The real diff --git a/src/test/cluster_unit/test_cluster_shmem.c b/src/test/cluster_unit/test_cluster_shmem.c index a5f87916c9..002188b047 100644 --- a/src/test/cluster_unit/test_cluster_shmem.c +++ b/src/test/cluster_unit/test_cluster_shmem.c @@ -464,6 +464,12 @@ void cluster_tx_enqueue_shmem_register(void) {} +/* spec-7.6 6.3b stub: cluster_init_shmem_module also calls + * cluster_drm_affinity_shmem_register. */ +void +cluster_drm_affinity_shmem_register(void) +{} + /* spec-3.2 D5b stub: cluster_init_shmem_module also calls * cluster_visibility_inject_shmem_register. */ void @@ -1075,13 +1081,14 @@ UT_TEST(test_cluster_shmem_iter_regions_returns_false_when_uninit) UT_TEST(test_cluster_shmem_max_regions_default_value) { /* - * The boot-value of cluster.shmem_max_regions is 80 (spec-5.56 raised it - * 64 -> 80 for the per-relation CR generation region; cluster_guc.c static + * The boot-value of cluster.shmem_max_regions is 96 (spec-5.56 raised it + * 64 -> 80 for the per-relation CR generation region; spec-7.6 6.3b raised + * it 80 -> 96 for the DRM affinity region + margin; cluster_guc.c static * initializer). Unit test links cluster_guc.o but never calls * cluster_init_guc, so the C global retains its static-initializer default. */ extern int cluster_shmem_max_regions; - UT_ASSERT_EQ(cluster_shmem_max_regions, 80); + UT_ASSERT_EQ(cluster_shmem_max_regions, 96); }