GitHub user leborchuk created a discussion: [Ideas] Add instrumentation and 
latency metrics to the Anser subsystem

### Description

# Description

Anser (`gpcontrib/anser/`) is a runtime pub/sub facility: producer nodes on the
segments publish a bloom filter over a join-build key, the coordinator unions
the parts, and consumer nodes on the segments receive it and prune probe rows.
See `gpcontrib/anser/README.md` for the architecture before starting.

Today we can see *what* the filter did (`Rows Removed by Bloom Filter` in
`EXPLAIN ANALYZE`) but nothing about *what it cost*. A producer blocks until the
coordinator acknowledges its part; a consumer blocks until the payload is
delivered. If those waits are expensive, the filter can be a net loss and we
currently have no way to tell.

## Goal 

Make every wait and every queueing delay in Anser measurable, both
per query (in `EXPLAIN`) and cluster-wide (in shared memory).

## Definition of done

- [ ] `EXPLAIN (ANALYZE, VERBOSE)` shows producer and consumer wait count and
      wait time, **including for segment-executed nodes**.
- [ ] `SELECT * FROM anser.stats()` shows queue time, service-cycle latency,
      and the histograms; `anser.stats_reset()` works.
- [ ] `SELECT * FROM anser.session_stats()` reflects the current session only.
- [ ] Regression tests pass and contain no timing-dependent expected values.
- [ ] `README.md` gains a "Metrics" section: every field, its unit, and where it
      is measured.
- [ ] No new LWLock acquisitions on the data path; no per-tuple timing calls.
- [ ] Core (`src/`) is untouched.

## What to measure

There are two distinct vantage points. Do not mix them up — this is the single
most important thing to understand before writing code.

| Vantage point | Who waits | Where the wait actually happens |
| --- | --- | --- |
| **Executor node** (segment or coordinator) | the query | segment: the libpq 
round trip in `AnserClientPublish` / `AnserClientConsumeWait` 
(`src/anserclient.c`); coordinator: the direct calls `AnserPublish` / 
`AnserWaitProducersRegistered` + `AnserWaitReady` + `AnserConsumeReady` 
(`src/anser.c`) |
| **Coordinator internals** | the QD backend serving a segment, and the two 
background workers | `AnserWaitSubmissionAck` (`src/anser.c:906`), 
`AnserWaitSlotResult` (`src/anser.c:1110`), `AnserGatherServiceCycle` 
(`src/anser.c:1235`), `AnserSendServiceCycle` (`src/anser.c:1433`) |

The first belongs in `EXPLAIN`. The second belongs in shared-memory counters,
because no single backend sees it.

## Implementation

Suggest three changes in this order. Each is independently reviewable and 
testable.

### Change 1 — per-node wait time in EXPLAIN

- [ ] Time the publish call in the producer node and the receive call in the
      consumer node. Record, per node: **number of waits**, **total wait time**,
      and **longest single wait**.
  - Producer: `AnserProducePublishPart()` in `src/anserbloomproduce.c` — it 
picks
    the transport by role, so wrapping it covers both segment and coordinator.
  - Consumer: `ExecAnserBloomFilterConsume()` in `src/anserbloomconsume.c`.
  - Store the accumulators in `AnserBloomProduceScanState` /
    `AnserBloomConsumeScanState` (`src/anserplanexec.c:81` and `:95`).
- [ ] Print them from `anser_produce_explain()` (`src/anserplanexec.c:422`) and
      `anser_consume_explain()` (`:636`), gated on `es->analyze` (there is
      nothing to print without execution) and shown only at
      `EXPLAIN (ANALYZE, VERBOSE)` — i.e. `es->analyze && es->verbose`.
      Use `ExplainPropertyFloat("Anser Wait Time", "ms", ...)` and
      `ExplainPropertyInteger("Anser Waits", NULL, ...)`.

**The trap you must handle.** A field you add to the node state on a *segment*
does **not** reach the QD. Read the comment at `src/anserplanexec.c:583-588`:
the existing code deliberately uses `InstrCountFiltered1/2` because only the
fixed fields of `CdbExplain_StatInst` (`src/backend/commands/explain_gp.c:44`)
travel back. Your new timers are not in that struct.

The supported escape hatch is the per-node *extra text* channel, which is how
`Hash` reports `Extra Text: (seg2) Hash chain length ...`:

- `PlanState.cdbexplainbuf` and `PlanState.cdbexplainfun`
  (`src/include/nodes/execnodes.h:1152-1153`)
- collected on the segment by `cdbexplain_collectExtraText()`
  (`src/backend/commands/explain_gp.c:1308`) and shipped to the QD
- **copy the pattern from `src/backend/executor/nodeRuntimeFilter.c:174-177`** —
  it is the closest existing analogue (a runtime-filter node doing exactly this)

So: allocate `cdbexplainbuf` in `*_begin` when `estate->es_instrument` is set,
install a `cdbexplainfun` that appends your numbers, and the QD will show them
as `Extra Text` per segment. **No core change is needed** — if you find yourself
editing anything under `src/`, stop and re-read this paragraph.

### Change 2 — subsystem-wide counters and latency histograms in shared memory

- [ ] Add a stats block to Anser's shared memory. Either extend `AnserControl`
      (`include/anser.h`) or add a separate struct; account for it in
      `AnserShmemSize()` and initialize it in `AnserShmemInit()`
      (`src/anser.c`), exactly as the existing structures do.
- [ ] Use `pg_atomic_uint64` (`src/include/storage/atomics.h`) for every counter
      and bucket, and update them **without** taking `AnserChannelLock` /
      `AnserRingLock`. These are on the data path; adding lock traffic for
      metrics would change what we are trying to measure.
- [ ] Measure, at minimum:

  | Metric | Where |
  | --- | --- |
  | time a part waits in the submission queue (enqueue → gather picks it up) | 
stamp the time in `AnserEnqueueSubmission()` (`src/anser.c:860`), read it in 
`AnserGatherServiceCycle()` (`:1235`) — add a field to `AnserSubmissionEntry` |
  | time a consumer slot waits for delivery (register → send stamps payload) | 
stamp in `AnserRegisterWaitSlot()` (`:1077`), read in `AnserSendServiceCycle()` 
(`:1433`) — add a field to `AnserWaitSlot` |
  | producer ack wait, on the QD side | `AnserWaitSubmissionAck()` (`:906`) |
  | consumer delivery wait, on the QD side | `AnserWaitSlotResult()` (`:1110`) |
  | background worker cycle duration, per service | around the 
`AnserGatherServiceCycle()` / `AnserSendServiceCycle()` calls in 
`AnserServiceLoop()` (`src/anserservice.c`) |
  | counts: parts submitted / accepted / rejected, channels cancelled by 
timeout, consumers delivered / cancelled | the same call sites |

- [ ] Histogram design: fixed power-of-two microsecond buckets, e.g. 20 buckets
      covering `<1µs, <2µs, ... , <512ms, >=512ms`. Compute the bucket with
      `pg_leftmost_one_pos64()` (`src/include/port/pg_bitutils.h`) — no loops, 
no
      floating point. Keep the bucket count a compile-time constant; the array
      lives in fixed shared memory sized at postmaster start.
- [ ] Use `instr_time` for all timing (`src/include/portability/instr_time.h`:
      `INSTR_TIME_SET_CURRENT`, `INSTR_TIME_SUBTRACT`, 
`INSTR_TIME_GET_MICROSEC`).
      Do **not** use `GetCurrentTimestamp()` for durations.

**Accuracy caveat to document in the code:** the wait loops sleep in slices
(`ANSER_WAIT_POLL_INTERVAL_MS` = 10 ms, `ANSER_WAIT_LATCH_TIMEOUT_MS` = 1000 ms,
`ANSER_SERVICE_WAKEUP_INTERVAL_MS` = 1000 ms — see `include/anser.h`). Measure
wall time across the *whole* wait call, never by counting loop iterations, and
note in a comment that sub-poll-interval waits are quantized.

### Change 3 — per-session stats and the user-facing view

- [ ] Accumulate this backend's Anser activity in a plain `static` struct in
      backend-local memory (no atomics, no locks — it is private).
- [ ] Flush it into the shared-memory totals once per query from an
      `ExecutorEnd_hook` (`src/include/executor/executor.h:108`). Install it in
      `_PG_init()` (`src/anserinit.c`) and **chain the previous hook** the same
      way `anser_planner()` chains `prev_planner_hook` — copy that pattern.
- [ ] Expose the numbers:
  - `anser.stats()` — set-returning function over the shared totals and the
    histogram (one row per bucket, or one row per metric with the histogram as
    an array; your call, document it), plus a matching view.
  - `anser.session_stats()` — the backend-local numbers.
  - `anser.stats_reset()` — zero the shared block; superuser only.
- [ ] Ship this as an **upgrade script**: add `anser--1.0--1.1.sql`, bump
      `default_version` in `anser.control`, and add both to `DATA` in the
      `Makefile`. Do not edit `anser--1.0.sql` in place — installed clusters
      already have 1.0.

## Testing

- [ ] Extend `gpcontrib/anser/sql/anser_test.sql` (+ `expected/anser_test.out`).
- [ ] **Never put a measured time or a histogram bucket count in expected
      output** — the tests would fail randomly. Assert properties instead:
      `anser_test_...() > 0`, `total_waits` increased after a known round trip,
      `stats_reset()` zeroes things. Return booleans from C test helpers
      (`src/anser_test.c`) rather than printing numbers.
- [ ] For the `EXPLAIN` part, prefer asserting on plan *shape* — a raw
      `EXPLAIN (ANALYZE, VERBOSE)` in expected output is unstable. If you must,
      filter the output through a query that checks only that the property is
      present.
- [ ] Run: `make -C gpcontrib/anser install && make -C gpcontrib/anser 
installcheck`
      (the `installcheck` target arms the cluster itself). Requires
      `shared_preload_libraries='anser'`, `anser.enable=on`, and
      `CREATE EXTENSION anser` in the test database.

### What I expect from code

#### `EXPLAIN (ANALYZE, VERBOSE)` — the normal case

Three segments, `anser_rf_build` (200 rows) joined to `anser_rf_probe`
(2000 rows), the filter working as intended:

```
EXPLAIN (ANALYZE, VERBOSE, COSTS OFF)
SELECT b.name, p.payload
  FROM anser_rf_build b JOIN anser_rf_probe p ON b.id = p.id;

 Gather Motion 3:1  (slice1; segments: 3) (actual time=38.412..39.104 rows=200 
loops=1)
   Output: b.name, p.payload
   ->  Hash Join (actual time=35.881..36.402 rows=67 loops=1)
         Output: b.name, p.payload
         Hash Cond: (p.id = b.id)
         ->  Custom Scan (Anser Bloom Consumer) (actual time=31.902..33.114 
rows=73 loops=1)
               Output: p.id, p.payload
               Bloom Filter Size: 1048576 bytes
               Bloom Filter Stats: memory=1024kB checked=667 rejected=594
               Rows Removed by Bloom Filter: 594
               Anser Waits: 1
               Anser Wait Time: 31.774 ms
               Anser Max Wait: 31.774 ms
               Extra Text: (seg0)   Anser consumer: waits=1 total=31.774ms 
max=31.774ms registration=0.412ms delivery=31.362ms transport=libpq
               ->  Seq Scan on public.anser_rf_probe p (actual 
time=0.021..0.204 rows=667 loops=1)
                     Output: p.id, p.payload
         ->  Hash (actual time=2.510..2.511 rows=67 loops=1)
               Output: b.name, b.id
               Buckets: 262144  Batches: 1  Memory Usage: 2049kB
               ->  Redistribute Motion 3:3  (slice2; segments: 3) (actual 
time=1.884..2.301 rows=67 loops=1)
                     Output: b.name, b.id
                     Hash Key: b.id
                     ->  Custom Scan (Anser Bloom Producer) (actual 
time=0.031..12.902 rows=67 loops=1)
                           Output: b.name, b.id
                           Bloom Filter Size: 1048576 bytes
                           Bloom Filter Stats: memory=1024kB
                           Anser Waits: 1
                           Anser Wait Time: 12.418 ms
                           Anser Max Wait: 12.418 ms
                           Extra Text: (seg1)   Anser producer: waits=1 
total=12.418ms max=12.418ms parts=1 bytes=1048640 transport=libpq
                           ->  Seq Scan on public.anser_rf_build b (actual 
time=0.014..0.098 rows=67 loops=1)
                                 Output: b.name, b.id
 Planning Time: 4.221 ms
   (slice0)    Executor memory: 84K bytes.
   (slice1)    Executor memory: 3204K bytes avg x 3 workers, 3210K bytes max 
(seg0).  Work_mem: 2049K bytes max.
   (slice2)    Executor memory: 412K bytes avg x 3 workers, 412K bytes max 
(seg1).
 Memory used:  128000kB
 Optimizer: Postgres query optimizer
 Execution Time: 39.884 ms
```

Read this example carefully — it encodes several requirements:

- **Three summary properties per node**, in this order: `Anser Waits`
  (integer), `Anser Wait Time` (ms), `Anser Max Wait` (ms). Emit them via
  `ExplainPropertyInteger` / `ExplainPropertyFloat` so JSON/YAML/XML output
  formats work for free — never `appendStringInfo` into the plan text.
- **The `Extra Text` line is the per-segment breakdown** shipped through
  `cdbexplainbuf` (see the main issue). The summary properties above it are the
  winning segment's values, exactly like every other per-node MPP statistic;
  `Extra Text` is where a specific segment's detail shows up. Keep it one line,
  `key=value` pairs, no line wrapping.
- **The consumer's wait splits into two phases**: `registration` (waiting for
  producers to announce themselves) and `delivery` (waiting for the payload
  after that). They come from different code paths and behave differently
  under load, so report them separately.
- **`transport=libpq` vs `transport=shmem`** tells you whether the node ran on
  a segment (libpq round trip to the QD) or coordinator-local (direct shared
  memory). Without it you cannot interpret the numbers at all.
- **Where the time lands is not symmetric.** The producer publishes after its
  child is exhausted, so its wait shows up in the node's *last* tuple time
  (`0.031..12.902`). The consumer must receive before it can return anything,
  so its wait shows up in the *first* tuple time (`31.902..`). Do not "fix"
  this; it is the truth about when each side blocks.
- The consumer emits 73 rows but the join emits 67: those 6 are bloom false
  positives. The filter is allowed to pass rows that do not join — it must never
  reject one that does.

#### `EXPLAIN (ANALYZE)` without VERBOSE — unchanged

The new lines are VERBOSE-only. Plain `ANALYZE` keeps exactly today's output:

```
         ->  Custom Scan (Anser Bloom Consumer) (actual time=31.902..33.114 
rows=73 loops=1)
               Bloom Filter Size: 1048576 bytes
               Bloom Filter Stats: memory=1024kB checked=667 rejected=594
               Rows Removed by Bloom Filter: 594
```

#### `EXPLAIN (ANALYZE, VERBOSE)` — the case this feature exists for

A producer never published (crash, or a segment that produced no rows and was
cancelled), the channel hit the produce deadline, and the consumer waited
`anser.timeout_ms` for nothing before failing open. The query is still correct —
just slower than it would have been with the feature off, which is precisely
what we currently cannot see:

```
         ->  Custom Scan (Anser Bloom Consumer) (actual time=1002.774..1003.918 
rows=667 loops=1)
               Output: p.id, p.payload
               Bloom Filter Size: 1048576 bytes
               Bloom Filter Stats: memory=1024kB (no filter received: cancelled)
               Anser Waits: 1
               Anser Wait Time: 1002.601 ms
               Anser Max Wait: 1002.601 ms
               Extra Text: (seg2)   Anser consumer: waits=1 total=1002.601ms 
max=1002.601ms registration=1000.118ms delivery=0.000ms result=cancelled 
transport=libpq
```

Note `rows=667` — every probe row passed, no pruning happened, and 1 second was
spent waiting. A `result=` field distinguishing `delivered` / `cancelled` /
`timeout` is required for exactly this reason.

#### `anser.stats()` — the shared-memory side (PR 2/3)

One row per metric, histogram buckets as an array so a single query is readable:

```
SELECT metric, count, total_ms, max_ms, p50_ms, p99_ms FROM anser.stats();

           metric            | count | total_ms | max_ms | p50_ms | p99_ms
-----------------------------+-------+----------+--------+--------+--------
 producer_ack_wait           |  1284 | 15012.44 |  84.12 |   8.19 |  65.53
 submission_queue_time       |  1284 |   612.08 |  16.38 |   0.26 |   4.09
 consumer_registration_wait  |   428 |  4118.72 | 131.07 |   4.09 | 131.07
 consumer_delivery_wait      |   428 | 12844.16 | 262.14 |  16.38 | 262.14
 send_slot_queue_time        |   428 |   131.07 |   2.05 |   0.13 |   1.02
 gather_cycle                | 18422 |   921.60 |   8.19 |   0.03 |   0.52
 send_cycle                  | 18422 |   755.20 |   4.09 |   0.02 |   0.26
(7 rows)

SELECT metric, buckets FROM anser.stats() WHERE metric = 
'consumer_delivery_wait';

         metric         |                     buckets
------------------------+--------------------------------------------------
 consumer_delivery_wait | {0,0,0,0,0,0,2,11,48,192,140,28,6,1,0,0,0,0,0,0}
(1 row)

SELECT * FROM anser.stats_counters();

          counter           | value
----------------------------+-------
 parts_submitted            |  1284
 parts_accepted             |  1281
 parts_rejected             |     3
 channels_timed_out         |     2
 consumers_delivered        |   426
 consumers_cancelled        |     2
(6 rows)
```

Because `p50_ms` / `p99_ms` are derived from the fixed power-of-two buckets they
are approximate — bucket boundaries, not interpolated values. Say so in the
`README.md` metrics table rather than pretending to millisecond precision.
`total_ms` and `max_ms` are exact.

### Use case/motivation

_No response_

### Related issues

https://github.com/apache/cloudberry/pull/1942

### Are you willing to submit a PR?

- [ ] Yes I am willing to submit a PR!

GitHub link: https://github.com/apache/cloudberry/discussions/1958

----
This is an automatically sent email for [email protected].
To unsubscribe, please send an email to: [email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to