diff --git a/.github/workflows/build-cloudberry.yml b/.github/workflows/build-cloudberry.yml index 83dcad822ad..1093597487d 100644 --- a/.github/workflows/build-cloudberry.yml +++ b/.github/workflows/build-cloudberry.yml @@ -243,6 +243,11 @@ jobs: "make_configs":["src/test/regress:installcheck-good"], "pg_settings":{"optimizer":"on"} }, + {"test":"ic-anser", + "make_configs":["gpcontrib/anser:installcheck-with-install"], + "shared_preload_libraries":"anser", + "postgres_conf_addons":"anser.enable=on" + }, {"test":"pax-ic-good-opt-off", "make_configs":[ "contrib/pax_storage/:pax-test", @@ -1533,6 +1538,15 @@ jobs: echo "Adding shared_preload_libraries: ${{ matrix.shared_preload_libraries }}" fi + # Append extra postgresql.conf settings requested by the matrix + # entry (e.g. postmaster-context GUCs a test module requires). + # BLDWRAP_POSTGRES_CONF_ADDONS is a pipe-separated list of + # key=value pairs (see gpAux/gpdemo/demo_cluster.sh). + if [[ -n "${{ matrix.postgres_conf_addons }}" ]]; then + EXTRA_CONF="${EXTRA_CONF}${EXTRA_CONF:+|}${{ matrix.postgres_conf_addons }}" + echo "Adding postgres_conf_addons: ${{ matrix.postgres_conf_addons }}" + fi + if ! time su - gpadmin -c "cd ${SRC_DIR} && NUM_PRIMARY_MIRROR_PAIRS='${{ matrix.num_primary_mirror_pairs }}' BLDWRAP_POSTGRES_CONF_ADDONS=\"${EXTRA_CONF}\" SRC_DIR=${SRC_DIR} ${SRC_DIR}/devops/build/automation/cloudberry/scripts/create-cloudberry-demo-cluster.sh"; then echo "::error::Demo cluster creation failed" exit 1 diff --git a/configure b/configure index d6ae6a0ab4a..1d5664668e5 100755 --- a/configure +++ b/configure @@ -819,6 +819,7 @@ GCOV enable_debug enable_orafce enable_faultinjector +with_anser enable_debug_extensions enable_pxf enable_gpfdist @@ -892,6 +893,7 @@ enable_strong_random enable_gpfdist enable_pxf enable_debug_extensions +with_anser enable_faultinjector enable_orafce enable_debug @@ -1663,6 +1665,7 @@ Optional Packages: --with-libraries=DIRS look for additional libraries in DIRS --with-libs=DIRS alternative spelling of --with-libraries --with-pgport=PORTNUM set default port number [5432] + --with-anser build the Anser runtime-filter extension --with-blocksize=BLOCKSIZE set table block size in kB [8] --with-segsize=SEGSIZE set table segment size in GB [1] @@ -3631,6 +3634,35 @@ fi +# +# Anser adaptive information sharing (runtime filters) +# + + + +# Check whether --with-anser was given. +if test "${with_anser+set}" = set; then : + withval=$with_anser; + case $withval in + yes) + : + ;; + no) + : + ;; + *) + as_fn_error $? "no argument expected for --with-anser option" "$LINENO" 5 + ;; + esac + +else + with_anser=no + +fi + + + + # # Enable fault injector # diff --git a/configure.ac b/configure.ac index 264f59e4b51..e85e3ddb5fc 100644 --- a/configure.ac +++ b/configure.ac @@ -241,6 +241,13 @@ PGAC_ARG_BOOL(enable, debug-extensions, yes, [exclude debug extensions in gpcontrib]) AC_SUBST(enable_debug_extensions) +# +# Anser adaptive information sharing (runtime filters) +# +PGAC_ARG_BOOL(with, anser, no, + [build the Anser runtime-filter extension]) +AC_SUBST(with_anser) + # # Enable fault injector # diff --git a/devops/build/automation/cloudberry/scripts/configure-cloudberry.sh b/devops/build/automation/cloudberry/scripts/configure-cloudberry.sh index eda9586c28f..694dbea4e8f 100755 --- a/devops/build/automation/cloudberry/scripts/configure-cloudberry.sh +++ b/devops/build/automation/cloudberry/scripts/configure-cloudberry.sh @@ -33,6 +33,7 @@ # - MapReduce Processing # - Oracle Compatibility (orafce) # - ORCA Query Optimizer +# - Anser Runtime Filters # - PAX Access Method # - PXF External Table Access # - Test Automation Support (tap-tests) @@ -162,6 +163,7 @@ execute_cmd ./configure --prefix=${BUILD_DESTINATION} \ --disable-pxf \ --enable-tap-tests \ ${CONFIGURE_DEBUG_OPTS} \ + --with-anser \ --with-diskquota \ --with-gp-stats-collector \ --with-gssapi \ diff --git a/gpcontrib/Makefile b/gpcontrib/Makefile index a03c881a92f..77e03508b65 100755 --- a/gpcontrib/Makefile +++ b/gpcontrib/Makefile @@ -51,6 +51,12 @@ endif ifeq "$(with_gp_stats_collector)" "yes" recurse_targets += gp_stats_collector endif + +# Anser is opt-in at configure time: --with-anser (off by default). +ifeq "$(with_anser)" "yes" + recurse_targets += anser +endif + $(call recurse,all install clean distclean, $(recurse_targets)) all: gpcloud pxf mapreduce orafce @@ -113,3 +119,4 @@ installcheck: $(MAKE) -C gp_toolkit installcheck $(MAKE) -C gp_exttable_fdw installcheck if [ "$(with_diskquota)" = "yes" ]; then $(MAKE) -C diskquota installcheck; fi + if [ "$(with_anser)" = "yes" ]; then $(MAKE) -C anser installcheck; fi diff --git a/gpcontrib/anser/Makefile b/gpcontrib/anser/Makefile new file mode 100644 index 00000000000..42c93ac0ff7 --- /dev/null +++ b/gpcontrib/anser/Makefile @@ -0,0 +1,103 @@ +#------------------------------------------------------------------------- +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +# Makefile for the anser extension. +# +# IDENTIFICATION +# gpcontrib/anser/Makefile +# +#------------------------------------------------------------------------- + +MODULE_big = anser +OBJS = \ + $(WIN32RES) \ + src/anserbloomconsume.o \ + src/anserbloomproduce.o \ + src/anserdispatch.o \ + src/anserfilter.o \ + src/anserinit.o \ + src/anserpayload.o \ + src/anserplan.o \ + src/anserplanexec.o \ + src/ansersideband.o \ + src/anser_test.o + +PGFILEDESC = "anser - adaptive information sharing runtime filters" + +# The subsystem itself needs no catalog objects -- it is configured entirely by +# GUCs and travels over the dispatch connection. The only extension here is +# anser_test, which exposes the internal C API to the regression tests from +# inside the same library. +EXTENSION = anser_test +DATA = anser_test--1.0.sql + +REGRESS = anser_test anser_runtime_filter anser_multijoin + +# src/anserdispatch.c writes sideband messages onto the dispatcher's own libpq +# connections, so it needs libpq's internal headers (as the dispatcher itself +# does). Deliberately NOT linked against libpq: the pq* writers we use stay +# global in the postgres binary, while the public PQ* API is made local there +# on purpose (src/backend/Makefile), so linking libpq.so would put a second +# copy of libpq on connections the backend's copy created. +PG_CPPFLAGS = -I$(srcdir)/include -I$(libpq_srcdir) +SHLIB_PREREQS = submake-libpq + +ifdef USE_PGXS +PG_CONFIG ?= pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = gpcontrib/anser +top_builddir = ../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif + +# The tests need the live gather/send services, which only exist when the +# library is preloaded and anser.enable is on -- both postmaster-context +# settings. Ensure them (and restart) before installcheck, as +# gp_relsizes_stats does for its worker. +installcheck: preload-anser + +.PHONY: preload-anser +preload-anser: + @if [ -z "$$COORDINATOR_DATA_DIRECTORY$$MASTER_DATA_DIRECTORY" ]; then \ + echo "ERROR: COORDINATOR_DATA_DIRECTORY (or MASTER_DATA_DIRECTORY) is not set;" >&2; \ + echo " source cloudberry-env.sh and gpdemo-env.sh before running installcheck." >&2; \ + exit 1; \ + fi + @current=`psql -d postgres -At -c "SHOW shared_preload_libraries;" | tr -d ' '`; \ + case ",$$current," in \ + *,anser,*) \ + echo "==> shared_preload_libraries already contains anser" ;; \ + *) \ + if [ -z "$$current" ]; then new=anser; else new="$$current,anser"; fi; \ + echo "==> Adding anser to shared_preload_libraries ($$new)"; \ + gpconfig -c shared_preload_libraries -v "'$$new'" --skipvalidation ;; \ + esac + gpconfig -c anser.enable -v on --skipvalidation + gpstop -ra + psql -d postgres -c "SHOW shared_preload_libraries;" + +# CI runs a single make target per test config, so offer one target that +# installs the extension, arms the cluster, and runs the tests. +.PHONY: installcheck-with-install +installcheck-with-install: + $(MAKE) install + $(MAKE) installcheck diff --git a/gpcontrib/anser/README.md b/gpcontrib/anser/README.md new file mode 100644 index 00000000000..66cb5762508 --- /dev/null +++ b/gpcontrib/anser/README.md @@ -0,0 +1,475 @@ +# Anser — adaptive information sharing + +Anser is a runtime pub/sub facility for MPP query execution. Producers on the +segments publish a small piece of information about a running query (today: a +bloom filter over a join-build key), the coordinator combines the per-segment +parts into one, and consumers on the segments receive it and use it to prune +work (today: skip probe rows that cannot join). + +Everything travels over the **dispatch connection the coordinator already holds +open to every segment**. There is no shared memory, no background worker, no +second connection and nothing extra to authenticate: a channel exists only in +the coordinator backend running the query, for exactly as long as that query +runs. + +This document covers installation, the architecture, the wire protocol, the +configuration surface, and what a *channel* is. For the plan-tree integration +see `anserplan.c`; for the payload/bloom protocol see `anserfilter.c` and +`lib/bloomfilter.c`. + +## Installation + +Anser is configured entirely by GUCs — it creates no catalog objects, so there +is nothing to `CREATE EXTENSION` in each database. + +``` +gpconfig -c shared_preload_libraries -v "'anser'" --skipvalidation +gpconfig -c anser.enable -v on +gpstop -ra +``` + +The library **must** be preloaded: a segment backend deserializing a dispatched +plan has no opportunity to load it on demand, and its CustomScan providers have +to be registered before that happens. + +Then turn the filter on where you want it — `anser.runtime_filter` is +`PGC_USERSET`, so per session, per role, or cluster-wide. + +`anser_test` is a separate control file over the same library, exposing the +internal C API to the regression tests. It is test-only; do not create it in +production databases. + +## Architecture + +A **channel** is one rendezvous point between the producers and consumers of a +single piece of runtime information, for a single query: + +``` +AnserChannelKey = { gp_session_id, gp_command_count, condition_id, condition_key[64] } +``` + +- `gp_session_id` + `gp_command_count` scope the channel to one query execution, + so keys never collide across sessions or across statements in a session. +- `condition_id` distinguishes multiple filters within the same query. +- `condition_key` is an opaque string describing the filtered condition — today + just `anser_rf_`, since the planner stamps the same key into + both plan nodes at injection time. It exists for the case where the key has to + be derived from the build's semantic identity instead (so that producer and + consumer can find each other without having been planned together), which is + why the wire format treats it as arbitrary bytes rather than as an identifier. + +Channels live in a hash in the coordinator backend, created on first use and +dropped at `ExecutorEnd` (or on transaction abort). Since the merge and the +delivery both happen in that one process, the accumulator is an ordinary +`palloc`'d buffer. + +### Where the nodes go + +Both nodes wrap a **base scan**, never an intermediate node. The producer goes +above the scan the join's build key comes from, the consumer above the scan its +probe key comes from; either may be several joins away, and `anser_resolve_key_scan()` +follows the key column down through Hash, Motion, HashJoin and our own nodes to +find it. + +Wrapping a base scan is not a preference. After `set_plan_references` an +intermediate node's targetlist holds `OUTER_VAR`/`INNER_VAR` Vars, and a +`CustomScan` keeps its child in `custom_plans` rather than in `lefttree`, so +copying such a targetlist into `custom_scan_tlist` leaves EXPLAIN resolving an +`OUTER_VAR` against a node with no outer plan — `get_variable()` raises +"bogus varno" and the plan cannot be printed at all. + +Two consequences: + +- A filter built from a base scan holds that relation's **whole** key column, + not the subset the joins below it would have left. A superset never rejects a + row that could join, so it is correct; it is just less selective than a filter + built on the joined result would be. +- When two joins reach the same relation, the second **stacks** its consumer + under the first rather than replacing it, so the scan is filtered by both. + +The direction is fixed: a filter can only flow **build → probe**. A hash join +builds its hash table from the inner side before reading the outer, so a +consumer on the inner side would wait for a producer whose rows the join is not +yet reading — and that producer only publishes once its subtree is drained, +which it cannot be. That is a deadlock, not a slow path, and it is why the +intermediate result cannot filter the table it is being joined to. + +### Only across a Motion + +A join is injected into only when its build and probe scans run in **different +slices**. Slices are divided by Motions and nothing else, so this says: filter +only where the rows already travel over the network. + +It is a safety rule first. An exchange is a synchronisation point — the +consumer stops until the coordinator has heard from every producer — and +within one slice producer and consumer are the same process, run one after the +other. A consumer placed under an intervening hash join then waits for a +producer in that join's inner side, which cannot start until the outer side +yields the very tuple the consumer is holding. `defer_first` covers exactly +one tuple of that, and no more. Across a Motion they are separate processes +and the question does not arise. + +It is also what makes the exchange worth its cost. Where a Motion already +carries the rows, the barrier is paid for by work the query was doing anyway. +In a co-located join it is new cost against a join that was already local, and +whether it pays for itself is a measurement nobody has made — so until someone +does, those joins are left alone. + +### Giving up early + +A bloom filter that is too small for its key count matches almost everything: it +costs a hash and `k` probes per probe row and eliminates nothing. Anser checks +for that at the three points where new information becomes available, and always +fails open — giving up means the query runs unfiltered, never that it runs wrong. + +| Where | Knows | Check | Cost of giving up | +| --- | --- | --- | --- | +| Planner (`anserplan.c`) | the row estimate | bitset bits / estimated keys ≥ 4 | nothing — the nodes are never injected | +| Producer init (`anserfilter.c`) | the plan parameters | the filter fits the payload cap, and still ≥ 4 bits/key | one `palloc0`, freed immediately; cancel published on the first tuple, before the build side is scanned | +| Producer publish (`anserbloomproduce.c`) | the filter, fully built | estimated FPR (`fill^k`) ≤ 50% | the build scan, already spent; saves the network and every consumer's probing | +| Coordinator merge (`anserdispatch.c`) | the merged payload | fill ≤ 95% | the merge, already spent; saves delivery and probing | + +The planner gate is the one that matters, because it is the only one that costs +nothing. The rest exist because a row estimate can be wrong, and the last one +exists because **folding changes the answer**: OR-ing three parts that are each +60% full gives a merged filter that is 94% full, so no producer can tell whether +the result will be useful. + +One subtlety worth knowing if you touch the sizing: do **not** clamp the element +estimate to make a filter fit. `total_elems` is also what `optimal_k()` derives +the hash count from, so understating it misconfigures the filter — a 128M-row +build side declared as 33.5M gets `k=10` where the optimum is `k=3`, turning a +13% false positive rate into 38%. The keys all go in regardless of what was +written down. Clamp the *size*; keep the count honest. + +### Parallel execution + +A parallel slice runs `numsegments × parallel_workers` processes, and **every +one of them is a full QE with its own dispatch connection** — not a PostgreSQL +background worker. So each publishes and subscribes for itself, and the +coordinator folds however many parts arrive. Nothing about the transport or the +merge changes. + +Only the number of parts to expect differs, and it is not the segment count: it +is the width of the build scan's slice, `numsegments × parallel_workers`. The +planner computes that while injecting and stamps it into the plan node +(`ANSER_RF_PRIV_N_PRODUCERS`); the executor reads it from there. Both optimizers +go through the same path, and a join whose slice cannot be identified is not +injected into. + +### Delivery cost + +Fan-in is parallel and fan-out is not. Every producer uploads its part at the +same time while the coordinator reads as it goes, but `anser_disp_deliver()` +then walks its subscribers and writes the merged filter to each in turn, +blocking until that peer has taken it. Delivery latency is therefore the *sum* +of the writes, not the slowest one, and it grows with the width of the slice. + +Measured on 4 segments, 5000 build rows, a 1 MB filter (`bloom_create` floors +every bitset at 1 MB, whatever the cardinality): + +| Slice width | Subscribers | Fan-in | Fan-out | Consumer stalls | +| --- | --- | --- | --- | --- | +| 4 | 4 | ~40 ms | ~520 ms | 549 ms | +| 16 | 16 | ~100 ms | ~1140 ms | 1005 ms, then gave up | + +The second row is why the default deadline is 100 s rather than 1 s: at 16 +subscribers the fan-out outlived a 1 s deadline, so every consumer ran +unfiltered. Raising it makes the filter arrive, but the wait is not free — the +first subscriber is served in under a millisecond and the last over a second +later, and none of them can tell where in the queue they are. + +**A filter can cost more than it saves.** In the 16-wide plan the consumer waits +160 ms to skip a probe scan that takes 40 ms. Anser is worth enabling when the +build side is selective *and* the probe side is large enough that the scan +dominates the exchange — not on small joins, and not, today, on wide slices. + +Each part is a whole bitset, because a union is a bitwise OR: N producers send +N × the full payload, and the coordinator returns the full payload to each of N +consumers. Nothing amortises with cluster size. For a 64 MB filter — the +ceiling `anser.max_info_size` allows — on 128 segments: + +| `parallel_workers` | Producers = consumers | Fan-in (base64, ×4/3) | Fan-out (raw) | Through the coordinator | +| --- | --- | --- | --- | --- | +| 1 | 128 | 10.7 GiB | 8.0 GiB | **18.7 GiB** | +| 4 | 512 | 42.7 GiB | 32.0 GiB | **74.7 GiB** | +| 8 | 1024 | 85.3 GiB | 64.0 GiB | **149.3 GiB** | + +Per join, over one coordinator NIC, with a CRC and a base64 decode per part on +one process. A star topology through a single coordinator is the wrong shape for +a reduction that is associative, commutative and idempotent; +`docs/local-fold.md` describes folding a segment's workers together before they +reach the coordinator, which is the first step away from it. + +### Plan shapes: the two optimizers + +Both optimizers reach the same exchange through the same code, but they choose +different slice widths for the same query, and the width is what governs +delivery cost. Same query, same cluster (4 segments): + +GPORCA, `max_parallel_workers_per_gather = 8` — a **parallel** build slice: + +``` + Gather Motion 16:1 (slice1; segments: 16) + -> Hash Join + -> Custom Scan (Anser Bloom Consumer) + -> Parallel Seq Scan on p_probe p + -> Hash + -> Broadcast Motion 16:16 (slice2; segments: 16) + -> Custom Scan (Anser Bloom Producer) + -> Parallel Seq Scan on p_build b +``` + +PostgreSQL planner (`optimizer = off`) — a **serial** build slice: + +``` + Gather Motion 8:1 (slice1; segments: 8) + -> Hash Join + -> Custom Scan (Anser Bloom Consumer) + -> Parallel Seq Scan on p_probe p + -> Hash + -> Broadcast Motion 2:8 (slice2; segments: 2) + -> Custom Scan (Anser Bloom Producer) + -> Seq Scan on p_build b +``` + +The difference is `Broadcast Motion 16:16` against `2:8`: ORCA parallelises the +build scan, the PG planner does not. So ORCA produces 16 producers and 16 +consumers where the PG planner produces 2 and 8 — eight times the parts to fold +and four times the deliveries, for the same filter. The PG plan finished the +whole query in 123 ms; the ORCA plan needed 282 ms with the filter delivered, +and 1293 ms when the fan-out overran the old 1 s deadline. + +Read `Gather Motion N:1` as the process count, not the segment count: +`FillSliceGangInfo()` fills a slice's segment list with `numsegments × +parallel_workers` entries, so `16:1` on 4 segments means 4 workers each. The +`x(0) workers` in the per-slice memory footer is a PostgreSQL `Gather`'s +`nworkers_launched`, which is always 0 here — an MPP slice has no `Gather` node — +and says nothing about MPP parallelism. + +### Payload types + +A channel carries one **payload type**, declared on the wire and registered in +`anserpayload.c`. The transport itself moves opaque bytes and never branches on +what they mean; everything type-specific lives in one descriptor +(`AnserPayloadOps`): + +| | `fold()` | `checksum_body` | +| --- | --- | --- | +| `B` — bloom filter | bitwise OR of equal-sized bitsets | yes | +| `-` — none (a subscription) | n/a | n/a | + +`fold()` is how the coordinator reduces one part per producer to a single +payload; there is no generic answer, which is why each type supplies it. A row +count, for instance, would fold by summing. + +`checksum_body` is about consequence, not size. A bloom filter fails +asymmetrically — a bit flipped 1 → 0 removes a key, so a joinable row is +rejected and the query silently returns too few rows — whereas a row count only +feeds a planning decision, where corruption costs a worse plan and never a wrong +answer. Types of the second kind opt out and pay nothing. (The header and +condition key are checksummed either way; that is ~100 ns and it is what +protects the routing fields.) + +Adding a type is three steps, none of which touch the framing: define a code, +add a row to `AnserPayloadTable`, and pass the code from the producer and +consumer nodes. `anserpayload.h` has the details. + +### How it attaches to the server + +Everything is reached through an existing extensibility point, so the server +carries no Anser-specific code (`anserinit.c`): + +| Hook | Used for | +| --- | --- | +| `planner_hook` | the injection pass, on the finished plan; wrapping the hook covers ORCA too, since it is dispatched from inside `standard_planner()` | +| `RegisterCustomScanMethods` | the producer/consumer nodes, so their methods resolve by name in every backend that deserializes a dispatched plan | +| `cdbdisp_notify_hook` | parts and subscriptions arriving from segments | +| `ExecutorEnd_hook` | dropping a query's channels | +| `DefineCustom*Variable` | the `anser.*` GUCs below | + +The first two already existed; `cdbdisp_notify_hook` and the +`GP_SIDEBAND_MESSAGE` tolerance in the QE command loop are the only additions +Anser needed in the server, and neither mentions Anser. + +## Wire protocol + +The two directions are deliberately asymmetric, because the constraints differ. + +**Segment → coordinator** is a `NOTIFY` on channel `anser_rf`, the model +`nextval()` uses (`cdb_sequence_nextval_qe` in `commands/sequence.c`). The QD is +a libpq *client*, and libpq rejects message types it does not know, so this +direction has to be a message type libpq already understands. `NotifyMyFrontEnd` +imposes no length limit of its own — the ~8 KB `NOTIFY_PAYLOAD_MAX_LENGTH` +applies to the SQL-level `NOTIFY`, which must fit a queue page — but it delivers +through `pq_sendstring`, so the payload must be a NUL-free string: + +``` +anser3 K T SSSSSSSSSS CCCCCCCCCC DDDDDDDDDD PPPPPPPPPP TTTTTTTTTT FFFF KKKK BBBBBBBBBB XXXXXXXX +^tag ^ ^ session command condition part total flags keylen bodylen crc + | \ payload type + \ kind + +``` + +`kind` is `P` (a producer's part) or `S` (a consumer subscribing) — what the +message *does*, as opposed to the payload type, which is what it *carries*. The +header is **95 bytes of fixed-width ASCII**, so the key and the body begin at +offsets that nothing in their own contents can shift, and the two lengths must +account for the message exactly — a short, long or misaligned message is rejected +before any of it is used. There is deliberately no delimiter anywhere in the +format: the earlier version ended its header with a newline and found it with +`strchr()`, which was correct only while the key could not itself contain a +newline, an invariant nothing enforced. + +**Coordinator → segment** is a `GP_SIDEBAND_MESSAGE`, written with `pqPutnchar`, +which performs no conversion — so the merged filter travels as **raw binary**, +with no base64 tax. That is the direction that matters most, since the merged +payload is sent once *per consumer* while each part is sent once. + +Both directions carry a **CRC32C**, and both discard a message that fails it. +The transport is TCP, not the UDP interconnect, so this is not about a lossy +link — TCP's 16-bit checksum is simply thin cover for a megabyte, and hardware +CRC32C costs about 0.05 ms/MB. The header and key are always covered; whether +the body is covered too is the payload type's decision, for the reasons in +[Payload types](#payload-types) above. Where it applies, the segment → +coordinator CRC covers the pre-base64 bytes, so it validates the decode as well. + +The coordinator services these while it is blocked receiving tuples: the +interconnect adds every dispatch socket to its wait set +(`ic_udpifc.c`, `ic_tcp.c`), and a readable one leads to +`checkForCancelFromQD` → `cdbdisp_checkForCancel` → `processResults`, which is +where the notify handler runs. Under `gp_interconnect_type=proxy` that check is +driven by a 2 s timer instead (`ic_proxy_backend.c`), so filter delivery can lag +by up to that long. + +## GUCs + +| GUC | Default | Context | Meaning | +| --- | --- | --- | --- | +| `anser.enable` | `off` | SIGHUP | Master switch. With it off the plan pass never injects anything and no filters are exchanged. | +| `anser.runtime_filter` | `off` | USERSET | Enables the post-planning pass that injects bloom-filter producer/consumer nodes into a matching plan. Requires `anser.enable`. | +| `anser.max_info_size` | `65 MB` | POSTMASTER | Maximum serialized payload (merged bloom filter + part header) a channel may hold; caps the effective bloom-filter size. The default is `64 MB + 1 MB` so a full 64 MB power-of-two bitset fits with its header; `bloom_create` also floors every bitset at 1 MB. | +| `anser.timeout_ms` | `100 s` | USERSET | How long a consumer waits for its filter before running unfiltered. The deadline matters because a producer that gets squelched never publishes at all: `ExecSquelchNode` only marks a `CustomScanState`, it does not call the node back. The default is generous because delivery is serial — see [Delivery cost](#delivery-cost). | +| `anser.debug` | `off` | USERSET | Traces the exchange — publish, merge, delivery, receive — in the log of the process each step happens in. See below. | + +### Tracing an exchange + +Until a filter is either used or timed out, none of the handoff is visible in +`EXPLAIN`: a consumer that waited and got nothing looks exactly like one whose +producers never published. `anser.debug` makes each step log where it happened, +which is normally the fastest way to find where an exchange broke: + +``` +seg0 producer init cond=0 part=0/3 elems=3334 payload=67108928 state=ok +seg0 producer child exhausted, publishing (state=ok) +seg0 published cond=0 part=0/3 bytes=1048592 cancelled=0 sent=1 +QD part cond=0 from seg0 (says part 0 of 3) 1/3 bytes=1048592 -> collecting +QD part cond=0 from seg1 (says part 1 of 3) 2/3 bytes=1048592 -> collecting +QD part cond=0 from seg2 (says part 2 of 3) 3/3 bytes=1048592 -> complete +QD delivering cond=0 to 3 subscriber(s) +QD pushed cond=0 bytes=1048592 cancelled=0 +seg0 received cond=0 bytes=1048592 cancelled=0 +``` + +Set it in `postgresql.conf` (`gpconfig -c anser.debug -v on`) rather than with +`SET` if you need to see the producer gang: a session-level `SET` does not +reliably reach every gang, and the producers are the half you usually want. + +A consumer that gives up reports what it saw — `read 0 message(s), 0 +unclaimed` means nothing arrived at all, whereas unclaimed messages mean +something arrived for a channel it was not waiting on. + +## Data flow: producer → merge (bitwise union) → consumer + +The parts from all segments are combined into **one** payload by a **bitwise OR +on the coordinator**, and that single combined payload is delivered to every +consumer. This is the core of Anser and worth stating precisely, because it is +*not* a concatenation: + +``` +segment 0 producer: bitset 0000 0001 ┐ +segment 1 producer: bitset 0000 0010 ├─ NOTIFY ─► QD backend (notify hook) +segment N producer: ... ┘ │ + │ fold each part into the + │ running merged bitset: + │ 0000 0001 + │ OR 0000 0010 + ▼ = 0000 0011 (one part) + channel payload = single merged bitset + │ + sideband push ───────────┼───────────────┐ + ▼ ▼ ▼ + consumer seg 0 consumer seg 1 ... consumer seg N + each receives the SAME combined 0000 0011 +``` + +Step by step: + +1. **Produce (per segment, in parallel).** Each segment's producer builds a bloom + filter over its local build keys (`bloom_create` from `total_elems` / + `max_payload` / a `condition_key`-derived seed, all carried in the plan node — + *not* on the wire) and serializes it as one *part*. Because every producer and + the consumer pass the identical parameters, every part has a byte-for-byte + identical size and shape. Publishing is fire-and-forget: the producer sends + its part and carries on without waiting for an acknowledgement. + +2. **Merge (coordinator, once per part).** The coordinator never reconstructs a + filter — it works on raw bytes. The **first** part is stored verbatim; every + later part is folded into the channel's payload with an in-place **bitwise OR** + of the bitset (`AnserBloomFoldPartInPlace` in `anserfilter.c`, called from + `anserdispatch.c`). The payload is therefore always a **single merged bitset**, + the size of one filter — it does **not** grow with the segment count. Folding + happens as parts arrive, so only the last fold is on the critical path. The OR + requires the incoming part to be the same size as the accumulator (guaranteed + by the shared parameters); a mismatch cancels the channel and consumers fail + open. + +3. **Deliver (coordinator → every consumer).** Once every expected part is + folded, the merged payload is judged once (see [Giving up + early](#giving-up-early)) and then pushed to each subscriber. Delivery is per + consumer: a failed write costs that one segment its filter and leaves the + others alone. A consumer that subscribes *after* the channel completed — which + happens routinely, since producers on other segments may finish first — is + served immediately. + +4. **Consume (per segment).** Each consumer rebuilds an empty filter from its own + plan parameters (the same `total_elems` / `max_payload` / seed the producers + used) and loads the received bitset into it (`AnserBloomDeserializePart`), + requiring the received length to match exactly (else it fails open). It does + **not** re-union anything. + +Correctness note: the combined filter is the OR (super-set) of every segment's +build keys, so it can only ever have *false positives*, never false negatives — +a probe row it rejects genuinely cannot join. Anser therefore only changes +performance, never results; any failure along this path degrades to "no filter" +(fail open). + +## Failure handling + +Every failure mode ends in unfiltered execution, never in a wrong answer and +never in an error raised into the query: + +- a producer that is squelched, errors, or produces an oversized part → the + channel never completes (or is cancelled) → consumers hit `anser.timeout_ms` + and run unfiltered; +- a malformed or undecodable part → the channel is cancelled and every consumer + is told so; +- a broken connection → the query is failing anyway, and the interconnect + reports it far more usefully than the filter path could; +- query cancellation → the consumer's wait is a `CHECK_FOR_INTERRUPTS` loop, and + a delivery that arrives after nobody is waiting is discarded by the QE command + loop (`GP_SIDEBAND_MESSAGE` is accepted and ignored there). + +## Tests + +`make installcheck` runs two suites: + +- `anser_test` — the payload protocol (serialize, fold, reject a mismatched + part) and the four give-up decisions, each walked along its boundary: the + estimate either side of 4 bits/key, the payload cap either side of "1 MB plus + a header", fill either side of the 95% limit, and a producer whose filter + saturates. The case tables live in the `.sql` file, so the expected output + records real sizes rather than a bare `t`. +- `anser_runtime_filter` — plan-tree integration end to end: the nodes are + injected, and query results are identical with the feature on and off. diff --git a/gpcontrib/anser/anser_test--1.0.sql b/gpcontrib/anser/anser_test--1.0.sql new file mode 100644 index 00000000000..c15793866e4 --- /dev/null +++ b/gpcontrib/anser/anser_test--1.0.sql @@ -0,0 +1,153 @@ +/* gpcontrib/anser/anser_test--1.0.sql */ + +-- complain if script is sourced in psql, rather than via CREATE EXTENSION +\echo Use "CREATE EXTENSION anser_test" to load this file. \quit + +CREATE FUNCTION anser_test_bloom_roundtrip( + condition_key text, + value int4) +RETURNS bool +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +CREATE FUNCTION anser_test_bloom_fold_inplace() +RETURNS bool +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +CREATE FUNCTION anser_test_bloom_rejects_mismatch() +RETURNS bool +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +CREATE FUNCTION anser_test_node_roundtrip(value int4) +RETURNS bool +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- The planner's give-up decision: NULL means no filter is injected. +CREATE FUNCTION anser_test_rf_size( + est_rows float8, + OUT injected bool, + OUT total_elems bigint, + OUT max_payload bigint, + OUT planned_bytes bigint) +RETURNS record +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- What AnserBloomCreate builds, or NULL when it declines to build anything. +CREATE FUNCTION anser_test_bloom_shape( + total_elems bigint, + cap bigint, + OUT built bool, + OUT bits bigint, + OUT serialized bigint, + OUT bits_per_key float8) +RETURNS record +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- The coordinator's decision about a merged payload, on a synthetic part. +CREATE FUNCTION anser_test_worth_delivering( + bitset_bytes int4, + bytes_set int4, + damage text) +RETURNS bool +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- Producer end to end: ":". +CREATE FUNCTION anser_test_producer_decision( + total_elems bigint, + cap bigint, + n_keys int4) +RETURNS text +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- The formatted QE -> QD message, verbatim. +CREATE FUNCTION anser_test_wire_format( + kind "char", + payload_type "char", + flags int4, + condition_key text, + body bytea) +RETURNS text +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- Format, alter one byte, parse: reports what the reader made of it. +CREATE FUNCTION anser_test_wire_roundtrip( + condition_key text, + body bytea, + tamper text, + payload_type "char") +RETURNS text +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- The QD -> QE checksum, so a test can compare two of them. +CREATE FUNCTION anser_test_push_crc( + payload_type "char", + condition_id int4, + flags int4, + condition_key text, + body bytea) +RETURNS int8 +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- The runtime-filter pushdown decision, over a synthetic scan. `tlist` +-- describes the scan's output targetlist, one element per entry: a positive +-- value is a plain Var on that table attno, and the rest are the shapes that +-- must be refused -- 0 a whole-row Var, -1 a system column, -2 a computed +-- column, -3 a Var of another relation, -4 a cast wrapping a Var. +-- +-- sk_attno is the attribute number the scan key would carry, and is NULL when +-- the shape is refused. +CREATE FUNCTION anser_test_pushdown_accepts( + tlist int4[], + attno int4, + filter_in_seqscan bool, + as_seqscan bool, + OUT accepts bool, + OUT sk_attno int4) +RETURNS record +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- The same scan put through AnserPushdownTarget, which must agree and name the +-- node itself when there is nothing to descend through. +CREATE FUNCTION anser_test_pushdown_target( + tlist int4[], + attno int4, + filter_in_seqscan bool, + as_seqscan bool, + OUT found_self bool, + OUT sk_attno int4) +RETURNS record +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; + +-- How many producers a slice runs, which is what a channel waits for. +-- FillSliceGangInfo() has one formula per gang type, so `gang` selects it: +-- 'unallocated', 'entrydb', 'singleton', 'reader' or 'writer'. `direct` is +-- the direct-dispatch content list, or NULL for none. `slice_index` lets a +-- caller ask about a slice outside the array. +CREATE FUNCTION anser_test_slice_producers( + gang text, + numsegments int4, + parallel_workers int4, + direct int4[], + slice_index int4) +RETURNS int4 +AS 'MODULE_PATHNAME' +LANGUAGE C CALLED ON NULL INPUT; + +-- Withdrawals the coordinator has honoured in this backend, so a test can see +-- that a consumer which ran out of time actually said so. +CREATE FUNCTION anser_test_unsubscribes() +RETURNS int8 +AS 'MODULE_PATHNAME' +LANGUAGE C STRICT; diff --git a/gpcontrib/anser/anser_test.control b/gpcontrib/anser/anser_test.control new file mode 100644 index 00000000000..8a4f941a49b --- /dev/null +++ b/gpcontrib/anser/anser_test.control @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# anser_test extension +comment = 'test helpers for the anser extension' +default_version = '1.0' +module_pathname = '$libdir/anser' +relocatable = true diff --git a/gpcontrib/anser/expected/anser_multijoin.out b/gpcontrib/anser/expected/anser_multijoin.out new file mode 100644 index 00000000000..0740fef3f15 --- /dev/null +++ b/gpcontrib/anser/expected/anser_multijoin.out @@ -0,0 +1,258 @@ +-- Anser runtime bloom filter: chains of 2..10 joins. +-- +-- Each table joins to the next on its own key pair -- t1.nxt = t2.own, then +-- t2.nxt = t3.own, and so on -- which is what a real chain looks like: no two +-- joins share a key, so no table's distribution can serve more than one of +-- them and every join needs a motion of its own. Joining a whole chain on one +-- column would make the joins co-located and would test the easy case only. +-- +-- This is also the shape the feature has to handle: the build side of the third +-- join is the result of the first two, and the key column for that join comes +-- from t2, so it reaches its base relation through a join rather than straight +-- through a Hash. +-- +-- What these cases assert is not that a filter was injected -- that is the +-- planner's choice and it differs between the two optimizers and with the +-- cluster width -- but that the answer is the same either way. A bloom filter +-- fails asymmetrically: a bit that should be set and is not silently removes +-- joinable rows, so "same rows with the filter on as with it off" is the +-- property worth asserting. Plan shapes are deliberately not asserted; an +-- expected file full of them would break on a three-segment cluster and pass on +-- a one-segment one without saying anything about correctness. +-- +-- Every query below reports its violations, so the expected output is empty +-- results and zeros. A failure names the chain that broke rather than leaving +-- a table of numbers to be compared by eye. +SET enable_nestloop = off; +SET enable_mergejoin = off; +-- A regress test must not inherit the production deadline. anser.timeout_ms +-- defaults to 100 s because delivery is serial and a wide slice needs that +-- long to be served; a test that waits it out has turned a delivery failure +-- into a slow pass. Pinned short here, so a filter that never arrives fails +-- the run instead of hiding in the clock. +SET anser.timeout_ms = 5000; +-- own is what the previous table joins to, nxt is what this table joins to the +-- next one with. They hold the same value, which keeps the chain 1:1 and the +-- answer predictable -- 1000 rows summing to 500500, whatever the join count -- +-- while still being two different columns as far as the planner is concerned. +-- +-- The distribution key is deliberately never the whole story: a table +-- distributed by own is joined on nxt, and the ones distributed by payload are +-- joined on both. Every join therefore redistributes or broadcasts one side, +-- and the producer ends up in a different slice from its consumer. +-- +-- t1 is the smallest and every later table contains it, so the chain returns +-- t1 whatever order the planner picks. The widening sizes give it a reason to +-- hash the joined result rather than the next table, which is the shape that +-- exercises the descent through a join. +CREATE TABLE anser_mj_t1 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t2 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t3 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t4 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t5 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t6 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t7 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t8 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t9 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t10 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t11 (own int, nxt int, payload text) DISTRIBUTED BY (own); +INSERT INTO anser_mj_t1 SELECT g, g, 't1_' || g FROM generate_series(1, 1000) g; +INSERT INTO anser_mj_t2 SELECT g, g, 't2_' || g FROM generate_series(1, 2000) g; +INSERT INTO anser_mj_t3 SELECT g, g, 't3_' || g FROM generate_series(1, 3000) g; +INSERT INTO anser_mj_t4 SELECT g, g, 't4_' || g FROM generate_series(1, 4000) g; +INSERT INTO anser_mj_t5 SELECT g, g, 't5_' || g FROM generate_series(1, 5000) g; +INSERT INTO anser_mj_t6 SELECT g, g, 't6_' || g FROM generate_series(1, 6000) g; +INSERT INTO anser_mj_t7 SELECT g, g, 't7_' || g FROM generate_series(1, 7000) g; +INSERT INTO anser_mj_t8 SELECT g, g, 't8_' || g FROM generate_series(1, 8000) g; +INSERT INTO anser_mj_t9 SELECT g, g, 't9_' || g FROM generate_series(1, 9000) g; +INSERT INTO anser_mj_t10 SELECT g, g, 't10_' || g FROM generate_series(1, 10000) g; +INSERT INTO anser_mj_t11 SELECT g, g, 't11_' || g FROM generate_series(1, 11000) g; +ANALYZE anser_mj_t1; +ANALYZE anser_mj_t2; +ANALYZE anser_mj_t3; +ANALYZE anser_mj_t4; +ANALYZE anser_mj_t5; +ANALYZE anser_mj_t6; +ANALYZE anser_mj_t7; +ANALYZE anser_mj_t8; +ANALYZE anser_mj_t9; +ANALYZE anser_mj_t10; +ANALYZE anser_mj_t11; +-- Runs one chain twice, with the filter on and off, and reports whether the two +-- agree. The checksum is what makes the comparison mean something: equal row +-- counts would also come out of two different sets of rows, and a filter that +-- wrongly rejects is exactly the failure that changes which rows come back. +CREATE FUNCTION anser_mj_check(njoins int) +RETURNS TABLE (joins int, nrows bigint, own_sum bigint, agrees boolean) +LANGUAGE plpgsql AS $$ +DECLARE + sql text; + i int; + on_rows bigint; + on_sum bigint; + off_rows bigint; + off_sum bigint; +BEGIN + sql := 'SELECT count(*), coalesce(sum(anser_mj_t1.own), 0) FROM anser_mj_t1'; + FOR i IN 2 .. njoins + 1 LOOP + sql := sql || format(' JOIN anser_mj_t%s ON anser_mj_t%s.nxt = anser_mj_t%s.own', + i, i - 1, i); + END LOOP; + PERFORM set_config('anser.runtime_filter', 'on', false); + EXECUTE sql INTO on_rows, on_sum; + PERFORM set_config('anser.runtime_filter', 'off', false); + EXECUTE sql INTO off_rows, off_sum; + RETURN QUERY SELECT njoins, on_rows, on_sum, + (on_rows = off_rows AND on_sum = off_sum); +END; +$$; +-- 2 through 10 joins, Postgres planner. Expected: no rows. +SET optimizer = off; +SELECT joins, nrows, own_sum, agrees + FROM generate_series(2, 10) g, anser_mj_check(g) + WHERE NOT agrees OR nrows <> 1000 OR own_sum <> 500500 + ORDER BY joins; + joins | nrows | own_sum | agrees +-------+-------+---------+-------- +(0 rows) + +-- The same under ORCA, which picks different join orders and slice widths. +SET optimizer = on; +SELECT joins, nrows, own_sum, agrees + FROM generate_series(2, 10) g, anser_mj_check(g) + WHERE NOT agrees OR nrows <> 1000 OR own_sum <> 500500 + ORDER BY joins; + joins | nrows | own_sum | agrees +-------+-------+---------+-------- +(0 rows) + +SET optimizer = off; +-- A chain whose middle join is an outer join, and whose next join reads the +-- nullable side's key. The joined result there holds NULLs the base relation +-- does not, and a NULL must not be taken for a key the filter is missing. +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_mj_lj_on AS + SELECT a.own AS aown, c.own AS cown, d.own AS down + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + LEFT JOIN anser_mj_t3 c ON b.nxt = c.own + JOIN anser_mj_t4 d ON c.nxt = d.own + DISTRIBUTED BY (aown); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_mj_lj_off AS + SELECT a.own AS aown, c.own AS cown, d.own AS down + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + LEFT JOIN anser_mj_t3 c ON b.nxt = c.own + JOIN anser_mj_t4 d ON c.nxt = d.own + DISTRIBUTED BY (aown); +SELECT count(*) AS lj_rows FROM anser_mj_lj_on; + lj_rows +--------- + 1000 +(1 row) + +SELECT count(*) AS lj_only_on + FROM (SELECT * FROM anser_mj_lj_on EXCEPT ALL SELECT * FROM anser_mj_lj_off) d; + lj_only_on +------------ + 0 +(1 row) + +SELECT count(*) AS lj_only_off + FROM (SELECT * FROM anser_mj_lj_off EXCEPT ALL SELECT * FROM anser_mj_lj_on) d; + lj_only_off +------------- + 0 +(1 row) + +-- A star rather than a chain: every join reads t1's key, so one table's +-- distribution serves them all and the build sides are base scans throughout. +-- This is the shape that worked before the descent went through joins, and it +-- has to keep working. +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_mj_star_on AS + SELECT a.own AS aown, e.payload AS epayload + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + JOIN anser_mj_t3 c ON a.nxt = c.own + JOIN anser_mj_t4 d ON a.nxt = d.own + JOIN anser_mj_t5 e ON a.nxt = e.own + DISTRIBUTED BY (aown); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_mj_star_off AS + SELECT a.own AS aown, e.payload AS epayload + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + JOIN anser_mj_t3 c ON a.nxt = c.own + JOIN anser_mj_t4 d ON a.nxt = d.own + JOIN anser_mj_t5 e ON a.nxt = e.own + DISTRIBUTED BY (aown); +SELECT count(*) AS star_rows FROM anser_mj_star_on; + star_rows +----------- + 1000 +(1 row) + +SELECT count(*) AS star_only_on + FROM (SELECT * FROM anser_mj_star_on EXCEPT ALL SELECT * FROM anser_mj_star_off) d; + star_only_on +-------------- + 0 +(1 row) + +SELECT count(*) AS star_only_off + FROM (SELECT * FROM anser_mj_star_off EXCEPT ALL SELECT * FROM anser_mj_star_on) d; + star_only_off +--------------- + 0 +(1 row) + +-- A chain that returns nothing. A filter that wrongly accepts is only slow, +-- never wrong, so this is the one direction an empty result can still check. +SET anser.runtime_filter = on; +SELECT count(*) AS empty_rows + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + JOIN anser_mj_t3 c ON b.nxt = c.own + WHERE a.own < 0; + empty_rows +------------ + 0 +(1 row) + +-- The chains above assert that the filter changes no answer. That also holds +-- when the filter never arrives and every consumer fails open, which is how a +-- broken exchange passes a green test. This asserts the other half: a chain +-- whose third table is three times the size of the first has rows to prune, so +-- a filter that arrived removes some. +CREATE FUNCTION anser_mj_pruned(q text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + removed bigint := 0; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) ' || q LOOP + IF line ~ 'Rows Removed by (Bloom Filter|Pushdown Runtime Filter): ' THEN + removed := removed + coalesce(substring(line from ': *([0-9]+)')::bigint, 0); + END IF; + END LOOP; + RETURN removed; +END; +$$; +SET anser.runtime_filter = on; +SELECT 'the chain pruned nothing: no filter arrived, or none was injected' AS problem + WHERE anser_mj_pruned('SELECT count(*) FROM anser_mj_t1 a JOIN anser_mj_t2 b ON a.nxt = b.own JOIN anser_mj_t3 c ON b.nxt = c.own') = 0; + problem +--------- +(0 rows) + +DROP FUNCTION anser_mj_pruned(text); +DROP FUNCTION anser_mj_check(int); +DROP TABLE anser_mj_t1, anser_mj_t2, anser_mj_t3, anser_mj_t4, anser_mj_t5, + anser_mj_t6, anser_mj_t7, anser_mj_t8, anser_mj_t9, anser_mj_t10, + anser_mj_t11; +RESET anser.runtime_filter; +RESET optimizer; +RESET enable_nestloop; +RESET enable_mergejoin; diff --git a/gpcontrib/anser/expected/anser_runtime_filter.out b/gpcontrib/anser/expected/anser_runtime_filter.out new file mode 100644 index 00000000000..bcdd6aeea97 --- /dev/null +++ b/gpcontrib/anser/expected/anser_runtime_filter.out @@ -0,0 +1,748 @@ +-- Anser runtime bloom filter: plan-tree integration (PR4). +-- +-- Requires the cluster to run with anser.enable=on so the gather/send +-- services are live. On a multi-segment cluster the build side redistributes, +-- exercising the Motion-directly-under-CustomScan case; on a single segment it +-- degrades to the leaf case. Either way the feature must (a) inject the +-- producer/consumer nodes and (b) never change query results. +-- Deterministic plan shape: force a hash join. +SET enable_nestloop = off; +SET enable_mergejoin = off; +-- A regress test must not inherit the production deadline. anser.timeout_ms +-- defaults to 100 s because delivery is serial and a wide slice needs that +-- long to be served; a test that waits it out has turned a delivery failure +-- into a slow pass. Pinned short here, so a filter that never arrives fails +-- the run instead of hiding in the clock. +SET anser.timeout_ms = 5000; +-- build is the smaller (hashed / preserved) side, distributed so the join key +-- must be redistributed; probe is the larger side distributed by the join key. +CREATE TABLE anser_rf_build (id int, name text) DISTRIBUTED BY (name); +CREATE TABLE anser_rf_probe (id int, payload text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_build SELECT g, 'b' || g FROM generate_series(1, 200) g; +INSERT INTO anser_rf_probe SELECT g, 'p' || g FROM generate_series(1, 2000) g; +ANALYZE anser_rf_build; +ANALYZE anser_rf_probe; +-- Use the Postgres planner for a deterministic plan shape. +SET optimizer = off; +-- With the filter on the plan carries an "Anser Bloom Producer" under the Hash +-- and an "Anser Bloom Consumer" above the probe scan (with the planned bloom +-- size); with it off neither node appears. (COSTS OFF keeps the output stable.) +SET anser.runtime_filter = on; +EXPLAIN (COSTS OFF) +SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id; + QUERY PLAN +------------------------------------------------------------------ + Gather Motion 3:1 (slice1; segments: 3) + -> Hash Right Join + Hash Cond: (p.id = b.id) + -> Custom Scan (Anser Bloom Consumer) + Bloom Filter Size: 1048576 bytes + -> Seq Scan on anser_rf_probe p + -> Hash + -> Redistribute Motion 3:3 (slice2; segments: 3) + Hash Key: b.id + -> Custom Scan (Anser Bloom Producer) + Bloom Filter Size: 1048576 bytes + Bloom Filter Stats: memory=1024kB + -> Seq Scan on anser_rf_build b + Optimizer: Postgres query optimizer +(14 rows) + +SET anser.runtime_filter = off; +EXPLAIN (COSTS OFF) +SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id; + QUERY PLAN +------------------------------------------------------------------ + Gather Motion 3:1 (slice1; segments: 3) + -> Hash Right Join + Hash Cond: (p.id = b.id) + -> Seq Scan on anser_rf_probe p + -> Hash + -> Redistribute Motion 3:3 (slice2; segments: 3) + Hash Key: b.id + -> Seq Scan on anser_rf_build b + Optimizer: Postgres query optimizer +(9 rows) + +-- Correctness: identical results with the filter on vs off (Postgres planner). +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_r_on AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_r_off AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS rows_on FROM anser_rf_r_on; + rows_on +--------- + 200 +(1 row) + +SELECT count(*) AS rows_off FROM anser_rf_r_off; + rows_off +---------- + 200 +(1 row) + +SELECT count(*) AS only_on + FROM (SELECT * FROM anser_rf_r_on EXCEPT ALL SELECT * FROM anser_rf_r_off) d; + only_on +--------- + 0 +(1 row) + +SELECT count(*) AS only_off + FROM (SELECT * FROM anser_rf_r_off EXCEPT ALL SELECT * FROM anser_rf_r_on) d; + only_off +---------- + 0 +(1 row) + +-- Correctness must also hold under ORCA (whether or not it injects the nodes). +SET optimizer = on; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_r_orca AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS only_orca + FROM (SELECT * FROM anser_rf_r_orca EXCEPT ALL SELECT * FROM anser_rf_r_off) d; + only_orca +----------- + 0 +(1 row) + +SELECT count(*) AS only_off_orca + FROM (SELECT * FROM anser_rf_r_off EXCEPT ALL SELECT * FROM anser_rf_r_orca) d; + only_off_orca +--------------- + 0 +(1 row) + +-- Pushdown mix: with gp_enable_runtime_filter_pushdown on, the consumer hands +-- the unioned filter to the probe SeqScan as an SK_BLOOM_FILTER scan key and +-- the scan (or the table AM) does the pruning. Results must still match the +-- filter-off run. +SET optimizer = off; +SET gp_enable_runtime_filter_pushdown = on; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_r_push AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS only_push + FROM (SELECT * FROM anser_rf_r_push EXCEPT ALL SELECT * FROM anser_rf_r_off) d; + only_push +----------- + 0 +(1 row) + +SELECT count(*) AS only_off_push + FROM (SELECT * FROM anser_rf_r_off EXCEPT ALL SELECT * FROM anser_rf_r_push) d; + only_off_push +--------------- + 0 +(1 row) + +RESET gp_enable_runtime_filter_pushdown; +-- Pushdown where the key's table attribute number and its position in the +-- scan's output targetlist disagree. The case above cannot see the +-- difference: anser_rf_probe's key is its first column and also the first +-- thing the scan emits, so the two numbers coincide. Here the key is the +-- probe table's *second* column and its *only* projected one, so a scan key +-- carrying the output position makes the scan test `payload` against a filter +-- built on `id` -- and since no payload is ever a build key, every row is +-- discarded. Both counts must be 200. +SET optimizer = on; +CREATE TABLE anser_rf_proj_build (id int, pad text) DISTRIBUTED BY (pad); +CREATE TABLE anser_rf_proj_probe (payload int, id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_proj_build SELECT g, 'b' || g FROM generate_series(1, 200) g; +INSERT INTO anser_rf_proj_probe SELECT -g, g FROM generate_series(1, 2000) g; +ANALYZE anser_rf_proj_build; +ANALYZE anser_rf_proj_probe; +SET gp_enable_runtime_filter_pushdown = on; +SET anser.runtime_filter = off; +SELECT count(p.id) AS proj_push_off + FROM anser_rf_proj_build b LEFT JOIN anser_rf_proj_probe p ON b.id = p.id; + proj_push_off +--------------- + 200 +(1 row) + +SET anser.runtime_filter = on; +SELECT count(p.id) AS proj_push_on + FROM anser_rf_proj_build b LEFT JOIN anser_rf_proj_probe p ON b.id = p.id; + proj_push_on +-------------- + 200 +(1 row) + +-- The alias is not the column. This subquery renames `payload` to the name +-- `id` and the real `id` to `id2`, and joins on id2 -- so resolving the key by +-- name would land on `payload`, the one column guaranteed never to match a +-- filter built from the build side. Resolution runs position -> TargetEntry +-- -> Var -> varattno and never reads a name, so the rename is invisible to it. +-- Both counts must be 200. +SET anser.runtime_filter = off; +SELECT count(p.id2) AS rename_push_off + FROM anser_rf_proj_build b + LEFT JOIN (SELECT payload AS id, id AS id2 FROM anser_rf_proj_probe) p + ON b.id = p.id2; + rename_push_off +----------------- + 200 +(1 row) + +SET anser.runtime_filter = on; +SELECT count(p.id2) AS rename_push_on + FROM anser_rf_proj_build b + LEFT JOIN (SELECT payload AS id, id AS id2 FROM anser_rf_proj_probe) p + ON b.id = p.id2; + rename_push_on +---------------- + 200 +(1 row) + +-- A computed key has no table attribute number to push down: the scan would +-- have to evaluate `id * 2`, which PassByBloomFilter cannot do. Note the join +-- clause itself still looks like a plain Var to the planner (it references the +-- scan's output position), so the key resolves and the nodes are injected -- +-- it is the scan's own targetlist entry that is an expression. The consumer +-- must decline the pushdown and probe the projected slot itself. Only the +-- 100 even build ids match, so both counts must be 100. +SET anser.runtime_filter = off; +SELECT count(p.k) AS expr_push_off + FROM anser_rf_proj_build b + LEFT JOIN (SELECT id * 2 AS k FROM anser_rf_proj_probe) p ON b.id = p.k; + expr_push_off +--------------- + 100 +(1 row) + +SET anser.runtime_filter = on; +SELECT count(p.k) AS expr_push_on + FROM anser_rf_proj_build b + LEFT JOIN (SELECT id * 2 AS k FROM anser_rf_proj_probe) p ON b.id = p.k; + expr_push_on +-------------- + 100 +(1 row) + +RESET gp_enable_runtime_filter_pushdown; +DROP TABLE anser_rf_proj_build, anser_rf_proj_probe; +SET optimizer = off; +-- Everything above checks that the nodes were injected and that the results did +-- not change. Both of those also hold when the filter never arrives and every +-- consumer fails open -- which is how a broken exchange passes a green test. +-- This is the assertion that the filter was used: the probe side holds 2000 +-- rows of which 200 join, so a filter that arrived removes the rest. Only the +-- Postgres planner is asserted, because its plan shape here is fixed; ORCA +-- chooses and the count would not be a stable thing to require. +CREATE FUNCTION anser_rf_pruned(q text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + removed bigint := 0; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) ' || q LOOP + IF line ~ 'Rows Removed by (Bloom Filter|Pushdown Runtime Filter): ' THEN + removed := removed + coalesce(substring(line from ': *([0-9]+)')::bigint, 0); + END IF; + END LOOP; + RETURN removed; +END; +$$; +SET optimizer = off; +SET anser.runtime_filter = on; +SELECT 'the filter pruned nothing: it never arrived, or it arrived empty' AS problem + WHERE anser_rf_pruned('SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id') = 0; + problem +--------- +(0 rows) + +-- Nested statement guard: a statement run through SPI from a function body +-- goes through its whole Start/Run/End inside the *outer* query's Run, so the +-- inner ExecutorEnd fires while the outer query is still executing. Dropping +-- channels there takes the running query's filters with it, and every consumer +-- that has not read yet fails open -- the exchange still happens, the filter is +-- just never used. +-- +-- This needs an inner join, not the left join above: the consumer defers its +-- first tuple only for INNER (see defer_first), which is what lets the join +-- emit a row -- and so evaluate the function -- before the consumer reads. +CREATE FUNCTION anser_rf_nested(x int) RETURNS int +LANGUAGE plpgsql VOLATILE AS $$ +BEGIN + PERFORM 1; + RETURN x; +END; +$$; +SELECT 'an inner statement dropped the running query''s filter' AS problem + WHERE anser_rf_pruned('SELECT anser_rf_nested(p.id) FROM anser_rf_build b + JOIN anser_rf_probe p ON b.id = p.id') = 0; + problem +--------- +(0 rows) + +DROP FUNCTION anser_rf_nested(int); +DROP FUNCTION anser_rf_pruned(text); +DROP TABLE anser_rf_r_push; +DROP TABLE anser_rf_r_on, anser_rf_r_off, anser_rf_r_orca; +DROP TABLE anser_rf_build, anser_rf_probe; +-- Datatype guard: producer and consumer hash the raw Datum bytes, so injection +-- is restricted to keys where SQL equality is bitwise Datum equality. A +-- cross-type join (float4 vs float8 Datums for equal values differ) must not +-- be injected; results must match the filter-off run either way. +CREATE TABLE anser_rf_build_f (id float8, name text) DISTRIBUTED BY (name); +CREATE TABLE anser_rf_probe_f (id float4, payload text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_build_f SELECT g, 'b' || g FROM generate_series(1, 200) g; +INSERT INTO anser_rf_probe_f SELECT g, 'p' || g FROM generate_series(1, 2000) g; +ANALYZE anser_rf_build_f; +ANALYZE anser_rf_probe_f; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_f_on AS + SELECT b.name, p.payload FROM anser_rf_build_f b JOIN anser_rf_probe_f p ON b.id = p.id + DISTRIBUTED BY (name); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_f_off AS + SELECT b.name, p.payload FROM anser_rf_build_f b JOIN anser_rf_probe_f p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS rows_on FROM anser_rf_f_on; + rows_on +--------- + 200 +(1 row) + +SELECT count(*) AS only_on + FROM (SELECT * FROM anser_rf_f_on EXCEPT ALL SELECT * FROM anser_rf_f_off) d; + only_on +--------- + 0 +(1 row) + +SELECT count(*) AS only_off + FROM (SELECT * FROM anser_rf_f_off EXCEPT ALL SELECT * FROM anser_rf_f_on) d; + only_off +---------- + 0 +(1 row) + +DROP TABLE anser_rf_f_on, anser_rf_f_off, anser_rf_build_f, anser_rf_probe_f; +-- Same-typed float keys are also excluded: -0.0 and 0.0 compare equal in SQL +-- but are not bitwise equal, so hashing raw Datums could prune a joinable row. +CREATE TABLE anser_rf_build_z (id float8, name text) DISTRIBUTED BY (name); +CREATE TABLE anser_rf_probe_z (id float8, payload text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_build_z VALUES (1, 'b1'), (-0.0, 'bz'); +INSERT INTO anser_rf_probe_z VALUES (1, 'p1'), (0.0, 'pz'), (2, 'p2'); +ANALYZE anser_rf_build_z; +ANALYZE anser_rf_probe_z; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_z_on AS + SELECT b.name, p.payload FROM anser_rf_build_z b JOIN anser_rf_probe_z p ON b.id = p.id + DISTRIBUTED BY (name); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_z_off AS + SELECT b.name, p.payload FROM anser_rf_build_z b JOIN anser_rf_probe_z p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS rows_on FROM anser_rf_z_on; + rows_on +--------- + 2 +(1 row) + +SELECT count(*) AS only_on + FROM (SELECT * FROM anser_rf_z_on EXCEPT ALL SELECT * FROM anser_rf_z_off) d; + only_on +--------- + 0 +(1 row) + +SELECT count(*) AS only_off + FROM (SELECT * FROM anser_rf_z_off EXCEPT ALL SELECT * FROM anser_rf_z_on) d; + only_off +---------- + 0 +(1 row) + +DROP TABLE anser_rf_z_on, anser_rf_z_off, anser_rf_build_z, anser_rf_probe_z; +-- Parameterized rescan guard: a build side whose rows depend on a parameter an +-- enclosing nested loop reassigns yields a different key set per outer tuple, +-- and neither node rebuilds on rescan -- the producer stays published, the +-- consumer keeps the filter it already has. The second iteration would be +-- probed against the first iteration's keys, dropping every row that joins. +-- So the plan pass must refuse the join outright. +-- +-- OFFSET 0 keeps the subquery from being pulled up, so it stays a nested loop +-- with a correlated inner side. Each group's ids are disjoint, which is what +-- makes a stale filter visible: with the first group's filter still in place +-- the second group matches nothing. +CREATE TABLE anser_rf_nl_outer (g int) DISTRIBUTED REPLICATED; +CREATE TABLE anser_rf_nl_build (g int, id int) DISTRIBUTED BY (g); +CREATE TABLE anser_rf_nl_probe (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_nl_outer VALUES (1), (2); +INSERT INTO anser_rf_nl_build SELECT 1, g FROM generate_series(1, 100) g; +INSERT INTO anser_rf_nl_build SELECT 2, g FROM generate_series(101, 200) g; +INSERT INTO anser_rf_nl_probe SELECT g FROM generate_series(1, 20000) g; +ANALYZE anser_rf_nl_outer; +ANALYZE anser_rf_nl_build; +ANALYZE anser_rf_nl_probe; +CREATE FUNCTION anser_rf_nodes(q text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + nodes bigint := 0; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP + IF line ~ 'Anser Bloom' THEN + nodes := nodes + 1; + END IF; + END LOOP; + RETURN nodes; +END; +$$; +-- Both groups must report 100, with the filter on and off alike, and the plan +-- must carry no Anser node at all. +SET anser.runtime_filter = off; +SELECT o.g, s.n AS nl_off +FROM anser_rf_nl_outer o +CROSS JOIN LATERAL (SELECT count(*) n FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = o.g OFFSET 0) s +ORDER BY o.g; + g | nl_off +---+-------- + 1 | 100 + 2 | 100 +(2 rows) + +SET anser.runtime_filter = on; +SELECT o.g, s.n AS nl_on +FROM anser_rf_nl_outer o +CROSS JOIN LATERAL (SELECT count(*) n FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = o.g OFFSET 0) s +ORDER BY o.g; + g | nl_on +---+------- + 1 | 100 + 2 | 100 +(2 rows) + +SELECT anser_rf_nodes('SELECT o.g, s.n FROM anser_rf_nl_outer o +CROSS JOIN LATERAL (SELECT count(*) n FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = o.g OFFSET 0) s') AS nodes_in_parameterized_plan; + nodes_in_parameterized_plan +----------------------------- + 0 +(1 row) + +-- The same two tables joined without the correlation are still injected into: +-- the guard must refuse a parameterized build side, not every build side. +SELECT anser_rf_nodes('SELECT count(*) FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = 1') AS nodes_when_not_parameterized; + nodes_when_not_parameterized +------------------------------ + 2 +(1 row) + +DROP FUNCTION anser_rf_nodes(text); +DROP TABLE anser_rf_nl_outer, anser_rf_nl_build, anser_rf_nl_probe; +-- nextval guard: the QD sends two kinds of message down a QE's dispatch +-- connection -- the nextval reply and a sideband message -- and each has its +-- own reader. A reader that meets the other's message has to skip it whole: +-- taking the type byte and erroring leaves the body in the stream, and the +-- session then dies somewhere else with "protocol synchronization was lost". +-- +-- In the field this needs an Anser consumer to give up on a filter the +-- coordinator goes on to deliver anyway, which is a race and cannot be asked +-- for from SQL. The fault puts a sideband message in front of one nextval +-- reply instead, so the ordering is certain rather than likely; the +-- three-argument form is occurrence 1..1, so it fires once. +-- +-- Nothing here is Anser's -- the fault supplies the message and the fix is in +-- cdb_sequence_nextval_qe -- but Anser is why a second message type exists on +-- that connection at all, so the test lives with it. +-- +-- The extension may or may not already be there, and saying so is not part of +-- what is being tested; it is never dropped, for the same reason -- it can +-- predate this test, and other tests in the same database rely on it. +-- start_ignore +CREATE EXTENSION IF NOT EXISTS gp_inject_fault; +NOTICE: extension "gp_inject_fault" already exists, skipping +-- end_ignore +CREATE SEQUENCE anser_rf_seq; +CREATE TABLE anser_rf_seq_t (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_seq_t SELECT g FROM generate_series(1, 10) g; +SELECT gp_inject_fault('nextval_sideband_before_response', 'skip', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; + gp_inject_fault +----------------- + Success: +(1 row) + +SELECT count(nextval('anser_rf_seq')) AS seq_rows FROM anser_rf_seq_t; + seq_rows +---------- + 10 +(1 row) + +SELECT gp_inject_fault('nextval_sideband_before_response', 'reset', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; + gp_inject_fault +----------------- + Success: +(1 row) + +DROP SEQUENCE anser_rf_seq; +DROP TABLE anser_rf_seq_t; +-- Delivery to a consumer that stopped reading must not block the coordinator. +-- +-- A merged filter is 1-64 MB and the dispatch connections are blocking, so +-- pushing to a QE that is no longer reading used to wait in pqWait() with no +-- deadline and no CHECK_FOR_INTERRUPTS -- inside the dispatcher loop, which +-- then served nobody. Neither cancel nor statement_timeout could end it. +-- +-- The ordering cannot be arranged with data: the channel completes when the +-- last part lands, which is normally while the consumers are still waiting. +-- Holding the delivery back is what lets every consumer reach its deadline +-- and move on to its probe side -- and it is while they are busy there that +-- the coordinator finally writes to all of them. +-- +-- Note this one hangs rather than fails if the fix is reverted, for the same +-- reason the bug was unkillable. It is here because the alternative is a +-- regression nobody notices until a cluster wedges. +-- start_ignore +CREATE EXTENSION IF NOT EXISTS gp_inject_fault; +NOTICE: extension "gp_inject_fault" already exists, skipping +-- end_ignore +SET anser.timeout_ms = 1; +CREATE TABLE anser_rf_push_build (id int, pad text) DISTRIBUTED BY (pad); +CREATE TABLE anser_rf_push_probe (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_push_build SELECT g, 'b' || g FROM generate_series(1, 1000) g; +INSERT INTO anser_rf_push_probe SELECT g % 1000 + 1 FROM generate_series(1, 1000000) g; +ANALYZE anser_rf_push_build; +ANALYZE anser_rf_push_probe; +-- One segment publishes a second late; every other consumer gives up at 1 ms. +SELECT gp_inject_fault('anser_before_deliver', 'sleep', '', '', '', 1, 1, 1, dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; + gp_inject_fault +----------------- + Success: +(1 row) + +SET anser.runtime_filter = on; +SELECT count(*) AS push_rows + FROM anser_rf_push_probe p JOIN anser_rf_push_build b ON p.id = b.id; + push_rows +----------- + 1000000 +(1 row) + +SELECT gp_inject_fault('anser_before_deliver', 'reset', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; + gp_inject_fault +----------------- + Success: +(1 row) + +DROP TABLE anser_rf_push_build, anser_rf_push_probe; +-- A consumer that runs out of time withdraws, so the coordinator stops +-- counting it as a subscriber. Without that it would keep delivering to a +-- process that has moved on -- which is what the push above has to survive, +-- and what this keeps from quietly regressing into. +-- +-- The withdrawal leaves no trace a query can see: the channel is gone by the +-- time the query returns, and a filter that merely arrived late looks the +-- same. So the coordinator counts them, and this reads the difference. +CREATE TABLE anser_rf_unsub_build (id int, pad text) DISTRIBUTED BY (pad); +CREATE TABLE anser_rf_unsub_probe (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_unsub_build SELECT g, 'b' || g FROM generate_series(1, 1000) g; +INSERT INTO anser_rf_unsub_probe SELECT g % 1000 + 1 FROM generate_series(1, 200000) g; +ANALYZE anser_rf_unsub_build; +ANALYZE anser_rf_unsub_probe; +-- start_ignore +CREATE EXTENSION IF NOT EXISTS anser_test; +NOTICE: extension "anser_test" already exists, skipping +-- end_ignore +SELECT anser_test_unsubscribes() AS unsub_before \gset +SELECT gp_inject_fault('anser_before_deliver', 'sleep', '', '', '', 1, 1, 1, dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; + gp_inject_fault +----------------- + Success: +(1 row) + +SELECT count(*) AS unsub_rows + FROM anser_rf_unsub_probe p JOIN anser_rf_unsub_build b ON p.id = b.id; + unsub_rows +------------ + 200000 +(1 row) + +SELECT gp_inject_fault('anser_before_deliver', 'reset', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; + gp_inject_fault +----------------- + Success: +(1 row) + +SELECT anser_test_unsubscribes() > :unsub_before AS consumers_withdrew; + consumers_withdrew +-------------------- + t +(1 row) + +DROP EXTENSION anser_test; +DROP TABLE anser_rf_unsub_build, anser_rf_unsub_probe; +-- Both blocks above ran on a 1 ms deadline so the consumers would time out. +-- Restore the file's deadline: at 1 ms no filter ever arrives, which would +-- make every assertion below pass without exercising anything. +SET anser.timeout_ms = 5000; +-- --------------------------------------------------------------------------- +-- Parallel execution. +-- +-- A parallel slice runs numsegments * parallel_workers processes and every one +-- of them publishes its own part, so the coordinator must wait for that many +-- parts rather than for one per segment. Undercounting completes the channel +-- early and delivers a filter missing build keys; a bloom filter short of keys +-- gives false negatives, which silently drops joinable rows. So the assertion +-- that matters is not the plan shape but that results are *identical* with the +-- filter on and off -- at more than one worker count, and under both planners, +-- which disagree here: for one query the Postgres planner may give a serial +-- build slice where GPORCA gives a parallel one. +-- +-- The build side is distributed by v and joined on id, so it is redistributed: +-- Anser only injects across a Motion, and a co-located join -- which is what +-- this used to use -- is now skipped outright, making the whole section +-- vacuous. The cost is that only GPORCA still parallelises it; the Postgres +-- planner marks Motion paths parallel-unsafe, so its two blocks below are +-- reduced to a filter-on/filter-off equivalence check on a serial slice. +-- They are kept because that equivalence is worth asserting on its own. +-- --------------------------------------------------------------------------- +CREATE TABLE anser_rf_par_build (id int, v text) DISTRIBUTED BY (v); +CREATE TABLE anser_rf_par_probe (id int, pad text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_par_build SELECT g, 'b' || g FROM generate_series(1, 500) g; +INSERT INTO anser_rf_par_probe SELECT g, 'p' || g FROM generate_series(1, 20000) g; +ANALYZE anser_rf_par_build; +ANALYZE anser_rf_par_probe; +-- Force a parallel plan on tables this small, the way PostgreSQL's own tests +-- do; without this the scans are far too cheap to be worth splitting. +SET enable_parallel = on; +SET min_parallel_table_scan_size = 0; +SET parallel_setup_cost = 0; +SET parallel_tuple_cost = 0; +-- Postgres planner, serial baseline +SET optimizer = off; +SET max_parallel_workers_per_gather = 0; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; + rows_on | only_on | only_off +---------+---------+---------- + 500 | 0 | 0 +(1 row) + +DROP TABLE anser_rf_par_on, anser_rf_par_off; +-- Postgres planner, parallel +SET optimizer = off; +SET max_parallel_workers_per_gather = 4; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; + rows_on | only_on | only_off +---------+---------+---------- + 500 | 0 | 0 +(1 row) + +DROP TABLE anser_rf_par_on, anser_rf_par_off; +-- GPORCA, serial baseline +SET optimizer = on; +SET max_parallel_workers_per_gather = 0; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; + rows_on | only_on | only_off +---------+---------+---------- + 500 | 0 | 0 +(1 row) + +DROP TABLE anser_rf_par_on, anser_rf_par_off; +-- GPORCA, parallel +SET optimizer = on; +SET max_parallel_workers_per_gather = 4; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; + rows_on | only_on | only_off +---------+---------+---------- + 500 | 0 | 0 +(1 row) + +DROP TABLE anser_rf_par_on, anser_rf_par_off; +DROP TABLE anser_rf_par_build, anser_rf_par_probe; +RESET enable_parallel; +RESET min_parallel_table_scan_size; +RESET parallel_setup_cost; +RESET parallel_tuple_cost; +RESET max_parallel_workers_per_gather; +RESET anser.runtime_filter; +RESET optimizer; +RESET enable_nestloop; +RESET enable_mergejoin; diff --git a/gpcontrib/anser/expected/anser_test.out b/gpcontrib/anser/expected/anser_test.out new file mode 100644 index 00000000000..54785b1e518 --- /dev/null +++ b/gpcontrib/anser/expected/anser_test.out @@ -0,0 +1,414 @@ +CREATE EXTENSION anser_test; +-- Bloom payload protocol: serialize one part and read it back. +SELECT anser_test_bloom_roundtrip('bf_roundtrip', 42); + anser_test_bloom_roundtrip +---------------------------- + t +(1 row) + +-- In-place fold: same-size union mutates the accumulator; a mismatched size is +-- rejected. This is the coordinator's only combine path (the first part is +-- kept verbatim, every later part folds into it). +SELECT anser_test_bloom_fold_inplace() AS fold_inplace_ok; + fold_inplace_ok +----------------- + t +(1 row) + +-- Safety regression: the consumer rebuilds the filter from its own parameters +-- and requires the received bitset to be exactly the expected size (and the +-- header magic to match); truncated/oversized/corrupt parts are rejected. +SELECT anser_test_bloom_rejects_mismatch() AS reject_mismatch; + reject_mismatch +----------------- + t +(1 row) + +-- Producer and consumer driven end to end in one backend: publish a part, let +-- the coordinator side merge it, then receive and query the filter. +SELECT anser_test_node_roundtrip(168); + anser_test_node_roundtrip +--------------------------- + t +(1 row) + +-- --------------------------------------------------------------------------- +-- Sizing, and the decision not to bother. +-- +-- Anser can decline to build a filter at four points, and these cases walk the +-- boundary of each. Declining is always safe: it costs the query its filter, +-- never its correctness. The payload cap is pinned here because every size +-- below follows from it. +-- --------------------------------------------------------------------------- +SET anser.max_info_size = 68157440; +-- 1. The planner's gate -- the only one that costs nothing, since nothing is +-- injected and no consumer is left waiting. A 64 MB bitset is 536870912 bits, +-- so 134217728 estimated keys is exactly 4 bits/key and one more key is not. +-- A zero or negative estimate is treated as one key, not as "no filter". +SELECT t.est_rows, s.injected, s.total_elems, s.planned_bytes +FROM (VALUES (0), (-5), (1), (1000), (1000000), (10000000), + (134217728), (134217729), (150000000), (1000000000)) + AS t(est_rows), + LATERAL anser_test_rf_size(t.est_rows) AS s; + est_rows | injected | total_elems | planned_bytes +------------+----------+-------------+--------------- + 0 | t | 1 | 1048576 + -5 | t | 1 | 1048576 + 1 | t | 1 | 1048576 + 1000 | t | 1000 | 1048576 + 1000000 | t | 1000000 | 1048576 + 10000000 | t | 10000000 | 16777216 + 134217728 | t | 134217728 | 67108864 + 134217729 | f | | + 150000000 | f | | + 1000000000 | f | | +(10 rows) + +-- 2. Filter construction. bloom_create floors every bitset at 1 MB and that +-- floor overrides the work_mem cap, so a cap below "1 MB + header" can only +-- yield a filter too large to send -- which is why a flat 1 MB cap is refused +-- while 1048592 (the floor plus exactly one header) is accepted and serializes +-- to precisely the cap. fits_cap is the invariant that must never be false. +SELECT t.what, t.elems, t.cap, s.built, s.bits, s.serialized, + round(s.bits_per_key::numeric, 3) AS bits_per_key, + (NOT s.built OR s.serialized <= t.cap) AS fits_cap +FROM (VALUES + ('no keys', 0::bigint, 1048640::bigint), + ('negative keys', -1, 1048640), + ('cap equals the header', 32, 16), + ('cap one byte over the header', 32, 17), + ('cap is 1 MB, no header room', 32, 1048576), + ('cap is 1 MB plus a header', 32, 1048592), + ('cap one byte short of that', 32, 1048591), + ('a single key', 1, 1048640), + ('exactly 4 bits/key', 2097152, 1048640), + ('one key too many', 2097153, 1048640), + ('exactly 4 bits/key at 2 MB', 4194304, 2097216), + ('one key too many at 2 MB', 4194305, 2097216), + ('a billion keys', 1000000000, 68157440)) + AS t(what, elems, cap), + LATERAL anser_test_bloom_shape(t.elems, t.cap) AS s; + what | elems | cap | built | bits | serialized | bits_per_key | fits_cap +------------------------------+------------+----------+-------+----------+------------+--------------+---------- + no keys | 0 | 1048640 | f | | | | t + negative keys | -1 | 1048640 | f | | | | t + cap equals the header | 32 | 16 | f | | | | t + cap one byte over the header | 32 | 17 | f | | | | t + cap is 1 MB, no header room | 32 | 1048576 | f | | | | t + cap is 1 MB plus a header | 32 | 1048592 | t | 8388608 | 1048592 | 262144.000 | t + cap one byte short of that | 32 | 1048591 | f | | | | t + a single key | 1 | 1048640 | t | 8388608 | 1048592 | 8388608.000 | t + exactly 4 bits/key | 2097152 | 1048640 | t | 8388608 | 1048592 | 4.000 | t + one key too many | 2097153 | 1048640 | f | | | | t + exactly 4 bits/key at 2 MB | 4194304 | 2097216 | t | 16777216 | 2097168 | 4.000 | t + one key too many at 2 MB | 4194305 | 2097216 | f | | | | t + a billion keys | 1000000000 | 68157440 | f | | | | t +(13 rows) + +-- 3. The coordinator's gate on a merged payload. The fill is fixed exactly by +-- setting whole bytes rather than by inserting keys, which would be both slow +-- and only statistically precise. The rule is "reject above 95%", so a payload +-- sitting exactly on the limit is still delivered. Malformed framing is not +-- worth delivering either, since it cannot be a filter at all. +SELECT t.what, t.bitset_bytes, t.bytes_set, t.damage, + anser_test_worth_delivering(t.bitset_bytes, t.bytes_set, t.damage) + AS worth_delivering +FROM (VALUES + ('empty', 1000, 0, ''), + ('half full', 1000, 500, ''), + ('just under the limit', 1000, 949, ''), + ('exactly on the limit', 1000, 950, ''), + ('just over the limit', 1000, 951, ''), + ('completely full', 1000, 1000, ''), + ('no bitset at all', 0, 0, ''), + ('corrupt magic', 1000, 0, 'magic'), + ('corrupt version', 1000, 0, 'version'), + ('zero part count', 1000, 0, 'parts'), + ('null payload', 1000, 0, 'null')) + AS t(what, bitset_bytes, bytes_set, damage); + what | bitset_bytes | bytes_set | damage | worth_delivering +----------------------+--------------+-----------+---------+------------------ + empty | 1000 | 0 | | t + half full | 1000 | 500 | | t + just under the limit | 1000 | 949 | | t + exactly on the limit | 1000 | 950 | | t + just over the limit | 1000 | 951 | | f + completely full | 1000 | 1000 | | f + no bitset at all | 0 | 0 | | f + corrupt magic | 1000 | 0 | magic | f + corrupt version | 1000 | 0 | version | f + zero part count | 1000 | 0 | parts | f + null payload | 1000 | 0 | null | f +(11 rows) + +-- 4. The producer driven end to end on the coordinator-local path: build, +-- insert, publish, consume. "no-filter:cancelled" is the case that matters +-- most -- construction declined, so the cancel goes out before the build side +-- is scanned and no consumer waits for its timeout. The last row is the one +-- the planner cannot predict: a filter sized for 32 keys that receives three +-- million, which saturates and so publishes a cancel instead of a payload. +SELECT t.what, + anser_test_producer_decision(t.elems, t.cap, t.n_keys) AS decision +FROM (VALUES + ('sparse filter', 32::bigint, 1048640::bigint, 100), + ('cap with no header room', 32, 1048576, 100), + ('no keys declared', 0, 1048640, 0), + ('one key past the floor', 2097153, 1048640, 0), + ('exactly 4 bits/key', 2097152, 1048640, 100000), + ('estimate of 32, three million inserted', 32, 1048640, 3000000)) + AS t(what, elems, cap, n_keys); + what | decision +----------------------------------------+--------------------- + sparse filter | built:delivered + cap with no header room | no-filter:cancelled + no keys declared | no-filter:cancelled + one key past the floor | no-filter:cancelled + exactly 4 bits/key | built:delivered + estimate of 32, three million inserted | built:cancelled +(6 rows) + +-- --------------------------------------------------------------------------- +-- The wire protocol. +-- +-- Not exhaustive; a fuzzer would be the right tool for that. These cover the +-- properties that are cheap to check and expensive to lose: a fixed-width +-- header with the fields where they are documented, a payload that cannot be +-- mistaken for framing, a length cross-check that rejects a message which does +-- not add up, and a checksum that rejects one altered byte anywhere it covers. +-- --------------------------------------------------------------------------- +-- The bytes, exactly. Session/command/condition are 42/7/3 and part is 1 of 3, +-- so these are golden values: any change to the layout, the field widths or +-- what the checksum covers shows up here as a diff. Note the third row -- a +-- cancelled message carries no body even though one was passed, and its +-- checksum must be computed over what is actually sent. +-- +-- That the message comes back as text is itself the check that it is NUL-free, +-- which a NOTIFY payload has to be: the fourth row's body is 00 ff 00. +SELECT t.what, length(m.msg) AS len, m.msg +FROM (VALUES + ('part with a 3-byte body', 'P', 'B', 0, '\x616263'::bytea), + ('subscription, no body', 'S', '-', 0, '\x'::bytea), + ('cancelled: body suppressed', 'P', 'B', 1, '\x616263'::bytea), + ('body of NUL and 0xff bytes', 'P', 'B', 0, '\x00ff00'::bytea)) + AS t(what, kind, ptype, flags, body), + LATERAL (SELECT anser_test_wire_format(t.kind::"char", t.ptype::"char", + t.flags, 'anser_rf_3', t.body) + AS msg) m; + what | len | msg +----------------------------+-----+--------------------------------------------------------------------------------------------------------------- + part with a 3-byte body | 109 | anser3 P B 0000000042 0000000007 0000000003 0000000001 0000000003 0000 0010 0000000004 f7b3c0d5anser_rf_3YWJj + subscription, no body | 105 | anser3 S - 0000000042 0000000007 0000000003 0000000001 0000000003 0000 0010 0000000000 ecd98662anser_rf_3 + cancelled: body suppressed | 105 | anser3 P B 0000000042 0000000007 0000000003 0000000001 0000000003 0001 0010 0000000000 e88148e9anser_rf_3 + body of NUL and 0xff bytes | 109 | anser3 P B 0000000042 0000000007 0000000003 0000000001 0000000003 0000 0010 0000000004 027f789banser_rf_3AP8A +(4 rows) + +-- Alter exactly one byte (or one length) and see what the reader does. +-- +-- The split matters: "malformed" is rejected by framing, before a byte is +-- allocated or a channel created, while "checksum-mismatch" got past framing +-- and was caught by the CRC. Corrupting the kind byte is the interesting one +-- -- the message stays perfectly well-formed, and only the checksum notices, +-- which is why the checksum covers the header and not just the payload. +SELECT t.tamper, + anser_test_wire_roundtrip('anser_rf_3', '\x616263'::bytea, t.tamper, + 'B'::"char") AS outcome +FROM (VALUES (''), ('truncate'), ('append'), ('bodylen'), ('tag'), ('kind'), + ('type'), ('crc'), ('key'), ('body')) AS t(tamper); + tamper | outcome +----------+------------------- + | ok + truncate | malformed + append | malformed + bodylen | malformed + tag | malformed + kind | checksum-mismatch + type | unknown-type + crc | checksum-mismatch + key | checksum-mismatch + body | checksum-mismatch +(10 rows) + +-- Keys and bodies that must survive the trip unchanged. "ok" means every +-- field and the decoded body came back identical, so each of these is a case +-- where a naive framing would have gone wrong: the key travels as raw bytes +-- and is taken by length, which is what lets it contain a newline, a space, or +-- the protocol's own tag without any escaping. +SELECT t.what, + anser_test_wire_roundtrip(t.key, t.body, '', 'B'::"char") AS outcome +FROM (VALUES + ('plain key', 'anser_rf_3' , '\x616263'::bytea), + ('key containing a newline', E'rf:a\nb' , '\x616263'::bytea), + ('key that looks like a header', 'anser3 P B 0000000000', '\x616263'::bytea), + ('key with spaces and quotes', 'rf:"a b".c' , '\x616263'::bytea), + ('key at the 63-byte limit', repeat('k', 63) , '\x616263'::bytea), + ('empty key', '' , '\x616263'::bytea), + ('empty body', 'anser_rf_3' , '\x'::bytea), + ('body of a single NUL', 'anser_rf_3' , '\x00'::bytea), + ('body of framing-hostile bytes', 'anser_rf_3' , '\x00ff0a0d5c22'::bytea), + ('1 KB body', 'anser_rf_3' , decode(repeat('00ff', 512), 'hex'))) + AS t(what, key, body); + what | outcome +-------------------------------+--------- + plain key | ok + key containing a newline | ok + key that looks like a header | ok + key with spaces and quotes | ok + key at the 63-byte limit | ok + empty key | ok + empty body | ok + body of a single NUL | ok + body of framing-hostile bytes | ok + 1 KB body | ok +(10 rows) + +-- The QD -> QE checksum. The value itself does not matter; which inputs +-- change it does. Every routing field and the key are always covered, and the +-- body only for a payload type that asks for it -- the fifth column is that +-- deliberate exemption, not an oversight. +SELECT + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 2, 0, 'k', '\x01') AS condition_covered, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 1, 1, 'k', '\x01') AS flags_covered, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 1, 0, 'j', '\x01') AS key_covered, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 1, 0, 'k', '\x02') AS bloom_body_covered, + anser_test_push_crc('-'::"char", 1, 0, 'k', '\x01') + = anser_test_push_crc('-'::"char", 1, 0, 'k', '\x02') AS other_body_exempt, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('-'::"char", 1, 0, 'k', '\x01') AS type_covered; + condition_covered | flags_covered | key_covered | bloom_body_covered | other_body_exempt | type_covered +-------------------+---------------+-------------+--------------------+-------------------+-------------- + t | t | t | t | t | t +(1 row) + +-- 5. The runtime-filter pushdown decision. A scan key is numbered by the +-- table's attributes, while the consumer is numbered by the scan's output +-- targetlist -- the slot it probes has already been projected. So the whole +-- job of the decision is that mapping, plus refusing every shape where there +-- is no mapping to make. sk_attno is what the key would carry; NULL means +-- refused, and the consumer then probes the slot itself (always correct, just +-- slower). In the tlist spec a positive value is a plain Var on that table +-- attno; 0 is a whole-row Var, -1 a system column, -2 a computed column, -3 a +-- Var of another relation, -4 a cast wrapping a Var. +SELECT t.what, t.tlist, t.attno, s.accepts, s.sk_attno +FROM (VALUES + ('all columns in order', '{1,2,3}'::int4[], 2, true, true), + ('projecting scan, one column', '{2}', 1, true, true), + ('reordered, first of two', '{3,1}', 1, true, true), + ('reordered, second of two', '{3,1}', 2, true, true), + ('position past the targetlist', '{2}', 2, true, true), + ('position zero', '{2}', 0, true, true), + ('negative position', '{2}', -1, true, true), + ('computed column', '{-2}', 1, true, true), + ('cast wrapping a column', '{-4}', 1, true, true), + ('whole-row reference', '{0}', 1, true, true), + ('system column', '{-1}', 1, true, true), + ('column of another relation', '{-3}', 1, true, true), + ('filter_in_seqscan off', '{2}', 1, false, true), + ('not a SeqScan', '{2}', 1, true, false) + ) AS t(what, tlist, attno, filter_in_seqscan, as_seqscan), + LATERAL anser_test_pushdown_accepts(t.tlist, t.attno, t.filter_in_seqscan, + t.as_seqscan) AS s; + what | tlist | attno | accepts | sk_attno +------------------------------+---------+-------+---------+---------- + all columns in order | {1,2,3} | 2 | t | 2 + projecting scan, one column | {2} | 1 | t | 2 + reordered, first of two | {3,1} | 1 | t | 3 + reordered, second of two | {3,1} | 2 | t | 1 + position past the targetlist | {2} | 2 | f | + position zero | {2} | 0 | f | + negative position | {2} | -1 | f | + computed column | {-2} | 1 | f | + cast wrapping a column | {-4} | 1 | f | + whole-row reference | {0} | 1 | f | + system column | {-1} | 1 | f | + column of another relation | {-3} | 1 | f | + filter_in_seqscan off | {2} | 1 | f | + not a SeqScan | {2} | 1 | f | +(14 rows) + +-- 6. The walk that finds where to push. With nothing to descend through it +-- must agree with the decision and name the node itself; a node that refuses +-- yields no target at all rather than a target with a meaningless attno. The +-- descent through stacked nodes needs live CustomScanStates, so it is covered +-- end to end by anser_runtime_filter instead. +SELECT t.what, s.found_self, s.sk_attno +FROM (VALUES + ('accepting scan is its own target', '{2}'::int4[], 1, true), + ('refused scan has no target', '{-2}', 1, true), + ('non-scan has no target', '{2}', 1, false) + ) AS t(what, tlist, attno, as_seqscan), + LATERAL anser_test_pushdown_target(t.tlist, t.attno, true, + t.as_seqscan) AS s; + what | found_self | sk_attno +----------------------------------+------------+---------- + accepting scan is its own target | t | 2 + refused scan has no target | f | + non-scan has no target | f | +(3 rows) + +-- 7. How many producers a slice runs. This number is the channel's +-- completion criterion, so over-counting costs every consumer the full +-- anser.timeout_ms -- the channel never reaches the count it waits for and +-- nobody is told -- while under-counting completes it early and hands out a +-- filter missing a segment's keys, which drops rows that join. So each of +-- FillSliceGangInfo's four formulas is pinned here, not inferred from one. +-- +-- numsegments is only meaningful for a reader or writer gang; the columns are +-- still filled for the other types to show they are ignored. +SELECT t.what, t.gang, t.nseg, t.workers, t.direct, + anser_test_slice_producers(t.gang, t.nseg, t.workers, t.direct, 0) + AS producers +FROM (VALUES + ('coordinator slice', 'unallocated', 3, 0, NULL::int4[]), + ('coordinator slice, parallel', 'unallocated', 3, 4, NULL), + ('entry db reader', 'entrydb', 3, 0, NULL), + ('singleton reader', 'singleton', 3, 0, NULL), + ('singleton reader, parallel', 'singleton', 3, 4, NULL), + ('reader, serial', 'reader', 3, 0, NULL), + ('reader, parallel', 'reader', 3, 4, NULL), + ('writer, serial', 'writer', 3, 0, NULL), + ('direct dispatch, one segment', 'reader', 3, 0, '{1}'), + ('direct dispatch, two, serial', 'reader', 3, 0, '{0,2}'), + ('direct dispatch, two, par 4', 'reader', 3, 4, '{0,2}'), + ('direct dispatch, nowhere', 'reader', 3, 4, '{}'), + ('no segments', 'reader', 0, 4, NULL), + ('negative segments', 'reader', -1, 4, NULL) + ) AS t(what, gang, nseg, workers, direct); + what | gang | nseg | workers | direct | producers +------------------------------+-------------+------+---------+--------+----------- + coordinator slice | unallocated | 3 | 0 | | 1 + coordinator slice, parallel | unallocated | 3 | 4 | | 1 + entry db reader | entrydb | 3 | 0 | | 1 + singleton reader | singleton | 3 | 0 | | 1 + singleton reader, parallel | singleton | 3 | 4 | | 4 + reader, serial | reader | 3 | 0 | | 3 + reader, parallel | reader | 3 | 4 | | 12 + writer, serial | writer | 3 | 0 | | 3 + direct dispatch, one segment | reader | 3 | 0 | {1} | 1 + direct dispatch, two, serial | reader | 3 | 0 | {0,2} | 2 + direct dispatch, two, par 4 | reader | 3 | 4 | {0,2} | 8 + direct dispatch, nowhere | reader | 3 | 4 | {} | 0 + no segments | reader | 0 | 4 | | 0 + negative segments | reader | -1 | 4 | | 0 +(14 rows) + +-- The slice has to be identifiable at all; anything else is zero, which the +-- injection pass reads as "unknown" and declines on. +SELECT t.what, anser_test_slice_producers('reader', 3, 4, NULL, t.idx) + AS producers +FROM (VALUES + ('slice 0 of 1 exists', 0), + ('slice 1 of 1 does not', 1), + ('negative slice index', -1) + ) AS t(what, idx); + what | producers +-----------------------+----------- + slice 0 of 1 exists | 12 + slice 1 of 1 does not | 0 + negative slice index | 0 +(3 rows) + +DROP EXTENSION anser_test; diff --git a/gpcontrib/anser/include/anser.h b/gpcontrib/anser/include/anser.h new file mode 100644 index 00000000000..d7967e59070 --- /dev/null +++ b/gpcontrib/anser/include/anser.h @@ -0,0 +1,84 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anser.h + * Shared definitions for the Anser adaptive information sharing + * subsystem. + * + * Anser lets producers on the segments publish a small piece of information + * about a running query -- today a bloom filter over a join-build key -- have + * the coordinator combine the per-segment parts, and hand the result back to + * consumers on the segments, which use it to prune work. + * + * Everything travels over the dispatch connection the coordinator already + * holds open to each segment (see ansersideband.h), so there is no shared + * memory, no background worker and no second connection to authenticate: a + * channel exists only in the coordinator backend running the query, for as + * long as that query runs. + * + * IDENTIFICATION + * gpcontrib/anser/include/anser.h + * + *------------------------------------------------------------------------- + */ +#ifndef ANSER_H +#define ANSER_H + +#include "postgres.h" + +#define ANSER_CONDITION_KEY_SIZE 64 + +/* + * Identifies one channel: a single condition within one running command. + * + * condition_key is an opaque symbol naming the condition -- the + * optimizer-generated equivalence-class symbol described in the Anser paper. + * Channels are only ever compared for equality, never ordered. + */ +typedef struct AnserChannelKey +{ + int gp_session_id; + int gp_command_count; + uint32 condition_id; + char condition_key[ANSER_CONDITION_KEY_SIZE]; +} AnserChannelKey; + +/* GUCs (defined in anserinit.c). */ +extern PGDLLEXPORT bool gp_anser_enable; +extern PGDLLEXPORT bool gp_anser_runtime_filter; +extern PGDLLEXPORT bool gp_anser_debug; +extern PGDLLEXPORT int gp_anser_max_info_size; +extern PGDLLEXPORT int gp_anser_timeout_ms; + +/* + * Trace the handoff between producers, the coordinator and consumers. + * + * Every step of the exchange is invisible in EXPLAIN until it is over -- a + * consumer that waits and gets nothing looks exactly like one whose producers + * never published -- so the path is traceable on demand rather than only + * reconstructable from a debugger. LOG level, so it lands in the log of + * whichever process it happened in. + */ +#define ANSER_DEBUG(...) \ + do { \ + if (gp_anser_debug) \ + elog(LOG, __VA_ARGS__); \ + } while (0) + +#endif /* ANSER_H */ diff --git a/gpcontrib/anser/include/anserbloom.h b/gpcontrib/anser/include/anserbloom.h new file mode 100644 index 00000000000..946b35db5f8 --- /dev/null +++ b/gpcontrib/anser/include/anserbloom.h @@ -0,0 +1,79 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserbloom.h + * Standalone Anser Bloom filter producer/consumer executor helpers. + * + * IDENTIFICATION + * gpcontrib/anser/include/anserbloom.h + * + *------------------------------------------------------------------------- + */ +#ifndef ANSERBLOOM_H +#define ANSERBLOOM_H + +#include "postgres.h" + +#include "anser.h" +#include "lib/bloomfilter.h" + +/* + * Opaque executor state handles for the Bloom filter producer and + * consumer; the struct definitions are private to + * anserbloomproduce.c and anserbloomconsume.c. + */ +typedef struct AnserBloomFilterProduceState AnserBloomFilterProduceState; +typedef struct AnserBloomFilterConsumeState AnserBloomFilterConsumeState; + +/* Producer side: build one Bloom filter part and publish it to the channel. */ + +/* + * libpq connection (parallel-retrieve-cursor model); NULL means connect + * without it and rely on pg_hba. Ignored on the coordinator-local path. + */ +extern PGDLLEXPORT AnserBloomFilterProduceState *ExecInitAnserBloomFilterProduce( + const AnserChannelKey *channel_key, + int64 total_elems, + Size max_payload_bytes, + uint32 part_index, + uint32 total_parts); +extern PGDLLEXPORT void ExecAnserBloomFilterProduceAddDatum(AnserBloomFilterProduceState *state, + Datum value, bool isnull); +extern PGDLLEXPORT bool ExecAnserBloomFilterProduceHasFilter(AnserBloomFilterProduceState *state); +extern PGDLLEXPORT bool ExecAnserBloomFilterProducePublish(AnserBloomFilterProduceState *state); +extern PGDLLEXPORT bool ExecAnserBloomFilterProduceCancel(AnserBloomFilterProduceState *state); +extern PGDLLEXPORT void ExecEndAnserBloomFilterProduce(AnserBloomFilterProduceState *state); + +/* Consumer side: gather all parts of a channel into one Bloom filter. */ +extern PGDLLEXPORT AnserBloomFilterConsumeState *ExecInitAnserBloomFilterConsume( + const AnserChannelKey *channel_key, + int64 total_elems, + Size max_payload_bytes, + uint32 expected_parts); +extern PGDLLEXPORT bool ExecAnserBloomFilterConsume(AnserBloomFilterConsumeState *state, + long registration_timeout_ms); +extern PGDLLEXPORT bloom_filter *ExecAnserBloomFilterConsumerGetFilter( + AnserBloomFilterConsumeState *state); +extern PGDLLEXPORT uint32 ExecAnserBloomFilterConsumerReceivedParts( + AnserBloomFilterConsumeState *state); +extern PGDLLEXPORT bool ExecAnserBloomFilterConsumerWasCancelled( + AnserBloomFilterConsumeState *state); +extern PGDLLEXPORT void ExecEndAnserBloomFilterConsume(AnserBloomFilterConsumeState *state); + +#endif /* ANSERBLOOM_H */ diff --git a/gpcontrib/anser/include/anserfilter.h b/gpcontrib/anser/include/anserfilter.h new file mode 100644 index 00000000000..dd802a4b6f6 --- /dev/null +++ b/gpcontrib/anser/include/anserfilter.h @@ -0,0 +1,127 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserfilter.h + * Bloom-filter payload helpers for Anser channels. + * + * IDENTIFICATION + * gpcontrib/anser/include/anserfilter.h + * + *------------------------------------------------------------------------- + */ +#ifndef ANSERFILTER_H +#define ANSERFILTER_H + +#include "postgres.h" + +#include "lib/bloomfilter.h" + +#define ANSER_BLOOM_PART_MAGIC 0x41424631U /* ABF1 */ +#define ANSER_BLOOM_PART_VERSION 1U + +/* + * On-wire framing for a serialized bloom part. It deliberately does NOT carry + * the bitset parameters (size / seed / hash count): both the producer and the + * consumer build the filter with bloom_create from the same plan parameters, so + * the shape is agreed by construction and never reconstructed from the wire. + * magic/version guard the framing; part_index/total_parts track the coordinator + * fold count (surfaced as diagnostics). Kept as a struct for forward + * extensibility. + */ +typedef struct AnserBloomPartHeader +{ + uint32 magic; + uint32 version; + uint32 part_index; + uint32 total_parts; +} AnserBloomPartHeader; + +/* + * When a bloom filter stops being worth building, or sending once built. + * Three checks, at the three points where new information arrives. + * + * ANSER_BLOOM_MIN_BITS_PER_KEY -- a floor on the *planned* density: bitset bits + * divided by the number of distinct keys we expect. Below it no amount of care + * in building the filter helps, so the filter is not built and, at plan time, + * the nodes are not even injected. At 4 bits/key the optimal hash count is 3 + * and the false positive rate is already ~15%; below that it collapses (2 + * bits/key is ~40%). + * + * ANSER_BLOOM_MAX_FPR -- the check on the *realized* filter, at publish time. + * It catches what a row estimate cannot: the estimate was too low, so the + * filter saturated anyway. This one is expressed as a false positive rate + * rather than a fill fraction on purpose. FPR is fill^k, so a single fill + * limit is not a single quality bar: 80% full is a 51% FPR at k=3 but only + * 10.7% at k=10, and a rule that cancelled the latter would be throwing away a + * filter that eliminates nine probe rows in ten. The producer has the filter + * and therefore its k, so it can just ask. + * + * ANSER_BLOOM_MERGED_MAX_FILL -- the same question asked by the coordinator of + * the merged payload, which is the only place it can be asked about what + * consumers will actually receive. It has to fall back on fill, because k is + * derived from plan parameters and is not carried in the serialized part. It + * is therefore set where even k=10 is past saving (0.95^10 = 60% FPR), so that + * it only ever rejects the hopeless. Putting k in AnserBloomPartHeader would + * let this use the FPR too. + * + * All three numbers are first cuts. Calibrating them is the "stop building + * filters that add nothing" part of the bloom-performance work; the debug trace + * logs fill and FPR at every decision point so that study has data. + */ +#define ANSER_BLOOM_MIN_BITS_PER_KEY 4.0 +#define ANSER_BLOOM_MAX_FPR 0.50 +#define ANSER_BLOOM_MERGED_MAX_FILL 0.95 + +/* + * Is a serialized part (or a merged accumulator of them) still worth + * delivering? False when too many of its bits are set for it to reject + * anything useful. Works on the wire form, so the coordinator can ask this of + * a merged payload without rebuilding a filter. + */ +extern PGDLLEXPORT bool AnserBloomPartWorthSending(const void *payload, Size payload_len); + +extern PGDLLEXPORT uint64 AnserBloomSeed(const char *condition_key); + +/* + * Build an empty filter, or return NULL when AnserBloomShapeFor says it is not + * worth building. A NULL return is not an error: the producer turns it into an + * immediate cancel, so consumers stop waiting instead of timing out. + */ +extern PGDLLEXPORT bloom_filter *AnserBloomCreate(int64 total_elems, + Size max_payload_bytes, + uint64 seed); +extern PGDLLEXPORT Size AnserBloomSerializedSize(const bloom_filter *filter); +extern PGDLLEXPORT bool AnserBloomSerializePart(const bloom_filter *filter, + uint32 part_index, + uint32 total_parts, + void *buffer, + Size buffer_size, + Size *payload_len); +extern PGDLLEXPORT bloom_filter *AnserBloomDeserializePart(const void *payload, + Size payload_len, + int64 total_elems, + Size max_payload_bytes, + uint64 seed, + uint32 *part_index, + uint32 *total_parts); +extern PGDLLEXPORT bool AnserBloomLooksLikePart(const void *payload, Size payload_len); +extern PGDLLEXPORT bool AnserBloomFoldPartInPlace(void *acc, Size acc_len, + const void *part, Size part_len); + +#endif /* ANSERFILTER_H */ diff --git a/gpcontrib/anser/include/anserpayload.h b/gpcontrib/anser/include/anserpayload.h new file mode 100644 index 00000000000..18ab6bab108 --- /dev/null +++ b/gpcontrib/anser/include/anserpayload.h @@ -0,0 +1,150 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserpayload.h + * What a channel carries, and who knows what to do with it. + * + * The transport (ansersideband.c on segments, anserdispatch.c on the + * coordinator) moves opaque bytes: it cannot tell a bloom filter from a row + * count, and should not have to. Everything that depends on what the bytes + * mean lives in one descriptor per payload type, so a new kind of runtime + * information needs no change to the framing, the checksumming or the channel + * bookkeeping. + * + * Exactly two things genuinely differ per type. + * + * How parts merge. The coordinator receives one part per producer and must + * reduce them to a single payload. For a bloom filter that is a bitwise OR of + * equal-sized bitsets; for a row count it would be a sum; for a min/max + * summary it would be two comparisons. There is no generic answer, so each + * type supplies fold(). + * + * Whether corruption can give a wrong answer. This is what 'checksum_body' + * decides, and it is about consequence, not size. A bloom filter fails + * asymmetrically: a bit flipped 0 -> 1 merely costs selectivity, but a bit + * flipped 1 -> 0 removes a key from the filter, so a joinable row is rejected + * and the query returns fewer rows than it should -- silently. A row count, by + * contrast, only feeds a planning decision: a corrupted one costs a worse plan, + * never an incorrect result. It does not need to be checksummed, and paying + * 0.05 ms/MB to checksum it anyway would be waste. + * + * Note what 'checksum_body' does *not* cover: the header and the condition key + * are checksummed for every message regardless. That is ~100 ns for a 95-byte + * header and it is what validates the routing fields, so there is nothing to + * gain by skipping it; the flag controls only the payload, which is where the + * cost lives. + * + * To add a payload type: + * + * 1. #define a code below. It travels as one byte and appears in the + * anser.debug trace, so keep it printable -- and it may not be '\0', + * which AnserPayloadTable uses to recognize a slot nobody registered. + * 2. Write a fold() and add a row to AnserPayloadTable in anserpayload.c, + * keyed by the code: the table is indexed by the wire byte, so the + * designator and the row's own 'code' field must agree (an assertion in + * AnserPayloadLookup catches it if they do not). + * 3. Pass the code to AnserSidebandPublish/AnserDispatchLocalPublish in the + * producer node, and to AnserSidebandConsumeWait/AnserDispatchLocalConsume + * in the consumer node. + * + * That is the whole extension point. In particular, do not add a branch on the + * payload type to the transport: if something there needs to know the type, + * that knowledge belongs in this descriptor instead. + * + * IDENTIFICATION + * gpcontrib/anser/include/anserpayload.h + * + *------------------------------------------------------------------------- + */ +#ifndef ANSERPAYLOAD_H +#define ANSERPAYLOAD_H + +#include "anser.h" + +/* + * Payload type codes. + * + * Distinct from the message kind (P/S), which says what a message *does* + * rather than what it *carries*: a subscription carries nothing, and a + * cancelled part still declares the type it would have carried. + */ +#define ANSER_PAYLOAD_NONE '-' /* no payload (a subscription) */ +#define ANSER_PAYLOAD_BLOOM 'B' /* serialized bloom filter part */ + +/* '\0' is reserved: it is how an unregistered code is recognized. */ + +typedef struct AnserPayloadOps +{ + char code; /* the wire byte */ + const char *name; /* for logs; no other use */ + bool checksum_body; /* see the file header */ + + /* + * Merge 'part' into the accumulator in place. + * + * The accumulator is the first part the channel received, kept verbatim, so + * the two always have the same type -- but not necessarily the same length + * or internal parameters, which is what this has to check. Return false if + * they cannot be merged; the caller then cancels the channel, which costs + * the consumers their filter but is never silently wrong. + * + * Must not modify 'accum' unless it returns true: the caller reports a + * false return and carries on, so a half-folded accumulator would be + * indistinguishable from a good one. + * + * NULL for a type that is never folded (one that has no payload). + */ + bool (*fold) (void *accum, Size accum_len, + const void *part, Size part_len); + + /* + * Is the finished accumulator worth delivering? Asked once, after the last + * part has been folded and before anything is sent to a consumer; a false + * return cancels the channel, so consumers run unfiltered. + * + * This is the only place the question can be answered honestly, because + * folding changes the answer: OR-ing bloom parts together makes the result + * denser than any part, so three parts that each looked worth sending can + * merge into one that is not. Producers cannot see that, and consumers + * should not be made to pay for it. + * + * NULL for a type that is always worth delivering -- a row count does not + * become less useful for having been summed. + */ + bool (*worth_delivering) (const void *accum, Size accum_len); +} AnserPayloadOps; + +/* + * Resolve a code to its descriptor, or NULL if nothing has registered it. + * + * Callers must handle the NULL: the code arrives from the wire, so an + * unregistered one is a real case, and defaulting it silently would turn a + * forgotten table row into a mystery rather than a log line. + */ +extern PGDLLEXPORT const AnserPayloadOps *AnserPayloadLookup(char code); + +/* + * Whether a type's body is covered by the message checksum. Takes a code + * rather than a descriptor because the transport reaches this while parsing, + * before it has resolved anything; an unregistered code answers false, which + * is why this one needs no error handling. + */ +extern PGDLLEXPORT bool AnserPayloadChecksumsBody(char code); + +#endif /* ANSERPAYLOAD_H */ diff --git a/gpcontrib/anser/include/anserplan.h b/gpcontrib/anser/include/anserplan.h new file mode 100644 index 00000000000..26c8169424d --- /dev/null +++ b/gpcontrib/anser/include/anserplan.h @@ -0,0 +1,167 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserplan.h + * Post-planning transformation that injects Anser runtime bloom-filter + * producer/consumer nodes into a finished plan tree. + * + * IDENTIFICATION + * gpcontrib/anser/include/anserplan.h + * + *------------------------------------------------------------------------- + */ +#ifndef ANSERPLAN_H +#define ANSERPLAN_H + +#include "nodes/execnodes.h" /* PlanState, for the pushdown helpers */ +#include "nodes/plannodes.h" + +/* + * Post-plan pass: recognize the supported join shape in a finished PlannedStmt + * and inject an Anser bloom-filter producer (on the hash build side) and a + * consumer (above the probe scan). Called once from planner(), so it covers + * both the Postgres planner and ORCA. A no-op unless the Anser runtime-filter + * GUCs are on and this is a coordinator SELECT. + */ +extern PGDLLEXPORT void AnserApplyRuntimeFilters(PlannedStmt *stmt); + +/* + * Register the two CustomScan providers (producer, consumer) so their methods + * resolve by name when a dispatched plan is deserialized. Must run once per + * backend (QD and every QE) before any plan execution. + */ +extern PGDLLEXPORT void AnserRegisterRuntimeFilterMethods(void); + +/* + * Node builders (implemented in anserplanexec.c, where the CustomScan method + * tables live). Each wraps `child` in a pass-through CustomScan carrying the + * runtime-filter parameters in custom_private; the caller assigns plan_node_id. + * `key_attno` is the build (producer) / probe (consumer) join-key attno in the + * child's output tuple. `n_producers` is how many processes will publish a + * part -- the width of the build scan's slice, which under parallel execution + * is numsegments * parallel_workers rather than the segment count. + */ +/* + * True for the producer and consumer nodes this module injects. The injection + * pass meets them where it expects a scan, because two joins can want to filter + * the same base relation; coordinator-only, see the implementation. + */ +extern PGDLLEXPORT bool AnserIsRuntimeFilterScan(const Plan *plan); + +extern PGDLLEXPORT CustomScan *AnserBuildBloomProducerScan( + Plan *child, + AttrNumber key_attno, + uint32 condition_id, + const char *condition_key, + int64 total_elems, + Size max_payload_bytes, + int64 planned_bytes, + int n_producers); +/* + * `defer_first` tells the consumer to pass its first tuple through unfiltered. + * ExecHashJoin pulls one outer tuple before building its hash table, and a + * consumer that blocks there waits for an inner side the join has not started + * -- in a co-located join, for its own process. Set it whenever the prefetch + * cannot be ruled out; the cost is that such a consumer cannot push its filter + * into the scan and probes it itself instead. + */ +extern PGDLLEXPORT CustomScan *AnserBuildBloomConsumerScan( + Plan *child, + AttrNumber key_attno, + uint32 condition_id, + const char *condition_key, + int64 total_elems, + Size max_payload_bytes, + int64 planned_bytes, + int n_producers, + bool defer_first); + +/* + * Bloom sizing for one join, from its estimated build cardinality. False means + * no filter is worth injecting -- see the density rule in anserfilter.h. + * + * Exposed rather than static because it is the cheapest of Anser's give-up + * decisions and therefore the one most worth testing directly at its boundary + * (anser_test.c); nothing but the injection pass and the tests should call it. + */ +extern PGDLLEXPORT bool AnserRuntimeFilterSize(double est_rows, + int64 *total_elems, + int64 *max_payload, + int64 *planned_bytes); + +/* + * Follow a key column of `top` down to the base scan that originates it, + * through the nodes that pass a column through unchanged (Hash, Motion), the + * joins that carry one side's columns up (HashJoin), and our own nodes. True + * on success, with *scan_out the scan, *attno_out the key's attno in it, + * *parent_out the node holding it (ours keep it in custom_plans, everything + * else in the outer link) and *slice_out the slice the scan runs in. + * + * False means no single base scan holds the whole key column -- an Append or a + * SetOp, say, where the column comes from several relations at once and a + * filter built on any one of them would reject rows that can join. A false + * return is therefore a correctness gate, not a missed optimization: callers + * must decline rather than guess. + * + * Only meaningful after set_plan_references, which is also what makes the + * descent necessary: before it the Vars still name their base relations. + */ +extern PGDLLEXPORT bool AnserResolveKeyScan(Plan *top, + AttrNumber key_attno, + int slice_index, + Plan **parent_out, + Plan **scan_out, + AttrNumber *attno_out, + int *slice_out); + +/* + * How many processes execute `slice_index` of `stmt` -- the number of parts a + * channel fed from that slice must wait for. Zero means the slice could not be + * identified, and the caller must decline rather than assume: a channel that + * expects too few parts completes early and hands out a filter missing some + * segment's keys, which drops rows that can join. + * + * Not the segment count. A parallel slice runs numsegments * parallel_workers + * full QEs, each publishing for itself, and the two optimizers choose different + * widths for the same query -- see the implementation. + */ +extern PGDLLEXPORT int AnserSliceProducers(PlannedStmt *stmt, int slice_index); + +/* + * Runtime-filter pushdown, split so the decision can be tested on its own. + * + * AnserPushdownAccepts answers "would this node take a scan key for the column + * it emits at output position `attno`?", and on yes reports the attribute + * number the key must carry -- which is the table's attno, not `attno`, because + * the scan evaluates the key against an unprojected tuple. + * + * AnserPushdownTarget walks down from `top` following that column and returns + * the deepest accepting node, so a stack of consumers over one relation all + * push into the same scan instead of probing tuple by tuple. + * + * Both return false/NULL rather than failing when the shape is not supported; + * the caller's fallback is to probe the filter itself, which is always correct. + */ +extern PGDLLEXPORT bool AnserPushdownAccepts(PlanState *ps, AttrNumber attno, + AttrNumber *sk_attno_out); +extern PGDLLEXPORT PlanState *AnserPushdownTarget(PlanState *top, + AttrNumber attno, + AttrNumber *sk_attno_out); + +#endif /* ANSERPLAN_H */ diff --git a/gpcontrib/anser/include/ansersideband.h b/gpcontrib/anser/include/ansersideband.h new file mode 100644 index 00000000000..7c2f0775eb2 --- /dev/null +++ b/gpcontrib/anser/include/ansersideband.h @@ -0,0 +1,251 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * ansersideband.h + * Anser transport over the existing QD <-> QE dispatch connection. + * + * Instead of opening a second libpq connection back to the coordinator, a + * segment executor reuses the connection the dispatcher already holds open: + * QE -> QD travels as a NOTIFY (the model nextval() uses, see + * cdb_sequence_nextval_qe in commands/sequence.c), and QD -> QE as a + * GP_SIDEBAND_MESSAGE the waiting QE reads off its own socket. + * + * The two directions are not symmetric, and the wire formats differ for a + * reason. A NOTIFY payload is delivered by pq_sendstring(), so it must be a + * NUL-free C string -- hence the text header and base64 body. The QD -> QE + * push is written with pqPutnchar(), which performs no conversion, so the + * merged filter travels as raw binary. That matters: the merged payload is + * sent once per consumer, while each part is sent once. + * + * Both directions carry a CRC32C. Not because the transport is lossy -- it is + * TCP, or a Unix socket; the UDP interconnect is a different channel entirely + * -- but because some payloads fail asymmetrically and TCP's 16-bit checksum is + * thin cover for a megabyte. The header and condition key are always covered; + * whether the body is covered too is the payload type's decision + * (AnserPayloadOps.checksum_body, see anserpayload.h), because that is the part + * that costs 0.05 ms/MB and only some payloads can turn corruption into a wrong + * answer. The QE -> QD checksum covers the raw pre-base64 bytes, so where it + * applies it validates the decode as well. + * + * Neither direction branches on the payload type beyond consulting that one + * flag: everything else about what the bytes mean belongs in the registry. + * + * IDENTIFICATION + * gpcontrib/anser/include/ansersideband.h + * + *------------------------------------------------------------------------- + */ +#ifndef ANSERSIDEBAND_H +#define ANSERSIDEBAND_H + +#include "anser.h" +#include "anserpayload.h" +#include "port/pg_crc32c.h" + +struct CdbDispatchResult; /* #include "cdb/cdbdispatchresult.h" */ +struct pgNotify; /* #include "libpq-fe.h" */ + +/* + * NOTIFY channel for QE -> QD Anser traffic. The dispatcher hands notifies it + * does not recognize to cdbdisp_notify_hook, where we match on this name. + */ +#define ANSER_NOTIFY_CHANNEL "anser_rf" + +/* First token of every QE -> QD payload; bump when the format changes. */ +#define ANSER_WIRE_TAG "anser3" + +/* + * Message kinds (QE -> QD): what the message does. What it carries is a + * separate field, the payload type -- see anserpayload.h. + */ +#define ANSER_WIRE_KIND_PART 'P' /* a producer's serialized part */ +#define ANSER_WIRE_KIND_SUBSCRIBE 'S' /* a consumer registering interest */ +#define ANSER_WIRE_KIND_UNSUBSCRIBE 'U' /* ... and withdrawing it again */ + +/* Flag bits, shared by both directions. */ +#define ANSER_WIRE_F_CANCELLED 0x0001 + +/* + * QE -> QD header: fixed width, so the key and body start at known offsets and + * nothing about their contents can affect parsing. + * + * anser3 K T SSSSSSSSSS CCCCCCCCCC DDDDDDDDDD PPPPPPPPPP TTTTTTTTTT FFFF KKKK BBBBBBBBBB XXXXXXXX + * ^tag ^ ^ session command condition part total ^ ^ bodylen crc + * | \ payload type flags keylen + * \ kind + * + * followed immediately by key bytes and body bytes. Every + * field is zero-padded (flags and CRC are hex, the rest decimal), so the header + * is pure ASCII of a constant length and holds no delimiter that payload bytes + * could imitate. The two lengths are then a cross-check: the total payload + * length must be exactly the header plus both, or the message is rejected. + * + * The signed session and command ids travel as uint32 and are cast back on + * receipt, which round-trips them exactly while keeping the width fixed -- + * "%010d" of a negative value is eleven characters, which would have made the + * header variable-width again. + * + * The previous format ended its header with a newline and found it with + * strchr(). That was correct only because the header could not itself contain + * a newline -- an invariant nothing enforced, and one that a key derived from + * relation names (quoted identifiers may contain anything) would have broken + * silently. + */ +#define ANSER_WIRE_HDR_FORMAT \ + ANSER_WIRE_TAG " %c %c %010u %010u %010u %010u %010u %04x %04u %010u %08x" + +/* + * Read format for the same header. The widths are what make the parse safe: + * every conversion stops after exactly its field, so no value can consume the + * separator or run into the key bytes that follow the header. + */ +#define ANSER_WIRE_HDR_SCANF \ + ANSER_WIRE_TAG " %c %c %10u %10u %10u %10u %10u %4x %4u %10u %8x" + +/* 6 + the 11 fields above, each preceded by its separating space. */ +#define ANSER_WIRE_HDR_LEN (6 + (1 + 1) + (1 + 1) + (1 + 10) + (1 + 10) \ + + (1 + 10) + (1 + 10) + (1 + 10) + (1 + 4) \ + + (1 + 4) + (1 + 10) + (1 + 8)) + +/* Offset of the 8-hex-digit CRC; the checksum covers everything before it. */ +#define ANSER_WIRE_CRC_OFFSET (ANSER_WIRE_HDR_LEN - 8) + +/* + * Largest key and body a header can describe. Checked before formatting so a + * field can never overflow its width and shift every field after it. + */ +#define ANSER_WIRE_MAX_KEYLEN 9999 +#define ANSER_WIRE_MAX_BODYLEN 999999999 + +/* + * The codec. + * + * Four functions, exported rather than static because together they *are* the + * protocol: the two directions have to agree byte for byte about offsets and + * about what the checksum covers, and that agreement is only auditable -- and + * only testable -- if both halves are reachable by name. sql/anser_test.sql + * drives them directly. + * + * They are implemented on the side that writes: AnserWireFormat in + * ansersideband.c (only a QE formats), AnserWireParse and AnserWireCheckCrc in + * anserdispatch.c (only the QD parses). + * + * AnserWireFormat returns a palloc'd NUL-terminated string. AnserWireParse + * fills 'out' and returns false for anything it will not vouch for; its + * contract and the checks it makes are documented at the definition. + * AnserWireCheckCrc takes the *decoded* body, or NULL/0 when there is none. + */ +typedef struct AnserWireMsg +{ + const char *wire; /* the message itself; the CRC covers its head */ + char kind; + char payload_type; /* resolved against the registry by the caller */ + AnserChannelKey key; + int key_len; + int part_index; + int total_parts; + int flags; + const char *body; /* base64, not NUL-terminated */ + int body_len; + uint32 crc; /* of the header, key and decoded body */ +} AnserWireMsg; + +extern PGDLLEXPORT char *AnserWireFormat(const AnserChannelKey *channel_key, char kind, + char payload_type, uint32 part_index, + uint32 total_parts, int flags, + const void *payload, Size payload_len); +extern PGDLLEXPORT bool AnserWireParse(const char *msg, AnserWireMsg *out); +extern PGDLLEXPORT bool AnserWireCheckCrc(const AnserWireMsg *msg, const void *body, + Size body_len); + +/* + * QD -> QE push: raw binary, written with pqPutInt/pqPutnchar. + * + * payload_type | crc32c | condition_id | flags | keylen | key | bodylen | body + * + * with each field a 4-byte integer except the key and body. The CRC covers all + * of the other fields in that order, the integers in network byte order, so it + * validates the routing information as well as the payload; the body is + * included only when the payload type says so. Both ends compute it through + * this one function so the two cannot drift apart. It is implemented in + * ansersideband.c, beside the reader that verifies it. + */ +extern PGDLLEXPORT pg_crc32c AnserWirePushCrc(char payload_type, uint32 condition_id, + uint32 flags, const char *key, int keylen, + const void *body, int bodylen); + +/* + * QE side (ansersideband.c). + * + * AnserSidebandPublish is fire-and-forget: unlike the libpq transport it does + * not wait for the coordinator to acknowledge the part. 'payload_type' is + * stamped on the wire even when cancelled, since it is what tells the + * coordinator how to fold the channel's parts. + * + * AnserSidebandConsumeWait blocks on this backend's own dispatch socket until + * the merged payload arrives, the channel is cancelled, or timeout_ms elapses. + * On success *payload is palloc'd in the caller's context. A false return + * always means "run unfiltered", never an error -- including when what arrived + * is not of 'payload_type', which would mean two different kinds of information + * had collided on one channel. + */ +extern PGDLLEXPORT bool AnserSidebandPublish(const AnserChannelKey *channel_key, + char payload_type, + uint32 part_index, uint32 total_parts, + const void *payload, Size payload_len, + bool cancelled); +extern PGDLLEXPORT bool AnserSidebandConsumeWait(const AnserChannelKey *channel_key, + char payload_type, + void **payload, Size *payload_len, + bool *cancelled, long timeout_ms); + +/* + * QD side (anserdispatch.c). + * + * AnserDispatchNotifyHandler is installed as cdbdisp_notify_hook; it folds + * arriving parts and pushes the merged payload to subscribers. The Local + * variants serve producers and consumers running on the coordinator itself, + * which have no dispatch connection to themselves and so operate on the same + * per-query channel table directly. They take the same payload type for the + * same reasons, so that a coordinator-local producer and a segment producer are + * interchangeable on one channel. + */ +extern PGDLLEXPORT bool AnserDispatchNotifyHandler(struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify); +extern PGDLLEXPORT bool AnserDispatchLocalPublish(const AnserChannelKey *channel_key, + char payload_type, + uint32 part_index, uint32 total_parts, + const void *payload, Size payload_len, + bool cancelled); +extern PGDLLEXPORT bool AnserDispatchLocalConsume(const AnserChannelKey *channel_key, + char payload_type, + void **payload, Size *payload_len, + bool *cancelled); + +/* + * Drop per-query state. AnserSidebandResetAll does both sides and is what + * the executor/transaction-end callbacks call; the halves are exposed because + * each lives with the state it owns. + */ +extern PGDLLEXPORT int64 AnserDispatchUnsubscribeCount(void); +extern PGDLLEXPORT void AnserDispatchReset(void); +extern PGDLLEXPORT void AnserSidebandResetInbox(void); +extern PGDLLEXPORT void AnserSidebandResetAll(void); + +#endif /* ANSERSIDEBAND_H */ diff --git a/gpcontrib/anser/sql/anser_multijoin.sql b/gpcontrib/anser/sql/anser_multijoin.sql new file mode 100644 index 00000000000..e10247295d6 --- /dev/null +++ b/gpcontrib/anser/sql/anser_multijoin.sql @@ -0,0 +1,228 @@ +-- Anser runtime bloom filter: chains of 2..10 joins. +-- +-- Each table joins to the next on its own key pair -- t1.nxt = t2.own, then +-- t2.nxt = t3.own, and so on -- which is what a real chain looks like: no two +-- joins share a key, so no table's distribution can serve more than one of +-- them and every join needs a motion of its own. Joining a whole chain on one +-- column would make the joins co-located and would test the easy case only. +-- +-- This is also the shape the feature has to handle: the build side of the third +-- join is the result of the first two, and the key column for that join comes +-- from t2, so it reaches its base relation through a join rather than straight +-- through a Hash. +-- +-- What these cases assert is not that a filter was injected -- that is the +-- planner's choice and it differs between the two optimizers and with the +-- cluster width -- but that the answer is the same either way. A bloom filter +-- fails asymmetrically: a bit that should be set and is not silently removes +-- joinable rows, so "same rows with the filter on as with it off" is the +-- property worth asserting. Plan shapes are deliberately not asserted; an +-- expected file full of them would break on a three-segment cluster and pass on +-- a one-segment one without saying anything about correctness. +-- +-- Every query below reports its violations, so the expected output is empty +-- results and zeros. A failure names the chain that broke rather than leaving +-- a table of numbers to be compared by eye. + +SET enable_nestloop = off; +SET enable_mergejoin = off; + +-- A regress test must not inherit the production deadline. anser.timeout_ms +-- defaults to 100 s because delivery is serial and a wide slice needs that +-- long to be served; a test that waits it out has turned a delivery failure +-- into a slow pass. Pinned short here, so a filter that never arrives fails +-- the run instead of hiding in the clock. +SET anser.timeout_ms = 5000; + +-- own is what the previous table joins to, nxt is what this table joins to the +-- next one with. They hold the same value, which keeps the chain 1:1 and the +-- answer predictable -- 1000 rows summing to 500500, whatever the join count -- +-- while still being two different columns as far as the planner is concerned. +-- +-- The distribution key is deliberately never the whole story: a table +-- distributed by own is joined on nxt, and the ones distributed by payload are +-- joined on both. Every join therefore redistributes or broadcasts one side, +-- and the producer ends up in a different slice from its consumer. +-- +-- t1 is the smallest and every later table contains it, so the chain returns +-- t1 whatever order the planner picks. The widening sizes give it a reason to +-- hash the joined result rather than the next table, which is the shape that +-- exercises the descent through a join. +CREATE TABLE anser_mj_t1 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t2 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t3 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t4 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t5 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t6 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t7 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t8 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t9 (own int, nxt int, payload text) DISTRIBUTED BY (own); +CREATE TABLE anser_mj_t10 (own int, nxt int, payload text) DISTRIBUTED BY (payload); +CREATE TABLE anser_mj_t11 (own int, nxt int, payload text) DISTRIBUTED BY (own); + +INSERT INTO anser_mj_t1 SELECT g, g, 't1_' || g FROM generate_series(1, 1000) g; +INSERT INTO anser_mj_t2 SELECT g, g, 't2_' || g FROM generate_series(1, 2000) g; +INSERT INTO anser_mj_t3 SELECT g, g, 't3_' || g FROM generate_series(1, 3000) g; +INSERT INTO anser_mj_t4 SELECT g, g, 't4_' || g FROM generate_series(1, 4000) g; +INSERT INTO anser_mj_t5 SELECT g, g, 't5_' || g FROM generate_series(1, 5000) g; +INSERT INTO anser_mj_t6 SELECT g, g, 't6_' || g FROM generate_series(1, 6000) g; +INSERT INTO anser_mj_t7 SELECT g, g, 't7_' || g FROM generate_series(1, 7000) g; +INSERT INTO anser_mj_t8 SELECT g, g, 't8_' || g FROM generate_series(1, 8000) g; +INSERT INTO anser_mj_t9 SELECT g, g, 't9_' || g FROM generate_series(1, 9000) g; +INSERT INTO anser_mj_t10 SELECT g, g, 't10_' || g FROM generate_series(1, 10000) g; +INSERT INTO anser_mj_t11 SELECT g, g, 't11_' || g FROM generate_series(1, 11000) g; + +ANALYZE anser_mj_t1; +ANALYZE anser_mj_t2; +ANALYZE anser_mj_t3; +ANALYZE anser_mj_t4; +ANALYZE anser_mj_t5; +ANALYZE anser_mj_t6; +ANALYZE anser_mj_t7; +ANALYZE anser_mj_t8; +ANALYZE anser_mj_t9; +ANALYZE anser_mj_t10; +ANALYZE anser_mj_t11; + +-- Runs one chain twice, with the filter on and off, and reports whether the two +-- agree. The checksum is what makes the comparison mean something: equal row +-- counts would also come out of two different sets of rows, and a filter that +-- wrongly rejects is exactly the failure that changes which rows come back. +CREATE FUNCTION anser_mj_check(njoins int) +RETURNS TABLE (joins int, nrows bigint, own_sum bigint, agrees boolean) +LANGUAGE plpgsql AS $$ +DECLARE + sql text; + i int; + on_rows bigint; + on_sum bigint; + off_rows bigint; + off_sum bigint; +BEGIN + sql := 'SELECT count(*), coalesce(sum(anser_mj_t1.own), 0) FROM anser_mj_t1'; + FOR i IN 2 .. njoins + 1 LOOP + sql := sql || format(' JOIN anser_mj_t%s ON anser_mj_t%s.nxt = anser_mj_t%s.own', + i, i - 1, i); + END LOOP; + PERFORM set_config('anser.runtime_filter', 'on', false); + EXECUTE sql INTO on_rows, on_sum; + PERFORM set_config('anser.runtime_filter', 'off', false); + EXECUTE sql INTO off_rows, off_sum; + RETURN QUERY SELECT njoins, on_rows, on_sum, + (on_rows = off_rows AND on_sum = off_sum); +END; +$$; + +-- 2 through 10 joins, Postgres planner. Expected: no rows. +SET optimizer = off; +SELECT joins, nrows, own_sum, agrees + FROM generate_series(2, 10) g, anser_mj_check(g) + WHERE NOT agrees OR nrows <> 1000 OR own_sum <> 500500 + ORDER BY joins; + +-- The same under ORCA, which picks different join orders and slice widths. +SET optimizer = on; +SELECT joins, nrows, own_sum, agrees + FROM generate_series(2, 10) g, anser_mj_check(g) + WHERE NOT agrees OR nrows <> 1000 OR own_sum <> 500500 + ORDER BY joins; + +SET optimizer = off; + +-- A chain whose middle join is an outer join, and whose next join reads the +-- nullable side's key. The joined result there holds NULLs the base relation +-- does not, and a NULL must not be taken for a key the filter is missing. +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_mj_lj_on AS + SELECT a.own AS aown, c.own AS cown, d.own AS down + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + LEFT JOIN anser_mj_t3 c ON b.nxt = c.own + JOIN anser_mj_t4 d ON c.nxt = d.own + DISTRIBUTED BY (aown); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_mj_lj_off AS + SELECT a.own AS aown, c.own AS cown, d.own AS down + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + LEFT JOIN anser_mj_t3 c ON b.nxt = c.own + JOIN anser_mj_t4 d ON c.nxt = d.own + DISTRIBUTED BY (aown); + +SELECT count(*) AS lj_rows FROM anser_mj_lj_on; +SELECT count(*) AS lj_only_on + FROM (SELECT * FROM anser_mj_lj_on EXCEPT ALL SELECT * FROM anser_mj_lj_off) d; +SELECT count(*) AS lj_only_off + FROM (SELECT * FROM anser_mj_lj_off EXCEPT ALL SELECT * FROM anser_mj_lj_on) d; + +-- A star rather than a chain: every join reads t1's key, so one table's +-- distribution serves them all and the build sides are base scans throughout. +-- This is the shape that worked before the descent went through joins, and it +-- has to keep working. +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_mj_star_on AS + SELECT a.own AS aown, e.payload AS epayload + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + JOIN anser_mj_t3 c ON a.nxt = c.own + JOIN anser_mj_t4 d ON a.nxt = d.own + JOIN anser_mj_t5 e ON a.nxt = e.own + DISTRIBUTED BY (aown); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_mj_star_off AS + SELECT a.own AS aown, e.payload AS epayload + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + JOIN anser_mj_t3 c ON a.nxt = c.own + JOIN anser_mj_t4 d ON a.nxt = d.own + JOIN anser_mj_t5 e ON a.nxt = e.own + DISTRIBUTED BY (aown); + +SELECT count(*) AS star_rows FROM anser_mj_star_on; +SELECT count(*) AS star_only_on + FROM (SELECT * FROM anser_mj_star_on EXCEPT ALL SELECT * FROM anser_mj_star_off) d; +SELECT count(*) AS star_only_off + FROM (SELECT * FROM anser_mj_star_off EXCEPT ALL SELECT * FROM anser_mj_star_on) d; + +-- A chain that returns nothing. A filter that wrongly accepts is only slow, +-- never wrong, so this is the one direction an empty result can still check. +SET anser.runtime_filter = on; +SELECT count(*) AS empty_rows + FROM anser_mj_t1 a + JOIN anser_mj_t2 b ON a.nxt = b.own + JOIN anser_mj_t3 c ON b.nxt = c.own + WHERE a.own < 0; + +-- The chains above assert that the filter changes no answer. That also holds +-- when the filter never arrives and every consumer fails open, which is how a +-- broken exchange passes a green test. This asserts the other half: a chain +-- whose third table is three times the size of the first has rows to prune, so +-- a filter that arrived removes some. +CREATE FUNCTION anser_mj_pruned(q text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + removed bigint := 0; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) ' || q LOOP + IF line ~ 'Rows Removed by (Bloom Filter|Pushdown Runtime Filter): ' THEN + removed := removed + coalesce(substring(line from ': *([0-9]+)')::bigint, 0); + END IF; + END LOOP; + RETURN removed; +END; +$$; +SET anser.runtime_filter = on; +SELECT 'the chain pruned nothing: no filter arrived, or none was injected' AS problem + WHERE anser_mj_pruned('SELECT count(*) FROM anser_mj_t1 a JOIN anser_mj_t2 b ON a.nxt = b.own JOIN anser_mj_t3 c ON b.nxt = c.own') = 0; +DROP FUNCTION anser_mj_pruned(text); + +DROP FUNCTION anser_mj_check(int); +DROP TABLE anser_mj_t1, anser_mj_t2, anser_mj_t3, anser_mj_t4, anser_mj_t5, + anser_mj_t6, anser_mj_t7, anser_mj_t8, anser_mj_t9, anser_mj_t10, + anser_mj_t11; + +RESET anser.runtime_filter; +RESET optimizer; +RESET enable_nestloop; +RESET enable_mergejoin; diff --git a/gpcontrib/anser/sql/anser_runtime_filter.sql b/gpcontrib/anser/sql/anser_runtime_filter.sql new file mode 100644 index 00000000000..9fd2f5d2bc9 --- /dev/null +++ b/gpcontrib/anser/sql/anser_runtime_filter.sql @@ -0,0 +1,558 @@ +-- Anser runtime bloom filter: plan-tree integration (PR4). +-- +-- Requires the cluster to run with anser.enable=on so the gather/send +-- services are live. On a multi-segment cluster the build side redistributes, +-- exercising the Motion-directly-under-CustomScan case; on a single segment it +-- degrades to the leaf case. Either way the feature must (a) inject the +-- producer/consumer nodes and (b) never change query results. + + +-- Deterministic plan shape: force a hash join. +SET enable_nestloop = off; +SET enable_mergejoin = off; + +-- A regress test must not inherit the production deadline. anser.timeout_ms +-- defaults to 100 s because delivery is serial and a wide slice needs that +-- long to be served; a test that waits it out has turned a delivery failure +-- into a slow pass. Pinned short here, so a filter that never arrives fails +-- the run instead of hiding in the clock. +SET anser.timeout_ms = 5000; + +-- build is the smaller (hashed / preserved) side, distributed so the join key +-- must be redistributed; probe is the larger side distributed by the join key. +CREATE TABLE anser_rf_build (id int, name text) DISTRIBUTED BY (name); +CREATE TABLE anser_rf_probe (id int, payload text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_build SELECT g, 'b' || g FROM generate_series(1, 200) g; +INSERT INTO anser_rf_probe SELECT g, 'p' || g FROM generate_series(1, 2000) g; +ANALYZE anser_rf_build; +ANALYZE anser_rf_probe; + +-- Use the Postgres planner for a deterministic plan shape. +SET optimizer = off; + +-- With the filter on the plan carries an "Anser Bloom Producer" under the Hash +-- and an "Anser Bloom Consumer" above the probe scan (with the planned bloom +-- size); with it off neither node appears. (COSTS OFF keeps the output stable.) +SET anser.runtime_filter = on; +EXPLAIN (COSTS OFF) +SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id; + +SET anser.runtime_filter = off; +EXPLAIN (COSTS OFF) +SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id; + +-- Correctness: identical results with the filter on vs off (Postgres planner). +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_r_on AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_r_off AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); + +SELECT count(*) AS rows_on FROM anser_rf_r_on; +SELECT count(*) AS rows_off FROM anser_rf_r_off; +SELECT count(*) AS only_on + FROM (SELECT * FROM anser_rf_r_on EXCEPT ALL SELECT * FROM anser_rf_r_off) d; +SELECT count(*) AS only_off + FROM (SELECT * FROM anser_rf_r_off EXCEPT ALL SELECT * FROM anser_rf_r_on) d; + +-- Correctness must also hold under ORCA (whether or not it injects the nodes). +SET optimizer = on; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_r_orca AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS only_orca + FROM (SELECT * FROM anser_rf_r_orca EXCEPT ALL SELECT * FROM anser_rf_r_off) d; +SELECT count(*) AS only_off_orca + FROM (SELECT * FROM anser_rf_r_off EXCEPT ALL SELECT * FROM anser_rf_r_orca) d; + +-- Pushdown mix: with gp_enable_runtime_filter_pushdown on, the consumer hands +-- the unioned filter to the probe SeqScan as an SK_BLOOM_FILTER scan key and +-- the scan (or the table AM) does the pruning. Results must still match the +-- filter-off run. +SET optimizer = off; +SET gp_enable_runtime_filter_pushdown = on; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_r_push AS + SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id + DISTRIBUTED BY (name); +SELECT count(*) AS only_push + FROM (SELECT * FROM anser_rf_r_push EXCEPT ALL SELECT * FROM anser_rf_r_off) d; +SELECT count(*) AS only_off_push + FROM (SELECT * FROM anser_rf_r_off EXCEPT ALL SELECT * FROM anser_rf_r_push) d; +RESET gp_enable_runtime_filter_pushdown; + +-- Pushdown where the key's table attribute number and its position in the +-- scan's output targetlist disagree. The case above cannot see the +-- difference: anser_rf_probe's key is its first column and also the first +-- thing the scan emits, so the two numbers coincide. Here the key is the +-- probe table's *second* column and its *only* projected one, so a scan key +-- carrying the output position makes the scan test `payload` against a filter +-- built on `id` -- and since no payload is ever a build key, every row is +-- discarded. Both counts must be 200. +SET optimizer = on; +CREATE TABLE anser_rf_proj_build (id int, pad text) DISTRIBUTED BY (pad); +CREATE TABLE anser_rf_proj_probe (payload int, id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_proj_build SELECT g, 'b' || g FROM generate_series(1, 200) g; +INSERT INTO anser_rf_proj_probe SELECT -g, g FROM generate_series(1, 2000) g; +ANALYZE anser_rf_proj_build; +ANALYZE anser_rf_proj_probe; +SET gp_enable_runtime_filter_pushdown = on; +SET anser.runtime_filter = off; +SELECT count(p.id) AS proj_push_off + FROM anser_rf_proj_build b LEFT JOIN anser_rf_proj_probe p ON b.id = p.id; +SET anser.runtime_filter = on; +SELECT count(p.id) AS proj_push_on + FROM anser_rf_proj_build b LEFT JOIN anser_rf_proj_probe p ON b.id = p.id; + +-- The alias is not the column. This subquery renames `payload` to the name +-- `id` and the real `id` to `id2`, and joins on id2 -- so resolving the key by +-- name would land on `payload`, the one column guaranteed never to match a +-- filter built from the build side. Resolution runs position -> TargetEntry +-- -> Var -> varattno and never reads a name, so the rename is invisible to it. +-- Both counts must be 200. +SET anser.runtime_filter = off; +SELECT count(p.id2) AS rename_push_off + FROM anser_rf_proj_build b + LEFT JOIN (SELECT payload AS id, id AS id2 FROM anser_rf_proj_probe) p + ON b.id = p.id2; +SET anser.runtime_filter = on; +SELECT count(p.id2) AS rename_push_on + FROM anser_rf_proj_build b + LEFT JOIN (SELECT payload AS id, id AS id2 FROM anser_rf_proj_probe) p + ON b.id = p.id2; + +-- A computed key has no table attribute number to push down: the scan would +-- have to evaluate `id * 2`, which PassByBloomFilter cannot do. Note the join +-- clause itself still looks like a plain Var to the planner (it references the +-- scan's output position), so the key resolves and the nodes are injected -- +-- it is the scan's own targetlist entry that is an expression. The consumer +-- must decline the pushdown and probe the projected slot itself. Only the +-- 100 even build ids match, so both counts must be 100. +SET anser.runtime_filter = off; +SELECT count(p.k) AS expr_push_off + FROM anser_rf_proj_build b + LEFT JOIN (SELECT id * 2 AS k FROM anser_rf_proj_probe) p ON b.id = p.k; +SET anser.runtime_filter = on; +SELECT count(p.k) AS expr_push_on + FROM anser_rf_proj_build b + LEFT JOIN (SELECT id * 2 AS k FROM anser_rf_proj_probe) p ON b.id = p.k; +RESET gp_enable_runtime_filter_pushdown; +DROP TABLE anser_rf_proj_build, anser_rf_proj_probe; +SET optimizer = off; + + +-- Everything above checks that the nodes were injected and that the results did +-- not change. Both of those also hold when the filter never arrives and every +-- consumer fails open -- which is how a broken exchange passes a green test. +-- This is the assertion that the filter was used: the probe side holds 2000 +-- rows of which 200 join, so a filter that arrived removes the rest. Only the +-- Postgres planner is asserted, because its plan shape here is fixed; ORCA +-- chooses and the count would not be a stable thing to require. +CREATE FUNCTION anser_rf_pruned(q text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + removed bigint := 0; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) ' || q LOOP + IF line ~ 'Rows Removed by (Bloom Filter|Pushdown Runtime Filter): ' THEN + removed := removed + coalesce(substring(line from ': *([0-9]+)')::bigint, 0); + END IF; + END LOOP; + RETURN removed; +END; +$$; +SET optimizer = off; +SET anser.runtime_filter = on; +SELECT 'the filter pruned nothing: it never arrived, or it arrived empty' AS problem + WHERE anser_rf_pruned('SELECT b.name, p.payload FROM anser_rf_build b LEFT JOIN anser_rf_probe p ON b.id = p.id') = 0; +-- Nested statement guard: a statement run through SPI from a function body +-- goes through its whole Start/Run/End inside the *outer* query's Run, so the +-- inner ExecutorEnd fires while the outer query is still executing. Dropping +-- channels there takes the running query's filters with it, and every consumer +-- that has not read yet fails open -- the exchange still happens, the filter is +-- just never used. +-- +-- This needs an inner join, not the left join above: the consumer defers its +-- first tuple only for INNER (see defer_first), which is what lets the join +-- emit a row -- and so evaluate the function -- before the consumer reads. +CREATE FUNCTION anser_rf_nested(x int) RETURNS int +LANGUAGE plpgsql VOLATILE AS $$ +BEGIN + PERFORM 1; + RETURN x; +END; +$$; +SELECT 'an inner statement dropped the running query''s filter' AS problem + WHERE anser_rf_pruned('SELECT anser_rf_nested(p.id) FROM anser_rf_build b + JOIN anser_rf_probe p ON b.id = p.id') = 0; +DROP FUNCTION anser_rf_nested(int); +DROP FUNCTION anser_rf_pruned(text); + +DROP TABLE anser_rf_r_push; +DROP TABLE anser_rf_r_on, anser_rf_r_off, anser_rf_r_orca; +DROP TABLE anser_rf_build, anser_rf_probe; + +-- Datatype guard: producer and consumer hash the raw Datum bytes, so injection +-- is restricted to keys where SQL equality is bitwise Datum equality. A +-- cross-type join (float4 vs float8 Datums for equal values differ) must not +-- be injected; results must match the filter-off run either way. +CREATE TABLE anser_rf_build_f (id float8, name text) DISTRIBUTED BY (name); +CREATE TABLE anser_rf_probe_f (id float4, payload text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_build_f SELECT g, 'b' || g FROM generate_series(1, 200) g; +INSERT INTO anser_rf_probe_f SELECT g, 'p' || g FROM generate_series(1, 2000) g; +ANALYZE anser_rf_build_f; +ANALYZE anser_rf_probe_f; + +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_f_on AS + SELECT b.name, p.payload FROM anser_rf_build_f b JOIN anser_rf_probe_f p ON b.id = p.id + DISTRIBUTED BY (name); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_f_off AS + SELECT b.name, p.payload FROM anser_rf_build_f b JOIN anser_rf_probe_f p ON b.id = p.id + DISTRIBUTED BY (name); + +SELECT count(*) AS rows_on FROM anser_rf_f_on; +SELECT count(*) AS only_on + FROM (SELECT * FROM anser_rf_f_on EXCEPT ALL SELECT * FROM anser_rf_f_off) d; +SELECT count(*) AS only_off + FROM (SELECT * FROM anser_rf_f_off EXCEPT ALL SELECT * FROM anser_rf_f_on) d; + +DROP TABLE anser_rf_f_on, anser_rf_f_off, anser_rf_build_f, anser_rf_probe_f; + +-- Same-typed float keys are also excluded: -0.0 and 0.0 compare equal in SQL +-- but are not bitwise equal, so hashing raw Datums could prune a joinable row. +CREATE TABLE anser_rf_build_z (id float8, name text) DISTRIBUTED BY (name); +CREATE TABLE anser_rf_probe_z (id float8, payload text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_build_z VALUES (1, 'b1'), (-0.0, 'bz'); +INSERT INTO anser_rf_probe_z VALUES (1, 'p1'), (0.0, 'pz'), (2, 'p2'); +ANALYZE anser_rf_build_z; +ANALYZE anser_rf_probe_z; + +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_z_on AS + SELECT b.name, p.payload FROM anser_rf_build_z b JOIN anser_rf_probe_z p ON b.id = p.id + DISTRIBUTED BY (name); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_z_off AS + SELECT b.name, p.payload FROM anser_rf_build_z b JOIN anser_rf_probe_z p ON b.id = p.id + DISTRIBUTED BY (name); + +SELECT count(*) AS rows_on FROM anser_rf_z_on; +SELECT count(*) AS only_on + FROM (SELECT * FROM anser_rf_z_on EXCEPT ALL SELECT * FROM anser_rf_z_off) d; +SELECT count(*) AS only_off + FROM (SELECT * FROM anser_rf_z_off EXCEPT ALL SELECT * FROM anser_rf_z_on) d; + +DROP TABLE anser_rf_z_on, anser_rf_z_off, anser_rf_build_z, anser_rf_probe_z; + +-- Parameterized rescan guard: a build side whose rows depend on a parameter an +-- enclosing nested loop reassigns yields a different key set per outer tuple, +-- and neither node rebuilds on rescan -- the producer stays published, the +-- consumer keeps the filter it already has. The second iteration would be +-- probed against the first iteration's keys, dropping every row that joins. +-- So the plan pass must refuse the join outright. +-- +-- OFFSET 0 keeps the subquery from being pulled up, so it stays a nested loop +-- with a correlated inner side. Each group's ids are disjoint, which is what +-- makes a stale filter visible: with the first group's filter still in place +-- the second group matches nothing. +CREATE TABLE anser_rf_nl_outer (g int) DISTRIBUTED REPLICATED; +CREATE TABLE anser_rf_nl_build (g int, id int) DISTRIBUTED BY (g); +CREATE TABLE anser_rf_nl_probe (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_nl_outer VALUES (1), (2); +INSERT INTO anser_rf_nl_build SELECT 1, g FROM generate_series(1, 100) g; +INSERT INTO anser_rf_nl_build SELECT 2, g FROM generate_series(101, 200) g; +INSERT INTO anser_rf_nl_probe SELECT g FROM generate_series(1, 20000) g; +ANALYZE anser_rf_nl_outer; +ANALYZE anser_rf_nl_build; +ANALYZE anser_rf_nl_probe; + +CREATE FUNCTION anser_rf_nodes(q text) RETURNS bigint +LANGUAGE plpgsql AS $$ +DECLARE + line text; + nodes bigint := 0; +BEGIN + FOR line IN EXECUTE 'EXPLAIN (COSTS OFF) ' || q LOOP + IF line ~ 'Anser Bloom' THEN + nodes := nodes + 1; + END IF; + END LOOP; + RETURN nodes; +END; +$$; + +-- Both groups must report 100, with the filter on and off alike, and the plan +-- must carry no Anser node at all. +SET anser.runtime_filter = off; +SELECT o.g, s.n AS nl_off +FROM anser_rf_nl_outer o +CROSS JOIN LATERAL (SELECT count(*) n FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = o.g OFFSET 0) s +ORDER BY o.g; +SET anser.runtime_filter = on; +SELECT o.g, s.n AS nl_on +FROM anser_rf_nl_outer o +CROSS JOIN LATERAL (SELECT count(*) n FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = o.g OFFSET 0) s +ORDER BY o.g; +SELECT anser_rf_nodes('SELECT o.g, s.n FROM anser_rf_nl_outer o +CROSS JOIN LATERAL (SELECT count(*) n FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = o.g OFFSET 0) s') AS nodes_in_parameterized_plan; + +-- The same two tables joined without the correlation are still injected into: +-- the guard must refuse a parameterized build side, not every build side. +SELECT anser_rf_nodes('SELECT count(*) FROM anser_rf_nl_probe p + JOIN anser_rf_nl_build b ON p.id = b.id + WHERE b.g = 1') AS nodes_when_not_parameterized; + +DROP FUNCTION anser_rf_nodes(text); +DROP TABLE anser_rf_nl_outer, anser_rf_nl_build, anser_rf_nl_probe; + +-- nextval guard: the QD sends two kinds of message down a QE's dispatch +-- connection -- the nextval reply and a sideband message -- and each has its +-- own reader. A reader that meets the other's message has to skip it whole: +-- taking the type byte and erroring leaves the body in the stream, and the +-- session then dies somewhere else with "protocol synchronization was lost". +-- +-- In the field this needs an Anser consumer to give up on a filter the +-- coordinator goes on to deliver anyway, which is a race and cannot be asked +-- for from SQL. The fault puts a sideband message in front of one nextval +-- reply instead, so the ordering is certain rather than likely; the +-- three-argument form is occurrence 1..1, so it fires once. +-- +-- Nothing here is Anser's -- the fault supplies the message and the fix is in +-- cdb_sequence_nextval_qe -- but Anser is why a second message type exists on +-- that connection at all, so the test lives with it. +-- +-- The extension may or may not already be there, and saying so is not part of +-- what is being tested; it is never dropped, for the same reason -- it can +-- predate this test, and other tests in the same database rely on it. +-- start_ignore +CREATE EXTENSION IF NOT EXISTS gp_inject_fault; +-- end_ignore +CREATE SEQUENCE anser_rf_seq; +CREATE TABLE anser_rf_seq_t (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_seq_t SELECT g FROM generate_series(1, 10) g; +SELECT gp_inject_fault('nextval_sideband_before_response', 'skip', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; +SELECT count(nextval('anser_rf_seq')) AS seq_rows FROM anser_rf_seq_t; +SELECT gp_inject_fault('nextval_sideband_before_response', 'reset', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; +DROP SEQUENCE anser_rf_seq; +DROP TABLE anser_rf_seq_t; + +-- Delivery to a consumer that stopped reading must not block the coordinator. +-- +-- A merged filter is 1-64 MB and the dispatch connections are blocking, so +-- pushing to a QE that is no longer reading used to wait in pqWait() with no +-- deadline and no CHECK_FOR_INTERRUPTS -- inside the dispatcher loop, which +-- then served nobody. Neither cancel nor statement_timeout could end it. +-- +-- The ordering cannot be arranged with data: the channel completes when the +-- last part lands, which is normally while the consumers are still waiting. +-- Holding the delivery back is what lets every consumer reach its deadline +-- and move on to its probe side -- and it is while they are busy there that +-- the coordinator finally writes to all of them. +-- +-- Note this one hangs rather than fails if the fix is reverted, for the same +-- reason the bug was unkillable. It is here because the alternative is a +-- regression nobody notices until a cluster wedges. +-- start_ignore +CREATE EXTENSION IF NOT EXISTS gp_inject_fault; +-- end_ignore +SET anser.timeout_ms = 1; +CREATE TABLE anser_rf_push_build (id int, pad text) DISTRIBUTED BY (pad); +CREATE TABLE anser_rf_push_probe (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_push_build SELECT g, 'b' || g FROM generate_series(1, 1000) g; +INSERT INTO anser_rf_push_probe SELECT g % 1000 + 1 FROM generate_series(1, 1000000) g; +ANALYZE anser_rf_push_build; +ANALYZE anser_rf_push_probe; + +-- One segment publishes a second late; every other consumer gives up at 1 ms. +SELECT gp_inject_fault('anser_before_deliver', 'sleep', '', '', '', 1, 1, 1, dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; +SET anser.runtime_filter = on; +SELECT count(*) AS push_rows + FROM anser_rf_push_probe p JOIN anser_rf_push_build b ON p.id = b.id; +SELECT gp_inject_fault('anser_before_deliver', 'reset', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; +DROP TABLE anser_rf_push_build, anser_rf_push_probe; + +-- A consumer that runs out of time withdraws, so the coordinator stops +-- counting it as a subscriber. Without that it would keep delivering to a +-- process that has moved on -- which is what the push above has to survive, +-- and what this keeps from quietly regressing into. +-- +-- The withdrawal leaves no trace a query can see: the channel is gone by the +-- time the query returns, and a filter that merely arrived late looks the +-- same. So the coordinator counts them, and this reads the difference. +CREATE TABLE anser_rf_unsub_build (id int, pad text) DISTRIBUTED BY (pad); +CREATE TABLE anser_rf_unsub_probe (id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_unsub_build SELECT g, 'b' || g FROM generate_series(1, 1000) g; +INSERT INTO anser_rf_unsub_probe SELECT g % 1000 + 1 FROM generate_series(1, 200000) g; +ANALYZE anser_rf_unsub_build; +ANALYZE anser_rf_unsub_probe; +-- start_ignore +CREATE EXTENSION IF NOT EXISTS anser_test; +-- end_ignore +SELECT anser_test_unsubscribes() AS unsub_before \gset +SELECT gp_inject_fault('anser_before_deliver', 'sleep', '', '', '', 1, 1, 1, dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; +SELECT count(*) AS unsub_rows + FROM anser_rf_unsub_probe p JOIN anser_rf_unsub_build b ON p.id = b.id; +SELECT gp_inject_fault('anser_before_deliver', 'reset', dbid) + FROM gp_segment_configuration WHERE role = 'p' AND content = -1; +SELECT anser_test_unsubscribes() > :unsub_before AS consumers_withdrew; +DROP EXTENSION anser_test; +DROP TABLE anser_rf_unsub_build, anser_rf_unsub_probe; +-- Both blocks above ran on a 1 ms deadline so the consumers would time out. +-- Restore the file's deadline: at 1 ms no filter ever arrives, which would +-- make every assertion below pass without exercising anything. +SET anser.timeout_ms = 5000; + + +-- --------------------------------------------------------------------------- +-- Parallel execution. +-- +-- A parallel slice runs numsegments * parallel_workers processes and every one +-- of them publishes its own part, so the coordinator must wait for that many +-- parts rather than for one per segment. Undercounting completes the channel +-- early and delivers a filter missing build keys; a bloom filter short of keys +-- gives false negatives, which silently drops joinable rows. So the assertion +-- that matters is not the plan shape but that results are *identical* with the +-- filter on and off -- at more than one worker count, and under both planners, +-- which disagree here: for one query the Postgres planner may give a serial +-- build slice where GPORCA gives a parallel one. +-- +-- The build side is distributed by v and joined on id, so it is redistributed: +-- Anser only injects across a Motion, and a co-located join -- which is what +-- this used to use -- is now skipped outright, making the whole section +-- vacuous. The cost is that only GPORCA still parallelises it; the Postgres +-- planner marks Motion paths parallel-unsafe, so its two blocks below are +-- reduced to a filter-on/filter-off equivalence check on a serial slice. +-- They are kept because that equivalence is worth asserting on its own. +-- --------------------------------------------------------------------------- +CREATE TABLE anser_rf_par_build (id int, v text) DISTRIBUTED BY (v); +CREATE TABLE anser_rf_par_probe (id int, pad text) DISTRIBUTED BY (id); +INSERT INTO anser_rf_par_build SELECT g, 'b' || g FROM generate_series(1, 500) g; +INSERT INTO anser_rf_par_probe SELECT g, 'p' || g FROM generate_series(1, 20000) g; +ANALYZE anser_rf_par_build; +ANALYZE anser_rf_par_probe; + +-- Force a parallel plan on tables this small, the way PostgreSQL's own tests +-- do; without this the scans are far too cheap to be worth splitting. +SET enable_parallel = on; +SET min_parallel_table_scan_size = 0; +SET parallel_setup_cost = 0; +SET parallel_tuple_cost = 0; + +-- Postgres planner, serial baseline +SET optimizer = off; +SET max_parallel_workers_per_gather = 0; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; +DROP TABLE anser_rf_par_on, anser_rf_par_off; + +-- Postgres planner, parallel +SET optimizer = off; +SET max_parallel_workers_per_gather = 4; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; +DROP TABLE anser_rf_par_on, anser_rf_par_off; + +-- GPORCA, serial baseline +SET optimizer = on; +SET max_parallel_workers_per_gather = 0; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; +DROP TABLE anser_rf_par_on, anser_rf_par_off; + +-- GPORCA, parallel +SET optimizer = on; +SET max_parallel_workers_per_gather = 4; +SET anser.runtime_filter = on; +CREATE TEMP TABLE anser_rf_par_on AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SET anser.runtime_filter = off; +CREATE TEMP TABLE anser_rf_par_off AS + SELECT b.v, p.pad FROM anser_rf_par_probe p + JOIN anser_rf_par_build b ON p.id = b.id + DISTRIBUTED BY (v); +SELECT (SELECT count(*) FROM anser_rf_par_on) AS rows_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_on + EXCEPT ALL + SELECT * FROM anser_rf_par_off) d) AS only_on, + (SELECT count(*) FROM (SELECT * FROM anser_rf_par_off + EXCEPT ALL + SELECT * FROM anser_rf_par_on) d) AS only_off; +DROP TABLE anser_rf_par_on, anser_rf_par_off; + +DROP TABLE anser_rf_par_build, anser_rf_par_probe; +RESET enable_parallel; +RESET min_parallel_table_scan_size; +RESET parallel_setup_cost; +RESET parallel_tuple_cost; +RESET max_parallel_workers_per_gather; + +RESET anser.runtime_filter; +RESET optimizer; +RESET enable_nestloop; +RESET enable_mergejoin; + diff --git a/gpcontrib/anser/sql/anser_test.sql b/gpcontrib/anser/sql/anser_test.sql new file mode 100644 index 00000000000..be937ae01cc --- /dev/null +++ b/gpcontrib/anser/sql/anser_test.sql @@ -0,0 +1,266 @@ +CREATE EXTENSION anser_test; + +-- Bloom payload protocol: serialize one part and read it back. +SELECT anser_test_bloom_roundtrip('bf_roundtrip', 42); + +-- In-place fold: same-size union mutates the accumulator; a mismatched size is +-- rejected. This is the coordinator's only combine path (the first part is +-- kept verbatim, every later part folds into it). +SELECT anser_test_bloom_fold_inplace() AS fold_inplace_ok; + +-- Safety regression: the consumer rebuilds the filter from its own parameters +-- and requires the received bitset to be exactly the expected size (and the +-- header magic to match); truncated/oversized/corrupt parts are rejected. +SELECT anser_test_bloom_rejects_mismatch() AS reject_mismatch; + +-- Producer and consumer driven end to end in one backend: publish a part, let +-- the coordinator side merge it, then receive and query the filter. +SELECT anser_test_node_roundtrip(168); + +-- --------------------------------------------------------------------------- +-- Sizing, and the decision not to bother. +-- +-- Anser can decline to build a filter at four points, and these cases walk the +-- boundary of each. Declining is always safe: it costs the query its filter, +-- never its correctness. The payload cap is pinned here because every size +-- below follows from it. +-- --------------------------------------------------------------------------- +SET anser.max_info_size = 68157440; + +-- 1. The planner's gate -- the only one that costs nothing, since nothing is +-- injected and no consumer is left waiting. A 64 MB bitset is 536870912 bits, +-- so 134217728 estimated keys is exactly 4 bits/key and one more key is not. +-- A zero or negative estimate is treated as one key, not as "no filter". +SELECT t.est_rows, s.injected, s.total_elems, s.planned_bytes +FROM (VALUES (0), (-5), (1), (1000), (1000000), (10000000), + (134217728), (134217729), (150000000), (1000000000)) + AS t(est_rows), + LATERAL anser_test_rf_size(t.est_rows) AS s; + +-- 2. Filter construction. bloom_create floors every bitset at 1 MB and that +-- floor overrides the work_mem cap, so a cap below "1 MB + header" can only +-- yield a filter too large to send -- which is why a flat 1 MB cap is refused +-- while 1048592 (the floor plus exactly one header) is accepted and serializes +-- to precisely the cap. fits_cap is the invariant that must never be false. +SELECT t.what, t.elems, t.cap, s.built, s.bits, s.serialized, + round(s.bits_per_key::numeric, 3) AS bits_per_key, + (NOT s.built OR s.serialized <= t.cap) AS fits_cap +FROM (VALUES + ('no keys', 0::bigint, 1048640::bigint), + ('negative keys', -1, 1048640), + ('cap equals the header', 32, 16), + ('cap one byte over the header', 32, 17), + ('cap is 1 MB, no header room', 32, 1048576), + ('cap is 1 MB plus a header', 32, 1048592), + ('cap one byte short of that', 32, 1048591), + ('a single key', 1, 1048640), + ('exactly 4 bits/key', 2097152, 1048640), + ('one key too many', 2097153, 1048640), + ('exactly 4 bits/key at 2 MB', 4194304, 2097216), + ('one key too many at 2 MB', 4194305, 2097216), + ('a billion keys', 1000000000, 68157440)) + AS t(what, elems, cap), + LATERAL anser_test_bloom_shape(t.elems, t.cap) AS s; + +-- 3. The coordinator's gate on a merged payload. The fill is fixed exactly by +-- setting whole bytes rather than by inserting keys, which would be both slow +-- and only statistically precise. The rule is "reject above 95%", so a payload +-- sitting exactly on the limit is still delivered. Malformed framing is not +-- worth delivering either, since it cannot be a filter at all. +SELECT t.what, t.bitset_bytes, t.bytes_set, t.damage, + anser_test_worth_delivering(t.bitset_bytes, t.bytes_set, t.damage) + AS worth_delivering +FROM (VALUES + ('empty', 1000, 0, ''), + ('half full', 1000, 500, ''), + ('just under the limit', 1000, 949, ''), + ('exactly on the limit', 1000, 950, ''), + ('just over the limit', 1000, 951, ''), + ('completely full', 1000, 1000, ''), + ('no bitset at all', 0, 0, ''), + ('corrupt magic', 1000, 0, 'magic'), + ('corrupt version', 1000, 0, 'version'), + ('zero part count', 1000, 0, 'parts'), + ('null payload', 1000, 0, 'null')) + AS t(what, bitset_bytes, bytes_set, damage); + +-- 4. The producer driven end to end on the coordinator-local path: build, +-- insert, publish, consume. "no-filter:cancelled" is the case that matters +-- most -- construction declined, so the cancel goes out before the build side +-- is scanned and no consumer waits for its timeout. The last row is the one +-- the planner cannot predict: a filter sized for 32 keys that receives three +-- million, which saturates and so publishes a cancel instead of a payload. +SELECT t.what, + anser_test_producer_decision(t.elems, t.cap, t.n_keys) AS decision +FROM (VALUES + ('sparse filter', 32::bigint, 1048640::bigint, 100), + ('cap with no header room', 32, 1048576, 100), + ('no keys declared', 0, 1048640, 0), + ('one key past the floor', 2097153, 1048640, 0), + ('exactly 4 bits/key', 2097152, 1048640, 100000), + ('estimate of 32, three million inserted', 32, 1048640, 3000000)) + AS t(what, elems, cap, n_keys); + +-- --------------------------------------------------------------------------- +-- The wire protocol. +-- +-- Not exhaustive; a fuzzer would be the right tool for that. These cover the +-- properties that are cheap to check and expensive to lose: a fixed-width +-- header with the fields where they are documented, a payload that cannot be +-- mistaken for framing, a length cross-check that rejects a message which does +-- not add up, and a checksum that rejects one altered byte anywhere it covers. +-- --------------------------------------------------------------------------- + +-- The bytes, exactly. Session/command/condition are 42/7/3 and part is 1 of 3, +-- so these are golden values: any change to the layout, the field widths or +-- what the checksum covers shows up here as a diff. Note the third row -- a +-- cancelled message carries no body even though one was passed, and its +-- checksum must be computed over what is actually sent. +-- +-- That the message comes back as text is itself the check that it is NUL-free, +-- which a NOTIFY payload has to be: the fourth row's body is 00 ff 00. +SELECT t.what, length(m.msg) AS len, m.msg +FROM (VALUES + ('part with a 3-byte body', 'P', 'B', 0, '\x616263'::bytea), + ('subscription, no body', 'S', '-', 0, '\x'::bytea), + ('cancelled: body suppressed', 'P', 'B', 1, '\x616263'::bytea), + ('body of NUL and 0xff bytes', 'P', 'B', 0, '\x00ff00'::bytea)) + AS t(what, kind, ptype, flags, body), + LATERAL (SELECT anser_test_wire_format(t.kind::"char", t.ptype::"char", + t.flags, 'anser_rf_3', t.body) + AS msg) m; + +-- Alter exactly one byte (or one length) and see what the reader does. +-- +-- The split matters: "malformed" is rejected by framing, before a byte is +-- allocated or a channel created, while "checksum-mismatch" got past framing +-- and was caught by the CRC. Corrupting the kind byte is the interesting one +-- -- the message stays perfectly well-formed, and only the checksum notices, +-- which is why the checksum covers the header and not just the payload. +SELECT t.tamper, + anser_test_wire_roundtrip('anser_rf_3', '\x616263'::bytea, t.tamper, + 'B'::"char") AS outcome +FROM (VALUES (''), ('truncate'), ('append'), ('bodylen'), ('tag'), ('kind'), + ('type'), ('crc'), ('key'), ('body')) AS t(tamper); + +-- Keys and bodies that must survive the trip unchanged. "ok" means every +-- field and the decoded body came back identical, so each of these is a case +-- where a naive framing would have gone wrong: the key travels as raw bytes +-- and is taken by length, which is what lets it contain a newline, a space, or +-- the protocol's own tag without any escaping. +SELECT t.what, + anser_test_wire_roundtrip(t.key, t.body, '', 'B'::"char") AS outcome +FROM (VALUES + ('plain key', 'anser_rf_3' , '\x616263'::bytea), + ('key containing a newline', E'rf:a\nb' , '\x616263'::bytea), + ('key that looks like a header', 'anser3 P B 0000000000', '\x616263'::bytea), + ('key with spaces and quotes', 'rf:"a b".c' , '\x616263'::bytea), + ('key at the 63-byte limit', repeat('k', 63) , '\x616263'::bytea), + ('empty key', '' , '\x616263'::bytea), + ('empty body', 'anser_rf_3' , '\x'::bytea), + ('body of a single NUL', 'anser_rf_3' , '\x00'::bytea), + ('body of framing-hostile bytes', 'anser_rf_3' , '\x00ff0a0d5c22'::bytea), + ('1 KB body', 'anser_rf_3' , decode(repeat('00ff', 512), 'hex'))) + AS t(what, key, body); + +-- The QD -> QE checksum. The value itself does not matter; which inputs +-- change it does. Every routing field and the key are always covered, and the +-- body only for a payload type that asks for it -- the fifth column is that +-- deliberate exemption, not an oversight. +SELECT + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 2, 0, 'k', '\x01') AS condition_covered, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 1, 1, 'k', '\x01') AS flags_covered, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 1, 0, 'j', '\x01') AS key_covered, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('B'::"char", 1, 0, 'k', '\x02') AS bloom_body_covered, + anser_test_push_crc('-'::"char", 1, 0, 'k', '\x01') + = anser_test_push_crc('-'::"char", 1, 0, 'k', '\x02') AS other_body_exempt, + anser_test_push_crc('B'::"char", 1, 0, 'k', '\x01') + <> anser_test_push_crc('-'::"char", 1, 0, 'k', '\x01') AS type_covered; + +-- 5. The runtime-filter pushdown decision. A scan key is numbered by the +-- table's attributes, while the consumer is numbered by the scan's output +-- targetlist -- the slot it probes has already been projected. So the whole +-- job of the decision is that mapping, plus refusing every shape where there +-- is no mapping to make. sk_attno is what the key would carry; NULL means +-- refused, and the consumer then probes the slot itself (always correct, just +-- slower). In the tlist spec a positive value is a plain Var on that table +-- attno; 0 is a whole-row Var, -1 a system column, -2 a computed column, -3 a +-- Var of another relation, -4 a cast wrapping a Var. +SELECT t.what, t.tlist, t.attno, s.accepts, s.sk_attno +FROM (VALUES + ('all columns in order', '{1,2,3}'::int4[], 2, true, true), + ('projecting scan, one column', '{2}', 1, true, true), + ('reordered, first of two', '{3,1}', 1, true, true), + ('reordered, second of two', '{3,1}', 2, true, true), + ('position past the targetlist', '{2}', 2, true, true), + ('position zero', '{2}', 0, true, true), + ('negative position', '{2}', -1, true, true), + ('computed column', '{-2}', 1, true, true), + ('cast wrapping a column', '{-4}', 1, true, true), + ('whole-row reference', '{0}', 1, true, true), + ('system column', '{-1}', 1, true, true), + ('column of another relation', '{-3}', 1, true, true), + ('filter_in_seqscan off', '{2}', 1, false, true), + ('not a SeqScan', '{2}', 1, true, false) + ) AS t(what, tlist, attno, filter_in_seqscan, as_seqscan), + LATERAL anser_test_pushdown_accepts(t.tlist, t.attno, t.filter_in_seqscan, + t.as_seqscan) AS s; + +-- 6. The walk that finds where to push. With nothing to descend through it +-- must agree with the decision and name the node itself; a node that refuses +-- yields no target at all rather than a target with a meaningless attno. The +-- descent through stacked nodes needs live CustomScanStates, so it is covered +-- end to end by anser_runtime_filter instead. +SELECT t.what, s.found_self, s.sk_attno +FROM (VALUES + ('accepting scan is its own target', '{2}'::int4[], 1, true), + ('refused scan has no target', '{-2}', 1, true), + ('non-scan has no target', '{2}', 1, false) + ) AS t(what, tlist, attno, as_seqscan), + LATERAL anser_test_pushdown_target(t.tlist, t.attno, true, + t.as_seqscan) AS s; + +-- 7. How many producers a slice runs. This number is the channel's +-- completion criterion, so over-counting costs every consumer the full +-- anser.timeout_ms -- the channel never reaches the count it waits for and +-- nobody is told -- while under-counting completes it early and hands out a +-- filter missing a segment's keys, which drops rows that join. So each of +-- FillSliceGangInfo's four formulas is pinned here, not inferred from one. +-- +-- numsegments is only meaningful for a reader or writer gang; the columns are +-- still filled for the other types to show they are ignored. +SELECT t.what, t.gang, t.nseg, t.workers, t.direct, + anser_test_slice_producers(t.gang, t.nseg, t.workers, t.direct, 0) + AS producers +FROM (VALUES + ('coordinator slice', 'unallocated', 3, 0, NULL::int4[]), + ('coordinator slice, parallel', 'unallocated', 3, 4, NULL), + ('entry db reader', 'entrydb', 3, 0, NULL), + ('singleton reader', 'singleton', 3, 0, NULL), + ('singleton reader, parallel', 'singleton', 3, 4, NULL), + ('reader, serial', 'reader', 3, 0, NULL), + ('reader, parallel', 'reader', 3, 4, NULL), + ('writer, serial', 'writer', 3, 0, NULL), + ('direct dispatch, one segment', 'reader', 3, 0, '{1}'), + ('direct dispatch, two, serial', 'reader', 3, 0, '{0,2}'), + ('direct dispatch, two, par 4', 'reader', 3, 4, '{0,2}'), + ('direct dispatch, nowhere', 'reader', 3, 4, '{}'), + ('no segments', 'reader', 0, 4, NULL), + ('negative segments', 'reader', -1, 4, NULL) + ) AS t(what, gang, nseg, workers, direct); + +-- The slice has to be identifiable at all; anything else is zero, which the +-- injection pass reads as "unknown" and declines on. +SELECT t.what, anser_test_slice_producers('reader', 3, 4, NULL, t.idx) + AS producers +FROM (VALUES + ('slice 0 of 1 exists', 0), + ('slice 1 of 1 does not', 1), + ('negative slice index', -1) + ) AS t(what, idx); + +DROP EXTENSION anser_test; diff --git a/gpcontrib/anser/src/anser_test.c b/gpcontrib/anser/src/anser_test.c new file mode 100644 index 00000000000..afd4886cc6f --- /dev/null +++ b/gpcontrib/anser/src/anser_test.c @@ -0,0 +1,1058 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anser_test.c + * SQL-callable test helpers for the Anser subsystem. + * + * IDENTIFICATION + * gpcontrib/anser/src/anser_test.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/htup_details.h" +#include "catalog/pg_type.h" +#include "anser.h" +#include "anserbloom.h" +#include "anserfilter.h" +#include "anserpayload.h" +#include "anserplan.h" +#include "ansersideband.h" +#include "cdb/cdbvars.h" +#include "common/base64.h" +#include "fmgr.h" +#include "funcapi.h" +#include "lib/bloomfilter.h" +#include "nodes/makefuncs.h" +#include "miscadmin.h" +#include "utils/array.h" +#include "utils/builtins.h" +#include "varatt.h" + +/* + * Bloom sizing used by the test helpers. bloom_create floors every filter at + * 1 MB, so these are the smallest filters we can build; producer and consumer + * sides must pass the identical pair (that is the whole point of carrying the + * parameters in the node rather than on the wire). + */ +#define ANSER_TEST_ELEMS 32 + +/* + * bloom_create floors every bitset at 1 MB, so a payload cap must be 1 MB + * *plus* room for the serialized-part header -- the same allowance the planner + * makes (ANSER_RF_HEADER_ROOM). Passing a flat 1 MB asks AnserBloomCreate for + * a filter that cannot fit the cap it was given, and it now declines. + */ +#define ANSER_TEST_MAX_PAYLOAD (1024 * 1024 + 64) + +PG_FUNCTION_INFO_V1(anser_test_bloom_roundtrip); +PG_FUNCTION_INFO_V1(anser_test_bloom_fold_inplace); +PG_FUNCTION_INFO_V1(anser_test_bloom_rejects_mismatch); +PG_FUNCTION_INFO_V1(anser_test_node_roundtrip); + + +Datum +anser_test_bloom_roundtrip(PG_FUNCTION_ARGS) +{ + char *key = text_to_cstring(PG_GETARG_TEXT_PP(0)); + int32 value_arg = PG_GETARG_INT32(1); + Datum value = Int32GetDatum(value_arg); + uint64 seed = AnserBloomSeed(key); + bloom_filter *filter; + bloom_filter *roundtrip; + char *payload; + Size payload_size; + Size payload_len = 0; + uint32 part_index = 0; + uint32 total_parts = 0; + bool lacks; + + filter = AnserBloomCreate(ANSER_TEST_ELEMS, ANSER_TEST_MAX_PAYLOAD, seed); + if (filter == NULL) + PG_RETURN_BOOL(false); + + bloom_add_element(filter, (unsigned char *) &value, sizeof(Datum)); + payload_size = AnserBloomSerializedSize(filter); + payload = palloc(payload_size); + if (!AnserBloomSerializePart(filter, 0, 1, payload, payload_size, + &payload_len)) + PG_RETURN_BOOL(false); + + roundtrip = AnserBloomDeserializePart(payload, payload_len, + 32, 1024 * 1024, seed, + &part_index, &total_parts); + if (roundtrip == NULL) + PG_RETURN_BOOL(false); + + lacks = bloom_lacks_element(roundtrip, (unsigned char *) &value, + sizeof(Datum)); + bloom_free(filter); + bloom_free(roundtrip); + PG_RETURN_BOOL(!lacks && part_index == 0 && total_parts == 1); +} + +/* + * In-place fold: folding an equally-sized part into a merged part is a bitwise + * OR of the bitset plus a fold-count bump, mutating the buffer without realloc. + * This is the coordinator's only combine path: the first part is stored + * verbatim, every later part folds in here. A differently-sized part is + * rejected and leaves the accumulator untouched. + */ +Datum +anser_test_bloom_fold_inplace(PG_FUNCTION_ARGS) +{ + uint64 seed = AnserBloomSeed("inplace_bloom"); + bloom_filter *left; + bloom_filter *right; + bloom_filter *big; + bloom_filter *merged; + Datum left_value = Int32GetDatum(7); + Datum right_value = Int32GetDatum(9); + char *acc; + char *part; + char *big_part; + Size acc_size; + Size part_size; + Size big_size; + Size acc_len = 0; + Size part_len = 0; + Size big_len = 0; + uint32 part_index = 0; + uint32 total_parts = 0; + uint32 tp_before = 0; + uint32 tp_after = 0; + bool same_ok; + bool mismatch_rejected; + + /* Two same-parameter parts: acc is the running merged part, part folds in. */ + left = AnserBloomCreate(ANSER_TEST_ELEMS, ANSER_TEST_MAX_PAYLOAD, seed); + right = AnserBloomCreate(ANSER_TEST_ELEMS, ANSER_TEST_MAX_PAYLOAD, seed); + if (left == NULL || right == NULL) + PG_RETURN_BOOL(false); + bloom_add_element(left, (unsigned char *) &left_value, sizeof(Datum)); + bloom_add_element(right, (unsigned char *) &right_value, sizeof(Datum)); + acc_size = AnserBloomSerializedSize(left); + part_size = AnserBloomSerializedSize(right); + acc = palloc(acc_size); + part = palloc(part_size); + if (!AnserBloomSerializePart(left, 0, 1, acc, acc_size, &acc_len) || + !AnserBloomSerializePart(right, 0, 1, part, part_size, &part_len)) + { + bloom_free(left); + bloom_free(right); + PG_RETURN_BOOL(false); + } + bloom_free(left); + bloom_free(right); + + same_ok = AnserBloomFoldPartInPlace(acc, acc_len, part, part_len); + merged = same_ok ? + AnserBloomDeserializePart(acc, acc_len, 32, 1024 * 1024, seed, + &part_index, &total_parts) : NULL; + same_ok = same_ok && + acc_len == acc_size && /* size unchanged, folded in place */ + merged != NULL && + part_index == 0 && + total_parts == 2 && /* one more part folded */ + !bloom_lacks_element(merged, (unsigned char *) &left_value, + sizeof(Datum)) && + !bloom_lacks_element(merged, (unsigned char *) &right_value, + sizeof(Datum)); + if (merged != NULL) + bloom_free(merged); + + /* + * A differently-sized part must be rejected and leave acc untouched. Since + * bloom_create floors every filter at 1 MB, we need a genuinely larger + * cardinality/budget to get a bigger (2 MB) bitset than the 1 MB acc. + */ + big = AnserBloomCreate(1500000, 4 * 1024 * 1024, seed); + if (big == NULL) + PG_RETURN_BOOL(false); + big_size = AnserBloomSerializedSize(big); + big_part = palloc(big_size); + if (!AnserBloomSerializePart(big, 0, 1, big_part, big_size, &big_len)) + { + bloom_free(big); + PG_RETURN_BOOL(false); + } + bloom_free(big); + + tp_before = ((const AnserBloomPartHeader *) acc)->total_parts; + mismatch_rejected = big_len != acc_len && + !AnserBloomFoldPartInPlace(acc, acc_len, big_part, big_len); + tp_after = ((const AnserBloomPartHeader *) acc)->total_parts; + mismatch_rejected = mismatch_rejected && tp_before == tp_after; + + PG_RETURN_BOOL(same_ok && mismatch_rejected); +} + +/* + * Safety regression for the size/format check in AnserBloomDeserializePart. + * + * The consumer rebuilds the filter from its OWN (total_elems, max_payload, seed) + * parameters, then requires the received bitset to be exactly the size those + * parameters imply and the wire header to carry the expected magic. A + * well-formed part must load; a truncated one, an oversized one, and one with a + * corrupted magic must all be rejected (NULL) so the consumer fails open rather + * than loading a wrongly-shaped bitset. Returns true iff the good part loads and + * every bad one is rejected. + */ +Datum +anser_test_bloom_rejects_mismatch(PG_FUNCTION_ARGS) +{ + uint64 seed = AnserBloomSeed("reject_mismatch"); + bloom_filter *filter; + char *good; + Size good_size; + Size good_len = 0; + bloom_filter *ok_load; + bloom_filter *short_load; + bloom_filter *long_load; + bloom_filter *magic_load; + AnserBloomPartHeader *hdr; + uint32 saved_magic; + bool ok; + + filter = AnserBloomCreate(ANSER_TEST_ELEMS, ANSER_TEST_MAX_PAYLOAD, seed); + if (filter == NULL) + PG_RETURN_BOOL(false); + + good_size = AnserBloomSerializedSize(filter); + good = palloc(good_size); + if (!AnserBloomSerializePart(filter, 0, 1, good, good_size, &good_len)) + { + bloom_free(filter); + PG_RETURN_BOOL(false); + } + bloom_free(filter); + + /* Well-formed: loads. */ + ok_load = AnserBloomDeserializePart(good, good_len, ANSER_TEST_ELEMS, + ANSER_TEST_MAX_PAYLOAD, seed, NULL, NULL); + + /* One byte short of the expected bitset: rejected. */ + short_load = AnserBloomDeserializePart(good, good_len - 1, ANSER_TEST_ELEMS, + ANSER_TEST_MAX_PAYLOAD, seed, NULL, NULL); + + /* Claiming more bytes than the expected bitset: rejected. */ + long_load = AnserBloomDeserializePart(good, good_len + 1, ANSER_TEST_ELEMS, + ANSER_TEST_MAX_PAYLOAD, seed, NULL, NULL); + + /* Corrupted wire magic: rejected before the size check. */ + hdr = (AnserBloomPartHeader *) good; + saved_magic = hdr->magic; + hdr->magic = saved_magic ^ 0xFFFFFFFFU; + magic_load = AnserBloomDeserializePart(good, good_len, ANSER_TEST_ELEMS, + ANSER_TEST_MAX_PAYLOAD, seed, NULL, NULL); + hdr->magic = saved_magic; + + ok = ok_load != NULL && short_load == NULL && long_load == NULL && + magic_load == NULL; + + if (ok_load != NULL) + bloom_free(ok_load); + if (short_load != NULL) + bloom_free(short_load); + if (long_load != NULL) + bloom_free(long_load); + if (magic_load != NULL) + bloom_free(magic_load); + pfree(good); + + PG_RETURN_BOOL(ok); +} + +/* Send a cancel request for conn's in-flight query (best effort). */ +/* Printable name for a channel state ("UNKNOWN" when out of range). */ +/* + * Drive a producer and a consumer through the whole path in this one backend. + * + * Coordinator-local, so it exercises the merge, the channel table and the + * lifetime rules without needing segments; the segment half (NOTIFY out, + * sideband message in) is covered by the runtime-filter test on a cluster. + */ +Datum +anser_test_node_roundtrip(PG_FUNCTION_ARGS) +{ + AnserChannelKey key; + AnserBloomFilterProduceState *producer; + AnserBloomFilterConsumeState *consumer; + int32 value_arg = PG_GETARG_INT32(0); + Datum value = Int32GetDatum(value_arg); + bool ok = false; + + MemSet(&key, 0, sizeof(key)); + key.gp_session_id = gp_session_id; + key.gp_command_count = gp_command_count; + key.condition_id = 77; + strlcpy(key.condition_key, "node_roundtrip", ANSER_CONDITION_KEY_SIZE); + + PG_TRY(); + { + producer = ExecInitAnserBloomFilterProduce(&key, ANSER_TEST_ELEMS, + ANSER_TEST_MAX_PAYLOAD, 0, 1); + if (producer == NULL) + ok = false; + else + { + ExecAnserBloomFilterProduceAddDatum(producer, value, false); + ok = ExecAnserBloomFilterProducePublish(producer); + ExecEndAnserBloomFilterProduce(producer); + } + + if (ok) + { + consumer = ExecInitAnserBloomFilterConsume(&key, ANSER_TEST_ELEMS, + ANSER_TEST_MAX_PAYLOAD, 1); + if (consumer == NULL) + ok = false; + else + { + ok = ExecAnserBloomFilterConsume(consumer, 1000) && + ExecAnserBloomFilterConsumerGetFilter(consumer) != NULL && + ExecAnserBloomFilterConsumerReceivedParts(consumer) == 1 && + !ExecAnserBloomFilterConsumerWasCancelled(consumer) && + !bloom_lacks_element(ExecAnserBloomFilterConsumerGetFilter(consumer), + (unsigned char *) &value, + sizeof(Datum)); + ExecEndAnserBloomFilterConsume(consumer); + } + } + } + PG_FINALLY(); + { + AnserSidebandResetAll(); + } + PG_END_TRY(); + + PG_RETURN_BOOL(ok); +} + +/* + * --------------------------------------------------------------------------- + * Sizing and give-up decisions. + * + * These four take their inputs as arguments and return what Anser decided, so + * the case tables live in sql/anser_test.sql where they are readable and the + * expected output records real sizes rather than a bare "ok". Between them + * they cover each of the four points where Anser can decide not to bother: + * the planner, filter construction, publication, and the merged result. + * --------------------------------------------------------------------------- + */ + +PG_FUNCTION_INFO_V1(anser_test_rf_size); +PG_FUNCTION_INFO_V1(anser_test_bloom_shape); +PG_FUNCTION_INFO_V1(anser_test_worth_delivering); +PG_FUNCTION_INFO_V1(anser_test_producer_decision); + +/* + * The planner gate: what AnserRuntimeFilterSize decides for an estimated build + * cardinality. NULL means no filter would be injected at all. + */ +Datum +anser_test_rf_size(PG_FUNCTION_ARGS) +{ + double est_rows = PG_GETARG_FLOAT8(0); + int64 total_elems = 0; + int64 max_payload = 0; + int64 planned_bytes = 0; + bool injected; + Datum values[4] = {0, 0, 0, 0}; + bool nulls[4] = {false, false, false, false}; + TupleDesc tupdesc; + + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + elog(ERROR, "anser_test_rf_size: expected a composite return type"); + tupdesc = BlessTupleDesc(tupdesc); + + injected = AnserRuntimeFilterSize(est_rows, &total_elems, &max_payload, + &planned_bytes); + + values[0] = BoolGetDatum(injected); + values[1] = Int64GetDatum(total_elems); + values[2] = Int64GetDatum(max_payload); + values[3] = Int64GetDatum(planned_bytes); + nulls[1] = nulls[2] = nulls[3] = !injected; + + PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls))); +} + +/* + * Filter construction: what AnserBloomCreate makes of (total_elems, cap). + * NULL means it declined -- either the smallest possible filter does not fit + * the cap, or there are too many keys for it to be worth building. + * + * Returns the realized sizes so the expected output pins down bloom_create's + * actual behaviour (its 1 MB floor, its power-of-two rounding) and not merely + * our verdict on it. + */ +Datum +anser_test_bloom_shape(PG_FUNCTION_ARGS) +{ + int64 total_elems = PG_GETARG_INT64(0); + int64 cap = PG_GETARG_INT64(1); + bloom_filter *filter; + Datum values[4] = {0, 0, 0, 0}; + bool nulls[4] = {false, false, false, false}; + TupleDesc tupdesc; + + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + elog(ERROR, "anser_test_bloom_shape: expected a composite return type"); + tupdesc = BlessTupleDesc(tupdesc); + + /* A negative cap would be a Size wraparound; the SQL side never sends one. */ + if (cap < 0) + elog(ERROR, "anser_test_bloom_shape: negative cap"); + + filter = AnserBloomCreate(total_elems, (Size) cap, AnserBloomSeed("shape")); + + values[0] = BoolGetDatum(filter != NULL); + nulls[1] = nulls[2] = nulls[3] = (filter == NULL); + if (filter != NULL) + { + values[1] = Int64GetDatum((int64) bloom_total_bits(filter)); + values[2] = Int64GetDatum((int64) AnserBloomSerializedSize(filter)); + values[3] = Float8GetDatum((double) bloom_total_bits(filter) / + (double) total_elems); + bloom_free(filter); + } + + PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls))); +} + +/* + * The coordinator's gate, on a synthetic part. + * + * Builds a part whose bitset has 'bytes_set' of its 'bitset_bytes' bytes fully + * set, which pins the fill fraction exactly -- reaching a given fill by + * inserting keys would be both slow and only statistically precise. 'damage' + * corrupts the framing instead: 'magic', 'version', 'parts' or 'null'. + */ +Datum +anser_test_worth_delivering(PG_FUNCTION_ARGS) +{ + int32 bitset_bytes = PG_GETARG_INT32(0); + int32 bytes_set = PG_GETARG_INT32(1); + const char *damage = text_to_cstring(PG_GETARG_TEXT_PP(2)); + AnserBloomPartHeader *header; + char *payload; + Size payload_len; + + if (strcmp(damage, "null") == 0) + PG_RETURN_BOOL(AnserBloomPartWorthSending(NULL, 1024)); + + if (bitset_bytes < 0 || bytes_set < 0 || bytes_set > bitset_bytes) + elog(ERROR, "anser_test_worth_delivering: bad bitset arguments"); + + payload_len = sizeof(AnserBloomPartHeader) + (Size) bitset_bytes; + payload = palloc0(payload_len); + header = (AnserBloomPartHeader *) payload; + header->magic = ANSER_BLOOM_PART_MAGIC; + header->version = ANSER_BLOOM_PART_VERSION; + header->part_index = 0; + header->total_parts = 1; + + if (strcmp(damage, "magic") == 0) + header->magic = ANSER_BLOOM_PART_MAGIC + 1; + else if (strcmp(damage, "version") == 0) + header->version = ANSER_BLOOM_PART_VERSION + 1; + else if (strcmp(damage, "parts") == 0) + header->total_parts = 0; + else if (damage[0] != '\0') + elog(ERROR, "anser_test_worth_delivering: unknown damage \"%s\"", damage); + + if (bytes_set > 0) + memset(payload + sizeof(AnserBloomPartHeader), 0xff, (Size) bytes_set); + + PG_RETURN_BOOL(AnserBloomPartWorthSending(payload, payload_len)); +} + +/* + * Producer end to end, on the coordinator-local path: build a filter for + * (total_elems, cap), insert 'n_keys' distinct keys, publish, then consume. + * + * Returns ":", where construction is built/no-filter + * and delivery is delivered/cancelled/missing. Every combination that can + * occur says something different: + * + * built:delivered the normal case + * built:cancelled the filter saturated, so publication became a cancel + * no-filter:cancelled construction declined, cancelled before any scan + * + * Each call takes a fresh condition_id, since several rows of one query share a + * session and command counter and would otherwise collide on one channel. + */ +Datum +anser_test_producer_decision(PG_FUNCTION_ARGS) +{ + static uint32 next_condition_id = 1000; + + int64 total_elems = PG_GETARG_INT64(0); + int64 cap = PG_GETARG_INT64(1); + int32 n_keys = PG_GETARG_INT32(2); + AnserChannelKey key; + AnserBloomFilterProduceState *producer; + const char *construction; + const char *delivery; + void *payload = NULL; + Size payload_len = 0; + bool cancelled = false; + int32 i; + + MemSet(&key, 0, sizeof(key)); + key.gp_session_id = gp_session_id; + key.gp_command_count = gp_command_count; + key.condition_id = next_condition_id++; + snprintf(key.condition_key, ANSER_CONDITION_KEY_SIZE, "anser_rf_%u", + key.condition_id); + + producer = ExecInitAnserBloomFilterProduce(&key, total_elems, (Size) cap, + 0, 1); + if (producer == NULL) + PG_RETURN_TEXT_P(cstring_to_text("no-producer:missing")); + + construction = ExecAnserBloomFilterProduceHasFilter(producer) + ? "built" : "no-filter"; + + for (i = 0; i < n_keys; i++) + { + Datum value = Int32GetDatum(i); + + ExecAnserBloomFilterProduceAddDatum(producer, value, false); + } + + (void) ExecAnserBloomFilterProducePublish(producer); + ExecEndAnserBloomFilterProduce(producer); + + if (!AnserDispatchLocalConsume(&key, ANSER_PAYLOAD_BLOOM, &payload, + &payload_len, &cancelled)) + delivery = cancelled ? "cancelled" : "missing"; + else + delivery = "delivered"; + + if (payload != NULL) + pfree(payload); + + PG_RETURN_TEXT_P(cstring_to_text(psprintf("%s:%s", construction, delivery))); +} + +/* + * --------------------------------------------------------------------------- + * The wire protocol. + * + * Not exhaustive -- a fuzzer would be the right tool for that, and pg_regress + * is not one. What these cover is the part of the format that is easy to get + * wrong and expensive to get wrong: that the header is a fixed width with the + * fields where they are documented, that a payload cannot be mistaken for + * framing (the reason the newline-delimited format was replaced), that the + * length cross-check rejects a message that does not add up, and that the + * checksum rejects a single altered byte anywhere it is supposed to cover -- + * and does not reject one where it deliberately does not. + * --------------------------------------------------------------------------- + */ + +PG_FUNCTION_INFO_V1(anser_test_wire_format); +PG_FUNCTION_INFO_V1(anser_test_wire_roundtrip); +PG_FUNCTION_INFO_V1(anser_test_push_crc); + +/* Fixed channel coordinates, so the golden headers below are stable. */ +#define ANSER_TEST_WIRE_SESSION 42 +#define ANSER_TEST_WIRE_COMMAND 7 +#define ANSER_TEST_WIRE_CONDITION 3 +#define ANSER_TEST_WIRE_PART 1 +#define ANSER_TEST_WIRE_TOTAL 3 + +static void +anser_test_wire_key(AnserChannelKey *key, const char *condition_key) +{ + MemSet(key, 0, sizeof(*key)); + key->gp_session_id = ANSER_TEST_WIRE_SESSION; + key->gp_command_count = ANSER_TEST_WIRE_COMMAND; + key->condition_id = ANSER_TEST_WIRE_CONDITION; + strlcpy(key->condition_key, condition_key, ANSER_CONDITION_KEY_SIZE); +} + +/* + * The formatted message, verbatim. + * + * Returning it as text is itself a check: a NOTIFY payload travels through + * pq_sendstring and so must be free of NUL bytes, and a text Datum cannot + * carry one -- which is why the body is base64 even though the header is not. + * Pass a body containing NULs and the length in the expected output proves it. + */ +Datum +anser_test_wire_format(PG_FUNCTION_ARGS) +{ + char kind = PG_GETARG_CHAR(0); + char payload_type = PG_GETARG_CHAR(1); + int32 flags = PG_GETARG_INT32(2); + char *condition_key = text_to_cstring(PG_GETARG_TEXT_PP(3)); + bytea *body = PG_GETARG_BYTEA_PP(4); + AnserChannelKey key; + char *msg; + + anser_test_wire_key(&key, condition_key); + msg = AnserWireFormat(&key, kind, payload_type, ANSER_TEST_WIRE_PART, + ANSER_TEST_WIRE_TOTAL, flags, + VARDATA_ANY(body), VARSIZE_ANY_EXHDR(body)); + + PG_RETURN_TEXT_P(cstring_to_text(msg)); +} + +/* + * Format a message, optionally alter one byte of it, then parse it back and + * report what the reader made of it. + * + * The offsets touched are derived from the format macros, never hardcoded, so + * this keeps working if a field is added. Outcomes mirror what the notify + * handler does with each: "malformed" is rejected by framing before anything + * is allocated, "unknown-type" has no registry entry, "checksum-mismatch" is + * well-framed but altered, and "ok" means every field and the decoded body + * came back exactly as they went in. + */ +Datum +anser_test_wire_roundtrip(PG_FUNCTION_ARGS) +{ + char *condition_key = text_to_cstring(PG_GETARG_TEXT_PP(0)); + bytea *body = PG_GETARG_BYTEA_PP(1); + char *tamper = text_to_cstring(PG_GETARG_TEXT_PP(2)); + char payload_type = PG_GETARG_CHAR(3); + AnserChannelKey key; + AnserWireMsg parsed; + char *msg; + Size msg_len; + const char *raw_body = VARDATA_ANY(body); + int raw_len = VARSIZE_ANY_EXHDR(body); + char *decoded = NULL; + int decoded_len = 0; + + anser_test_wire_key(&key, condition_key); + msg = AnserWireFormat(&key, ANSER_WIRE_KIND_PART, payload_type, + ANSER_TEST_WIRE_PART, ANSER_TEST_WIRE_TOTAL, 0, + raw_len > 0 ? raw_body : NULL, (Size) raw_len); + msg_len = strlen(msg); + + /* + * One byte, or one length, altered -- see the case table in the test. An + * empty 'tamper' falls through every branch and leaves the message intact. + */ + if (strcmp(tamper, "truncate") == 0) + msg[msg_len - 1] = '\0'; + else if (strcmp(tamper, "append") == 0) + { + char *longer = palloc(msg_len + 2); + + memcpy(longer, msg, msg_len); + longer[msg_len] = 'x'; + longer[msg_len + 1] = '\0'; + msg = longer; + } + else if (strcmp(tamper, "bodylen") == 0) + { + /* Last digit of the bodylen field: it ends one space before the CRC. */ + char *digit = msg + ANSER_WIRE_CRC_OFFSET - 2; + + *digit = (*digit == '9') ? '8' : (char) (*digit + 1); + } + else if (strcmp(tamper, "tag") == 0) + msg[0] = 'x'; + else if (strcmp(tamper, "kind") == 0) + msg[sizeof(ANSER_WIRE_TAG)] = ANSER_WIRE_KIND_SUBSCRIBE; + else if (strcmp(tamper, "type") == 0) + msg[sizeof(ANSER_WIRE_TAG) + 2] = 'Z'; + else if (strcmp(tamper, "crc") == 0) + { + char *digit = msg + ANSER_WIRE_HDR_LEN - 1; + + *digit = (*digit == '0') ? '1' : '0'; + } + else if (strcmp(tamper, "key") == 0) + { + char *first = msg + ANSER_WIRE_HDR_LEN; + + *first = (*first == 'a') ? 'b' : 'a'; + } + else if (strcmp(tamper, "body") == 0) + { + /* Stay inside the base64 alphabet so this tests the CRC, not decoding. */ + char *first = msg + ANSER_WIRE_HDR_LEN + strlen(condition_key); + + *first = (*first == 'A') ? 'B' : 'A'; + } + else if (strcmp(tamper, "") != 0) + elog(ERROR, "anser_test_wire_roundtrip: unknown tamper \"%s\"", tamper); + + if (!AnserWireParse(msg, &parsed)) + PG_RETURN_TEXT_P(cstring_to_text("malformed")); + + if (AnserPayloadLookup(parsed.payload_type) == NULL) + PG_RETURN_TEXT_P(cstring_to_text("unknown-type")); + + if (parsed.body_len > 0) + { + int maxlen = pg_b64_dec_len(parsed.body_len); + + decoded = palloc(maxlen); + decoded_len = pg_b64_decode(parsed.body, parsed.body_len, decoded, + maxlen); + if (decoded_len < 0) + PG_RETURN_TEXT_P(cstring_to_text("undecodable")); + } + + if (!AnserWireCheckCrc(&parsed, decoded, (Size) decoded_len)) + PG_RETURN_TEXT_P(cstring_to_text("checksum-mismatch")); + + /* Framed and vouched for: now every field must have survived the trip. */ + if (parsed.kind != ANSER_WIRE_KIND_PART) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: kind")); + if (parsed.payload_type != payload_type) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: payload_type")); + if (parsed.key.gp_session_id != ANSER_TEST_WIRE_SESSION || + parsed.key.gp_command_count != ANSER_TEST_WIRE_COMMAND || + parsed.key.condition_id != ANSER_TEST_WIRE_CONDITION) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: channel")); + if (parsed.part_index != ANSER_TEST_WIRE_PART || + parsed.total_parts != ANSER_TEST_WIRE_TOTAL) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: part")); + if (parsed.flags != 0) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: flags")); + if (strcmp(parsed.key.condition_key, condition_key) != 0) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: condition_key")); + if (decoded_len != raw_len || + (raw_len > 0 && memcmp(decoded, raw_body, raw_len) != 0)) + PG_RETURN_TEXT_P(cstring_to_text("mismatch: body")); + + PG_RETURN_TEXT_P(cstring_to_text("ok")); +} + +/* + * The QD -> QE checksum, as an integer so the test can compare two of them. + * + * What matters is not the value but which inputs change it: every routing field + * and the key always, and the body only for a payload type that asks for its + * body to be covered. + */ +Datum +anser_test_push_crc(PG_FUNCTION_ARGS) +{ + char payload_type = PG_GETARG_CHAR(0); + int32 condition_id = PG_GETARG_INT32(1); + int32 flags = PG_GETARG_INT32(2); + char *condition_key = text_to_cstring(PG_GETARG_TEXT_PP(3)); + bytea *body = PG_GETARG_BYTEA_PP(4); + + PG_RETURN_INT64((int64) (uint32) + AnserWirePushCrc(payload_type, (uint32) condition_id, + (uint32) flags, condition_key, + (int) strlen(condition_key), + VARDATA_ANY(body), + (int) VARSIZE_ANY_EXHDR(body))); +} + +/* + * --------------------------------------------------------------------------- + * Runtime-filter pushdown: the decision, on its own + * --------------------------------------------------------------------------- + */ + +PG_FUNCTION_INFO_V1(anser_test_pushdown_accepts); +PG_FUNCTION_INFO_V1(anser_test_pushdown_target); + +/* The relation the synthetic scan below claims to be scanning. */ +#define ANSER_TEST_SCANRELID 1 + +/* + * Targetlist entry kinds, as the SQL-visible spec encodes them. A positive + * value is the table attno of a plain Var; the rest are the shapes that must + * be refused. + */ +#define ANSER_TEST_TL_WHOLEROW 0 /* Var, varattno 0 */ +#define ANSER_TEST_TL_SYSCOL (-1) /* Var, negative varattno */ +#define ANSER_TEST_TL_EXPR (-2) /* not a Var at all */ +#define ANSER_TEST_TL_OTHERREL (-3) /* Var of a different RTE */ +#define ANSER_TEST_TL_RELABEL (-4) /* a cast wrapping a Var */ + +/* + * Build the targetlist `spec` describes. Each element becomes one entry, in + * order, so the array index doubles as the output position the decision is + * asked about. + */ +static List * +anser_test_make_tlist(ArrayType *spec) +{ + Datum *elems; + bool *nulls; + int nelems; + List *tlist = NIL; + int i; + + if (ARR_NDIM(spec) > 1) + elog(ERROR, "anser_test: targetlist spec must be one-dimensional"); + + deconstruct_array(spec, INT4OID, sizeof(int32), true, TYPALIGN_INT, + &elems, &nulls, &nelems); + + for (i = 0; i < nelems; i++) + { + int32 kind; + Expr *expr; + + if (nulls[i]) + elog(ERROR, "anser_test: targetlist spec may not contain NULLs"); + kind = DatumGetInt32(elems[i]); + + switch (kind) + { + case ANSER_TEST_TL_EXPR: + + /* + * The decision only asks whether the entry is a Var, so any + * non-Var exercises the same branch. An OpExpr is what a + * computed column really looks like; nothing evaluates it. + */ + expr = (Expr *) makeNode(OpExpr); + break; + case ANSER_TEST_TL_OTHERREL: + expr = (Expr *) makeVar(ANSER_TEST_SCANRELID + 1, 1, INT4OID, + -1, InvalidOid, 0); + break; + case ANSER_TEST_TL_RELABEL: + { + RelabelType *rt = makeNode(RelabelType); + + rt->arg = (Expr *) makeVar(ANSER_TEST_SCANRELID, 1, + INT4OID, -1, InvalidOid, 0); + rt->resulttype = OIDOID; + rt->relabelformat = COERCE_IMPLICIT_CAST; + expr = (Expr *) rt; + } + break; + default: + /* whole-row (0), system column (negative), or a real column */ + expr = (Expr *) makeVar(ANSER_TEST_SCANRELID, + (AttrNumber) kind, INT4OID, -1, + InvalidOid, 0); + break; + } + + tlist = lappend(tlist, makeTargetEntry(expr, (AttrNumber) (i + 1), + NULL, false)); + } + + return tlist; +} + +/* + * A SeqScanState over that targetlist. Nothing is executed, so only the + * fields the decision reads are set: the node tag, filter_in_seqscan, and the + * plan's targetlist and scanrelid. Passing as_seqscan = false substitutes a + * node of another type, which must be refused whatever its targetlist says. + */ +static PlanState * +anser_test_make_scanstate(ArrayType *spec, bool filter_in_seqscan, + bool as_seqscan) +{ + SeqScan *scan = makeNode(SeqScan); + + scan->scan.scanrelid = ANSER_TEST_SCANRELID; + scan->scan.plan.targetlist = anser_test_make_tlist(spec); + + if (!as_seqscan) + { + MaterialState *ms = makeNode(MaterialState); + + ms->ss.ps.plan = (Plan *) scan; + return (PlanState *) ms; + } + + { + SeqScanState *sss = makeNode(SeqScanState); + + sss->ss.ps.plan = (Plan *) scan; + sss->filter_in_seqscan = filter_in_seqscan; + return (PlanState *) sss; + } +} + +/* + * AnserPushdownAccepts on a synthetic scan. Returns the scan key's attribute + * number, or NULL when the shape is refused -- so the expected output records + * the mapping itself and not merely a verdict on it. + */ +Datum +anser_test_pushdown_accepts(PG_FUNCTION_ARGS) +{ + ArrayType *spec = PG_GETARG_ARRAYTYPE_P(0); + AttrNumber attno = (AttrNumber) PG_GETARG_INT32(1); + bool filter_in_seqscan = PG_GETARG_BOOL(2); + bool as_seqscan = PG_GETARG_BOOL(3); + PlanState *ps; + AttrNumber sk_attno = InvalidAttrNumber; + bool accepts; + Datum values[2] = {0, 0}; + bool nulls[2] = {false, false}; + TupleDesc tupdesc; + + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + elog(ERROR, "anser_test_pushdown_accepts: expected a composite return type"); + tupdesc = BlessTupleDesc(tupdesc); + + ps = anser_test_make_scanstate(spec, filter_in_seqscan, as_seqscan); + accepts = AnserPushdownAccepts(ps, attno, &sk_attno); + + values[0] = BoolGetDatum(accepts); + values[1] = Int32GetDatum((int32) sk_attno); + nulls[1] = !accepts; + + PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls))); +} + +/* + * AnserPushdownTarget over the same synthetic scan, with no stacking: it must + * agree with AnserPushdownAccepts and report the node itself as the target. + * + * The descent through stacked nodes cannot be built here -- that needs real + * CustomScanStates, which need a live EState -- so it is covered end to end by + * the multi-join case in anser_runtime_filter instead. What this pins down is + * the part that is easy to get wrong without noticing: that a found target + * carries the mapped attno through, and that nothing is reported when the + * bottom node refuses. + */ +Datum +anser_test_pushdown_target(PG_FUNCTION_ARGS) +{ + ArrayType *spec = PG_GETARG_ARRAYTYPE_P(0); + AttrNumber attno = (AttrNumber) PG_GETARG_INT32(1); + bool filter_in_seqscan = PG_GETARG_BOOL(2); + bool as_seqscan = PG_GETARG_BOOL(3); + PlanState *ps; + PlanState *target; + AttrNumber sk_attno = InvalidAttrNumber; + Datum values[2] = {0, 0}; + bool nulls[2] = {false, false}; + TupleDesc tupdesc; + + if (get_call_result_type(fcinfo, NULL, &tupdesc) != TYPEFUNC_COMPOSITE) + elog(ERROR, "anser_test_pushdown_target: expected a composite return type"); + tupdesc = BlessTupleDesc(tupdesc); + + ps = anser_test_make_scanstate(spec, filter_in_seqscan, as_seqscan); + target = AnserPushdownTarget(ps, attno, &sk_attno); + + values[0] = BoolGetDatum(target == ps); + values[1] = Int32GetDatum((int32) sk_attno); + nulls[1] = (target == NULL); + + PG_RETURN_DATUM(HeapTupleGetDatum(heap_form_tuple(tupdesc, values, nulls))); +} + +/* + * --------------------------------------------------------------------------- + * How many producers a slice runs + * --------------------------------------------------------------------------- + */ + +PG_FUNCTION_INFO_V1(anser_test_slice_producers); + +/* + * AnserSliceProducers() over a hand-built PlanSlice. + * + * The count is the channel's completion criterion, and FillSliceGangInfo gives + * a different answer per gang type -- so the cases below are the four formulas + * it uses, plus the boundaries where the slice cannot be identified at all. + * Nothing is executed: the function reads the slice array and nothing else. + * + * `gang` names the gang type; `direct` is the direct-dispatch content list, + * or NULL for no direct dispatch. `slice_index` is what to ask about, so a + * caller can also probe outside the array. + */ +Datum +anser_test_slice_producers(PG_FUNCTION_ARGS) +{ + char *gang = text_to_cstring(PG_GETARG_TEXT_PP(0)); + int32 numsegments = PG_GETARG_INT32(1); + int32 parallel_workers = PG_GETARG_INT32(2); + int32 slice_index = PG_GETARG_INT32(4); + PlannedStmt *stmt = makeNode(PlannedStmt); + PlanSlice slice; + + MemSet(&slice, 0, sizeof(slice)); + + if (strcmp(gang, "unallocated") == 0) + slice.gangType = GANGTYPE_UNALLOCATED; + else if (strcmp(gang, "entrydb") == 0) + slice.gangType = GANGTYPE_ENTRYDB_READER; + else if (strcmp(gang, "singleton") == 0) + slice.gangType = GANGTYPE_SINGLETON_READER; + else if (strcmp(gang, "reader") == 0) + slice.gangType = GANGTYPE_PRIMARY_READER; + else if (strcmp(gang, "writer") == 0) + slice.gangType = GANGTYPE_PRIMARY_WRITER; + else + elog(ERROR, "anser_test: unknown gang type \"%s\"", gang); + + slice.numsegments = numsegments; + slice.parallel_workers = parallel_workers; + + if (!PG_ARGISNULL(3)) + { + ArrayType *direct = PG_GETARG_ARRAYTYPE_P(3); + Datum *elems; + bool *nulls; + int nelems; + int i; + + deconstruct_array(direct, INT4OID, sizeof(int32), true, TYPALIGN_INT, + &elems, &nulls, &nelems); + + slice.directDispatch.isDirectDispatch = true; + for (i = 0; i < nelems; i++) + { + if (nulls[i]) + elog(ERROR, "anser_test: direct dispatch list may not contain NULLs"); + slice.directDispatch.contentIds = + lappend_int(slice.directDispatch.contentIds, + DatumGetInt32(elems[i])); + } + } + + stmt->numSlices = 1; + stmt->slices = &slice; + + PG_RETURN_INT32(AnserSliceProducers(stmt, slice_index)); +} + +PG_FUNCTION_INFO_V1(anser_test_unsubscribes); + +/* + * Withdrawals the coordinator has honoured in this backend. + * + * A consumer that runs out of time says so, and the coordinator drops it from + * the channel rather than delivering 1-64 MB to a process that has moved on. + * Nothing else in the system records that it happened: the channel is gone by + * the time the query returns, and a filter that simply arrived late looks the + * same from SQL. A test reads the difference across a query. + */ +Datum +anser_test_unsubscribes(PG_FUNCTION_ARGS) +{ + PG_RETURN_INT64(AnserDispatchUnsubscribeCount()); +} diff --git a/gpcontrib/anser/src/anserbloomconsume.c b/gpcontrib/anser/src/anserbloomconsume.c new file mode 100644 index 00000000000..694959efd2f --- /dev/null +++ b/gpcontrib/anser/src/anserbloomconsume.c @@ -0,0 +1,202 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserbloomconsume.c + * Standalone Anser Bloom filter consumer executor helper. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserbloomconsume.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "anser.h" +#include "anserbloom.h" +#include "anserfilter.h" +#include "anserpayload.h" +#include "ansersideband.h" +#include "cdb/cdbvars.h" +#include "miscadmin.h" +#include "tcop/dest.h" +#include "tcop/tcopprot.h" + +/* + * State for one Bloom filter consumer. Consumes the merged payload for a + * channel exactly once (consumed), either from the coordinator's shared + * coordinator. cancelled records that the producer side aborted instead of + * delivering the payload. + */ +struct AnserBloomFilterConsumeState +{ + AnserChannelKey channel_key; + bloom_filter *filter; + int64 total_elems; /* filter sizing, shared with the producer */ + Size max_payload_bytes; + uint64 seed; + uint32 expected_parts; + uint32 received_parts; + bool consumed; + bool cancelled; +}; + +static bool ExecAnserBloomFilterConsumeSideband(AnserBloomFilterConsumeState *state, + long timeout_ms); +static bool ExecAnserBloomFilterConsumeFinish(AnserBloomFilterConsumeState *state, + void *payload, Size payload_len); + +AnserBloomFilterConsumeState * +ExecInitAnserBloomFilterConsume(const AnserChannelKey *channel_key, + int64 total_elems, Size max_payload_bytes, + uint32 expected_parts) +{ + AnserBloomFilterConsumeState *state; + + if (channel_key == NULL || expected_parts == 0) + return NULL; + + state = palloc0(sizeof(AnserBloomFilterConsumeState)); + state->channel_key = *channel_key; + state->total_elems = total_elems; + state->max_payload_bytes = max_payload_bytes; + state->seed = AnserBloomSeed(channel_key->condition_key); + state->expected_parts = expected_parts; + return state; +} + +bool +ExecAnserBloomFilterConsume(AnserBloomFilterConsumeState *state, + long registration_timeout_ms) +{ + if (state == NULL) + return false; + + if (state->consumed) + return state->filter != NULL; + + return ExecAnserBloomFilterConsumeSideband(state, registration_timeout_ms); +} + +/* + * Dispatch-connection consume path. + * + * On a segment this blocks on our own dispatch socket until the coordinator + * pushes the merged filter; on the coordinator the merge happened in this very + * process, so there is nothing to wait for and we just read it. + */ +static bool +ExecAnserBloomFilterConsumeSideband(AnserBloomFilterConsumeState *state, + long timeout_ms) +{ + void *payload = NULL; + Size payload_len = 0; + bool cancelled = false; + bool got; + + /* + * Which of the two paths a process takes is decided by Gp_role alone, and + * a process that takes the wrong one fails open without ever reaching the + * coordinator: the local path finds no channel on a segment, and the + * sideband path finds no frontend on the coordinator. Logged with the + * facts the choice rests on, because a consumer that reports no filter + * cannot otherwise be told apart from one that asked and was refused. + */ + ANSER_DEBUG("anser: seg%d consumer cond=%u taking the %s path (role=%d writer=%d port=%s dest=%d expected_parts=%u timeout=%ld)", + GpIdentity.segindex, state->channel_key.condition_id, + Gp_role == GP_ROLE_EXECUTE ? "sideband" : "local", + (int) Gp_role, Gp_is_writer ? 1 : 0, + MyProcPort != NULL ? "ok" : "NULL", + (int) whereToSendOutput, + state->expected_parts, timeout_ms); + + if (Gp_role == GP_ROLE_EXECUTE) + got = AnserSidebandConsumeWait(&state->channel_key, ANSER_PAYLOAD_BLOOM, + &payload, &payload_len, &cancelled, + timeout_ms); + else + got = AnserDispatchLocalConsume(&state->channel_key, ANSER_PAYLOAD_BLOOM, + &payload, &payload_len, &cancelled); + + if (!got || cancelled) + { + if (payload != NULL) + pfree(payload); + state->cancelled = cancelled; + state->consumed = true; + return false; + } + + return ExecAnserBloomFilterConsumeFinish(state, payload, payload_len); +} + +/* + * Turn a merged payload into this consumer's filter. + * + * The coordinator unions every segment's part into one before delivery, so a + * single chunk is deserialized rather than N; the merged header's total_parts + * records how many were folded, which we surface as the received count. + */ +static bool +ExecAnserBloomFilterConsumeFinish(AnserBloomFilterConsumeState *state, + void *payload, Size payload_len) +{ + uint32 part_index = 0; + uint32 folded = 0; + + state->filter = AnserBloomDeserializePart(payload, payload_len, + state->total_elems, + state->max_payload_bytes, + state->seed, + &part_index, &folded); + state->received_parts = (state->filter != NULL) ? folded : 0; + + if (payload != NULL) + pfree(payload); + state->consumed = true; + return state->filter != NULL; +} + +bloom_filter * +ExecAnserBloomFilterConsumerGetFilter(AnserBloomFilterConsumeState *state) +{ + return state != NULL ? state->filter : NULL; +} + +uint32 +ExecAnserBloomFilterConsumerReceivedParts(AnserBloomFilterConsumeState *state) +{ + return state != NULL ? state->received_parts : 0; +} + +bool +ExecAnserBloomFilterConsumerWasCancelled(AnserBloomFilterConsumeState *state) +{ + return state != NULL && state->cancelled; +} + +void +ExecEndAnserBloomFilterConsume(AnserBloomFilterConsumeState *state) +{ + if (state == NULL) + return; + + if (state->filter != NULL) + bloom_free(state->filter); + pfree(state); +} diff --git a/gpcontrib/anser/src/anserbloomproduce.c b/gpcontrib/anser/src/anserbloomproduce.c new file mode 100644 index 00000000000..1bf5d584439 --- /dev/null +++ b/gpcontrib/anser/src/anserbloomproduce.c @@ -0,0 +1,219 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserbloomproduce.c + * Standalone Anser Bloom filter producer executor helper. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserbloomproduce.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "anser.h" +#include "anserbloom.h" +#include "anserfilter.h" +#include "anserpayload.h" +#include "ansersideband.h" +#include "cdb/cdbvars.h" + +/* + * State for a single bloom filter producer: the target channel, the filter + * being built, and this producer's identity within total_parts. published + * and cancelled guard against double publication and drive teardown. + */ +struct AnserBloomFilterProduceState +{ + AnserChannelKey channel_key; + bloom_filter *filter; + uint32 part_index; + uint32 total_parts; + bool published; + bool cancelled; +}; + +/* + * Publish one part. + * + * A segment sends it to the coordinator over the dispatch connection; a + * coordinator-local producer hands it to the same per-query channel table the + * coordinator merges into, since it has no connection to itself. total_parts + * doubles as expected_producers: each producer contributes exactly one part. + */ +static bool +AnserProducePublishPart(AnserBloomFilterProduceState *state, + const void *payload, Size payload_len, bool cancelled) +{ + if (Gp_role == GP_ROLE_EXECUTE) + return AnserSidebandPublish(&state->channel_key, ANSER_PAYLOAD_BLOOM, + state->part_index, state->total_parts, + payload, payload_len, cancelled); + + return AnserDispatchLocalPublish(&state->channel_key, ANSER_PAYLOAD_BLOOM, + state->part_index, state->total_parts, + payload, payload_len, cancelled); +} + +AnserBloomFilterProduceState * +ExecInitAnserBloomFilterProduce(const AnserChannelKey *channel_key, + int64 total_elems, + Size max_payload_bytes, + uint32 part_index, + uint32 total_parts) +{ + AnserBloomFilterProduceState *state; + uint64 seed; + + if (channel_key == NULL || total_parts == 0 || part_index >= total_parts) + return NULL; + + state = palloc0(sizeof(AnserBloomFilterProduceState)); + state->channel_key = *channel_key; + state->part_index = part_index; + state->total_parts = total_parts; + seed = AnserBloomSeed(channel_key->condition_key); + state->filter = AnserBloomCreate(total_elems, max_payload_bytes, seed); + + return state; +} + +void +ExecAnserBloomFilterProduceAddDatum(AnserBloomFilterProduceState *state, + Datum value, bool isnull) +{ + /* + * A NULL filter means AnserBloomCreate declined to build one -- too many + * keys for the payload cap. There is nothing to add to, and the node has + * already published the cancel. + */ + if (state == NULL || state->filter == NULL || state->published || isnull) + return; + + bloom_add_element(state->filter, (unsigned char *) &value, sizeof(Datum)); +} + +/* Does this producer have a filter to fill? False means "already hopeless". */ +bool +ExecAnserBloomFilterProduceHasFilter(AnserBloomFilterProduceState *state) +{ + return state != NULL && state->filter != NULL; +} + +bool +ExecAnserBloomFilterProducePublish(AnserBloomFilterProduceState *state) +{ + Size payload_size; + Size payload_len = 0; + void *payload; + bool ok; + + if (state == NULL || state->published) + { + ANSER_DEBUG("anser: publish skipped (%s)", + state == NULL ? "no producer state" : "already published"); + return false; + } + + if (state->cancelled || state->filter == NULL) + { + ANSER_DEBUG("anser: publishing a cancel (%s)", + state->cancelled ? "producer cancelled" : "no filter"); + state->published = true; + return AnserProducePublishPart(state, NULL, 0, true); + } + + /* + * Too saturated to be worth anything? Cancel rather than send. + * + * This is the check the planner cannot make: it decided the filter was + * worth building from a row estimate, and estimates are wrong. A filter + * whose bits are nearly all set matches nearly every probe row, so shipping + * it would buy the consumers a hash and k probes per row in exchange for + * almost no rows eliminated -- pure overhead on both sides. + */ + if (bloom_false_positive_rate(state->filter) > ANSER_BLOOM_MAX_FPR) + { + ANSER_DEBUG("anser: publishing a cancel: filter is %.1f%% full, est. FPR %.1f%% above the %.0f%% limit", + bloom_prop_bits_set(state->filter) * 100.0, + bloom_false_positive_rate(state->filter) * 100.0, + ANSER_BLOOM_MAX_FPR * 100.0); + state->published = true; + return AnserProducePublishPart(state, NULL, 0, true); + } + + ANSER_DEBUG("anser: filter is %.1f%% full, est. FPR %.2f%%", + bloom_prop_bits_set(state->filter) * 100.0, + bloom_false_positive_rate(state->filter) * 100.0); + + /* + * Serialize as a self-contained single part (index 0 of 1). The coordinator + * stores the first part verbatim and OR-folds each later part, bumping the + * merged header's fold count, so the final count reflects how many parts were + * unioned. state->part_index / state->total_parts identify this producer to + * the channel (expected_producers), not the on-wire part layout. + */ + payload_size = AnserBloomSerializedSize(state->filter); + payload = palloc(payload_size); + ok = AnserBloomSerializePart(state->filter, + 0, + 1, + payload, + payload_size, + &payload_len); + if (ok) + ok = AnserProducePublishPart(state, payload, payload_len, false); + else + ANSER_DEBUG("anser: publish skipped: could not serialize part (size=%zu)", + payload_size); + + pfree(payload); + state->published = true; + return ok; +} + +bool +ExecAnserBloomFilterProduceCancel(AnserBloomFilterProduceState *state) +{ + if (state == NULL || state->published) + return false; + + state->cancelled = true; + state->published = true; + return AnserProducePublishPart(state, NULL, 0, true); +} + +/* + * Free the producer state. + * + * Deliberately does NOT publish a cancel for an unpublished producer: whether + * silence means "ran and was abandoned" or "never ran at all" is knowable only + * to the node, and the two need opposite handling (see anser_produce_end in + * anserplanexec.c). + */ +void +ExecEndAnserBloomFilterProduce(AnserBloomFilterProduceState *state) +{ + if (state == NULL) + return; + + if (state->filter != NULL) + bloom_free(state->filter); + pfree(state); +} diff --git a/gpcontrib/anser/src/anserdispatch.c b/gpcontrib/anser/src/anserdispatch.c new file mode 100644 index 00000000000..06330332b82 --- /dev/null +++ b/gpcontrib/anser/src/anserdispatch.c @@ -0,0 +1,786 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserdispatch.c + * Coordinator side of the dispatch-connection transport. + * + * Everything here runs in the QD backend, reached from cdbdisp_notify_hook + * while the dispatcher drains QE messages -- which happens from inside the + * interconnect wait loop, so this code is on the query's critical path. It + * must stay cheap and must not throw for anything recoverable: an error here + * lands in a running query, whereas losing a filter only costs us an + * unfiltered scan. + * + * Because producer merge and consumer delivery both happen in this one + * process, the accumulator is an ordinary palloc'd buffer. There is no shared + * memory, no DSM segment to attach, and no separate worker to hand data to; + * parts are folded in place as they arrive, so only the final fold is on the + * critical path. + * + * Nothing here knows what a part contains. Merging is delegated to the payload + * type's fold() (anserpayload.h), and the only other thing this file asks about + * a type is whether its body is covered by the message checksum -- which is why + * it does not include anserfilter.h at all. + * + * A note on libpq linkage. The connections we write to were created by the + * copy of libpq that is statically linked into the postgres binary, and every + * symbol listed in libpq's exports.txt is deliberately made *local* in that + * binary (see the version-script hack in src/backend/Makefile) precisely so a + * module cannot end up driving one connection through two copies of libpq. + * So this file must not call the public PQ* API: linking libpq.so to obtain it + * would create exactly the mixture that hack exists to prevent. The internal + * pq* writers are not in exports.txt and so remain global in the backend, + * which is how pqPutMsgStart() and friends resolve here; the two accessors we + * would otherwise want are inlined below, straight off the struct. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserdispatch.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "libpq-fe.h" +#include "libpq-int.h" + +#include "anser.h" +#include "anserpayload.h" +#include "ansersideband.h" +#include "cdb/cdbconn.h" +#include "cdb/cdbdisp.h" +#include "cdb/cdbdispatchresult.h" +#include "cdb/cdbvars.h" +#include "utils/faultinjector.h" +#include "common/base64.h" +#include "lib/stringinfo.h" +#include "nodes/pg_list.h" +#include "utils/hsearch.h" +#include "utils/memutils.h" + +/* + * One channel's merge state, living for the duration of the query that created + * it. Keyed by AnserChannelKey, which must be the first field. + */ +typedef struct AnserDispChannel +{ + AnserChannelKey key; + const AnserPayloadOps *ops; /* what this carries; NULL until a part says */ + char *payload; /* merged part, or NULL before the first one */ + Size payload_len; + int parts_received; + int expected_parts; /* 0 until a part tells us; from total_parts */ + bool cancelled; + bool complete; /* every expected part folded, or cancelled */ + List *subscribers; /* PGconn * of QEs awaiting delivery */ +} AnserDispChannel; + +static HTAB *AnserDispChannels = NULL; + +/* + * Withdrawals honoured since the backend started. Only a test reads it: an + * unsubscribe leaves no other trace, because the channel it touches is gone + * by the time the query returns. Counting them is how we tell "the consumer + * withdrew and the coordinator acted on it" from "the filter happened to + * arrive in time", which look identical from SQL. + */ +static int64 AnserDispUnsubscribes = 0; +static MemoryContext AnserDispContext = NULL; + +static AnserDispChannel *anser_disp_lookup(const AnserChannelKey *key, bool create); +static void anser_disp_apply_part(AnserDispChannel *chan, + const AnserPayloadOps *ops, + const void *payload, Size payload_len, + int total_parts, bool cancelled); +static void anser_disp_deliver(AnserDispChannel *chan); +static bool anser_disp_push(PGconn *conn, AnserDispChannel *chan); + +/* + * PQstatus() / PQerrorMessage() equivalents. See the linkage note above for + * why these are not the real thing; libpq-int.h gives us the full struct. + */ +static inline bool +anser_conn_ok(const PGconn *conn) +{ + return conn != NULL && conn->status == CONNECTION_OK; +} + +static inline const char * +anser_conn_error(const PGconn *conn) +{ + if (conn == NULL) + return "connection pointer is NULL"; + if (PQExpBufferBroken(&conn->errorMessage)) + return "out of memory"; + return conn->errorMessage.data; +} + +/* + * How many withdrawals this backend has honoured. For tests; see the counter. + */ +int64 +AnserDispatchUnsubscribeCount(void) +{ + return AnserDispUnsubscribes; +} + +/* + * Per-query state lives in its own context so it can be dropped wholesale. + * Channels are keyed by (session, command, condition), so entries from an + * earlier command in the same transaction are distinct and simply unused + * until the reset. + */ +static void +anser_disp_init(void) +{ + HASHCTL hctl; + + if (AnserDispChannels != NULL) + return; + + AnserDispContext = AllocSetContextCreate(TopMemoryContext, + "Anser dispatch transport", + ALLOCSET_DEFAULT_SIZES); + + MemSet(&hctl, 0, sizeof(hctl)); + hctl.keysize = sizeof(AnserChannelKey); + hctl.entrysize = sizeof(AnserDispChannel); + hctl.hcxt = AnserDispContext; + + AnserDispChannels = hash_create("Anser dispatch channels", 32, &hctl, + HASH_ELEM | HASH_BLOBS | HASH_CONTEXT); +} + +void +AnserDispatchReset(void) +{ + if (AnserDispChannels == NULL) + return; + + hash_destroy(AnserDispChannels); + AnserDispChannels = NULL; + MemoryContextDelete(AnserDispContext); + AnserDispContext = NULL; +} + +static AnserDispChannel * +anser_disp_lookup(const AnserChannelKey *key, bool create) +{ + AnserDispChannel *chan; + bool found; + + anser_disp_init(); + + chan = (AnserDispChannel *) hash_search(AnserDispChannels, key, + create ? HASH_ENTER : HASH_FIND, + &found); + if (chan == NULL) + return NULL; + + if (create && !found) + { + /* hash_search only fills the key; initialize the rest. */ + chan->ops = NULL; + chan->payload = NULL; + chan->payload_len = 0; + chan->parts_received = 0; + chan->expected_parts = 0; + chan->cancelled = false; + chan->complete = false; + chan->subscribers = NIL; + } + + return chan; +} + +/* + * cdbdisp_notify_hook: is this one of ours, and if so, handle it. + * + * Returns false for any notify on another channel so the dispatcher can carry + * on with its own handling. A malformed payload is consumed (it is addressed + * to us) but otherwise ignored: the affected consumers will time out and run + * unfiltered. + */ +bool +AnserDispatchNotifyHandler(struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify) +{ + PGnotify *n = (PGnotify *) notify; + CdbDispatchResult *dr = (CdbDispatchResult *) dispatchResult; + int sender = (dr != NULL && dr->segdbDesc != NULL) + ? dr->segdbDesc->segindex : -99; + AnserWireMsg msg; + const AnserPayloadOps *ops; + AnserDispChannel *chan; + MemoryContext oldcxt; + + if (n == NULL || n->relname == NULL || + strcmp(n->relname, ANSER_NOTIFY_CHANNEL) != 0) + return false; + + if (n->extra == NULL || !AnserWireParse(n->extra, &msg)) + { + elog(LOG, "anser: ignoring malformed message from a segment (len=%zu)", + n->extra != NULL ? strlen(n->extra) : (size_t) 0); + return true; + } + + /* + * Resolve what the message carries before doing anything with it. An + * unregistered type means a producer was taught to send something this + * coordinator does not know how to merge -- almost certainly a payload type + * added to anserpayload.h but not to AnserPayloadTable. + */ + ops = AnserPayloadLookup(msg.payload_type); + if (ops == NULL) + { + elog(LOG, "anser: unknown payload type '%c' from seg%d (cond=%u); ignoring", + msg.payload_type, sender, msg.key.condition_id); + return true; + } + + /* + * A message with no body can be checked here, before anything is + * allocated; one with a body is checked in the PART branch below, once it + * has been decoded. + */ + if (msg.body_len == 0 && !AnserWireCheckCrc(&msg, NULL, 0)) + { + elog(LOG, "anser: ignoring corrupted message from seg%d (cond=%u kind=%c)", + sender, msg.key.condition_id, msg.kind); + return true; + } + + anser_disp_init(); + oldcxt = MemoryContextSwitchTo(AnserDispContext); + + chan = anser_disp_lookup(&msg.key, true); + if (chan == NULL) + { + MemoryContextSwitchTo(oldcxt); + return true; + } + + if (msg.kind == ANSER_WIRE_KIND_SUBSCRIBE) + { + PGconn *conn = dr->segdbDesc->conn; + + /* + * A consumer can subscribe after the channel is already complete -- + * producers on other segments may well have finished first -- so + * deliver immediately in that case rather than recording interest. + */ + ANSER_DEBUG("anser: QD subscribe cond=%u from seg%d (channel %s)", + msg.key.condition_id, sender, + chan->complete ? "complete, delivering now" : "still collecting"); + if (chan->complete) + (void) anser_disp_push(conn, chan); + else + chan->subscribers = lappend(chan->subscribers, conn); + } + else if (msg.kind == ANSER_WIRE_KIND_UNSUBSCRIBE) + { + PGconn *conn = dr->segdbDesc->conn; + + /* + * The consumer ran out of time and is no longer reading for us. + * Delivering anyway would put 1-64 MB on a connection whose other end + * is busy elsewhere, and the message would then have to be recognised + * and skipped by whatever reads it next. + * + * This narrows the window rather than closing it: the withdrawal can + * arrive while delivery is already under way, which is why the push + * never blocks and why every reader of this connection still has to + * tolerate a message that is not its own. + */ + ANSER_DEBUG("anser: QD unsubscribe cond=%u from seg%d (%d subscriber(s) before)", + msg.key.condition_id, sender, + list_length(chan->subscribers)); + chan->subscribers = list_delete_ptr(chan->subscribers, conn); + AnserDispUnsubscribes++; + } + else if (msg.kind == ANSER_WIRE_KIND_PART) + { + char *raw = NULL; + int raw_len = 0; + + if (!(msg.flags & ANSER_WIRE_F_CANCELLED) && msg.body_len > 0) + { + int maxlen = pg_b64_dec_len(msg.body_len); + + raw = palloc(maxlen); + raw_len = pg_b64_decode(msg.body, msg.body_len, raw, maxlen); + + /* + * Undecodable, oversized or corrupted: cancel the channel rather + * than guess. Consumers then run unfiltered, which is slower but + * correct -- folding in a part we cannot vouch for risks clearing a + * bit that should be set, and a filter missing a key silently drops + * joinable rows. + */ + if (raw_len < 0 || raw_len > gp_anser_max_info_size || + !AnserWireCheckCrc(&msg, raw, (Size) raw_len)) + { + elog(LOG, "anser: unusable part for condition %u from seg%d; cancelling channel", + msg.key.condition_id, sender); + pfree(raw); + raw = NULL; + raw_len = 0; + msg.flags |= ANSER_WIRE_F_CANCELLED; + } + } + + anser_disp_apply_part(chan, ops, raw, (Size) raw_len, msg.total_parts, + (msg.flags & ANSER_WIRE_F_CANCELLED) != 0); + ANSER_DEBUG("anser: QD %s part cond=%u from seg%d (says part %d of %d) %d/%d bytes=%d -> %s", + ops->name, msg.key.condition_id, sender, msg.part_index, + msg.total_parts, chan->parts_received, + chan->expected_parts, raw_len, + chan->cancelled ? "cancelled" : + chan->complete ? "complete" : "collecting"); + if (raw != NULL) + pfree(raw); + } + + if (chan->complete) + anser_disp_deliver(chan); + + MemoryContextSwitchTo(oldcxt); + return true; +} + +/* + * Fold one part into the channel's accumulator. + * + * The first part is kept verbatim and becomes the accumulator; later parts are + * merged into it in place by the payload type's fold(), so no part is ever + * copied twice and the accumulator is never reallocated. Nothing here knows + * what the bytes are -- for a bloom filter the fold is a bitwise OR, for a row + * count it would be a sum, and this function reads the same either way. + */ +static void +anser_disp_apply_part(AnserDispChannel *chan, const AnserPayloadOps *ops, + const void *payload, Size payload_len, int total_parts, + bool cancelled) +{ + if (chan->cancelled) + return; /* already dead; nothing to do */ + + if (chan->ops == NULL) + chan->ops = ops; + else if (chan->ops != ops) + { + /* + * Two producers disagree about what this channel carries. Merging + * across types is meaningless, so give up on the channel: consumers run + * unfiltered, which is always correct. + */ + elog(LOG, "anser: cond=%u carries both '%s' and '%s'; cancelling channel", + chan->key.condition_id, chan->ops->name, ops->name); + chan->cancelled = true; + chan->complete = true; + chan->payload = NULL; + chan->payload_len = 0; + return; + } + + if (total_parts > chan->expected_parts) + { + /* + * Take the largest count any producer claims. Letting a later part + * lower it would complete the channel early and deliver a filter + * missing another segment's keys -- a false negative, which drops + * joinable rows. Disagreement means the producers computed their + * slice width differently and is worth seeing. + */ + if (chan->expected_parts != 0) + elog(LOG, "anser: cond=%u producer count changed %d -> %d", + chan->key.condition_id, chan->expected_parts, total_parts); + chan->expected_parts = total_parts; + } + + if (cancelled) + { + chan->cancelled = true; + chan->complete = true; + chan->payload = NULL; + chan->payload_len = 0; + return; + } + + if (payload == NULL || payload_len == 0) + { + /* An empty part still counts toward completion. */ + chan->parts_received++; + } + else if (chan->payload == NULL) + { + chan->payload = palloc(payload_len); + memcpy(chan->payload, payload, payload_len); + chan->payload_len = payload_len; + chan->parts_received++; + } + else if (ops->fold != NULL && + ops->fold(chan->payload, chan->payload_len, payload, payload_len)) + { + chan->parts_received++; + } + else + { + /* + * The parts cannot be merged -- for a bloom filter, sizes or filter + * parameters disagree. That should not happen (every part on a channel + * is built from the same plan parameters), but if it does the only safe + * answer is to give up on the channel. A type with no fold() reaches + * here too, which is right: it should never have carried a body. + */ + elog(LOG, "anser: incompatible '%s' part for condition %u; cancelling channel", + ops->name, chan->key.condition_id); + chan->cancelled = true; + chan->complete = true; + chan->payload = NULL; + chan->payload_len = 0; + return; + } + + if (chan->expected_parts > 0 && chan->parts_received >= chan->expected_parts) + { + chan->complete = true; + + /* + * Every part is in, so this is the first and last chance to judge the + * merged result. A type that declines it here saves each consumer both + * the delivery and the per-row probing it would have paid for. + */ + if (chan->payload != NULL && ops->worth_delivering != NULL && + !ops->worth_delivering(chan->payload, chan->payload_len)) + { + elog(LOG, "anser: merged '%s' payload for condition %u is not worth delivering; cancelling channel", + ops->name, chan->key.condition_id); + chan->cancelled = true; + chan->payload = NULL; + chan->payload_len = 0; + } + } +} + +/* Push the finished channel to everyone waiting, then forget them. */ +static void +anser_disp_deliver(AnserDispChannel *chan) +{ + ListCell *lc; + + /* + * Hold the delivery back, so a test can let every consumer reach its + * deadline first and then be delivered to anyway. That ordering is what + * puts a filter on a connection nobody is reading, and it cannot be + * arranged with data: the channel completes when the last part lands, + * which is normally while the consumers are still waiting for it. + */ + SIMPLE_FAULT_INJECTOR("anser_before_deliver"); + + ANSER_DEBUG("anser: QD delivering cond=%u to %d subscriber(s)", + chan->key.condition_id, list_length(chan->subscribers)); + foreach(lc, chan->subscribers) + (void) anser_disp_push((PGconn *) lfirst(lc), chan); + + list_free(chan->subscribers); + chan->subscribers = NIL; +} + +/* + * Write one merged payload to a QE as a GP_SIDEBAND_MESSAGE. + * + * Delivery is per consumer: a write that fails costs that one segment its + * filter (it will time out and run unfiltered) and leaves the others alone. + * This is the same "try to reach every consumer" rule the shared-memory send + * service followed. + */ +static bool +anser_disp_push(PGconn *conn, AnserDispChannel *chan) +{ + int flags = chan->cancelled ? ANSER_WIRE_F_CANCELLED : 0; + int keylen = (int) strlen(chan->key.condition_key); + int paylen = chan->cancelled ? 0 : (int) chan->payload_len; + pg_crc32c crc; + char paytype; + + if (!anser_conn_ok(conn)) + return false; + + /* + * A channel that was cancelled before any part arrived has no type. That + * is fine: the delivery carries no body, and a consumer only checks the + * type of a body it actually received. + */ + paytype = chan->ops != NULL ? chan->ops->code : ANSER_PAYLOAD_NONE; + + crc = AnserWirePushCrc(paytype, chan->key.condition_id, (uint32) flags, + chan->key.condition_key, keylen, + chan->payload, paylen); + + /* + * Raw binary: pqPutnchar performs no encoding conversion, so unlike the + * QE -> QD direction this needs no base64. + */ + if (pqPutMsgStart(GP_SIDEBAND_MESSAGE, conn) < 0 || + pqPutInt((int) paytype, 4, conn) < 0 || + pqPutInt((int) crc, 4, conn) < 0 || + pqPutInt((int) chan->key.condition_id, 4, conn) < 0 || + pqPutInt(flags, 4, conn) < 0 || + pqPutInt(keylen, 4, conn) < 0 || + pqPutnchar(chan->key.condition_key, keylen, conn) < 0 || + pqPutInt(paylen, 4, conn) < 0 || + (paylen > 0 && pqPutnchar(chan->payload, paylen, conn) < 0)) + { + elog(LOG, "anser: could not deliver filter for condition %u: %s", + chan->key.condition_id, anser_conn_error(conn)); + return false; + } + + /* + * Nothing above this point can block -- those calls only grow the output + * buffer. The two that can are avoided deliberately, because this runs + * inside the dispatcher loop and a merged filter is 1-64 MB headed for a + * peer that may have stopped reading: a consumer that gave up on its + * deadline stays subscribed until it can say otherwise. + * + * pqPutMsgEnd() flushes by itself once the buffer passes 8 kB, and a + * blocking pqFlush() then waits in pqWait() with no deadline and no + * CHECK_FOR_INTERRUPTS. Either one strands the coordinator: it stops + * serving every other segment, and the query cannot even be cancelled. + * + * So queue the bytes and let the dispatcher's own poll() place them when + * the peer makes room. A positive return says part of the message is + * still queued, which is an outcome and not a failure. + */ + pqPutMsgEndNoAutoFlush(conn); + if (pqFlushNonBlocking(conn) < 0) + { + elog(LOG, "anser: could not deliver filter for condition %u: %s", + chan->key.condition_id, anser_conn_error(conn)); + return false; + } + + ANSER_DEBUG("anser: QD pushed cond=%u type=%c bytes=%d cancelled=%d", + chan->key.condition_id, paytype, paylen, + chan->cancelled ? 1 : 0); + return true; +} + +/* + * Parse a QE -> QD payload: fixed-width header, then the condition key, then + * the base64 body. ansersideband.h documents the layout and the reasoning; the + * checks here are what make it trustworthy: + * + * - the tag must match, so a message from a different format is not + * misinterpreted as this one; + * - the header is a constant length, so the key and the body start at offsets + * that no payload byte can influence; + * - the two lengths must account for the message exactly, which catches a + * truncated or over-long message before any of it is used; + * - the CRC is verified once the body has been decoded, in the caller. + * + * The payload type is carried through as the raw byte; resolving it against the + * registry is the caller's job, so that an unregistered type is reported as + * exactly that rather than as a framing error. + */ +bool +AnserWireParse(const char *msg, AnserWireMsg *out) +{ + Size msglen = strlen(msg); + char kind; + char paytype; + uint32 ssid, + ccnt, + condid, + part, + total, + flags, + keylen, + bodylen, + crc; + + if (msglen < ANSER_WIRE_HDR_LEN) + return false; + if (strncmp(msg, ANSER_WIRE_TAG " ", sizeof(ANSER_WIRE_TAG)) != 0) + return false; + + if (sscanf(msg, ANSER_WIRE_HDR_SCANF, + &kind, &paytype, &ssid, &ccnt, &condid, &part, &total, &flags, + &keylen, &bodylen, &crc) != 11) + return false; + + if (kind != ANSER_WIRE_KIND_PART && + kind != ANSER_WIRE_KIND_SUBSCRIBE && + kind != ANSER_WIRE_KIND_UNSUBSCRIBE) + return false; + if (keylen >= ANSER_CONDITION_KEY_SIZE) + return false; + if (msglen != ANSER_WIRE_HDR_LEN + (Size) keylen + (Size) bodylen) + return false; + + MemSet(out, 0, sizeof(*out)); + out->wire = msg; + out->kind = kind; + out->payload_type = paytype; + out->key.gp_session_id = (int) ssid; + out->key.gp_command_count = (int) ccnt; + out->key.condition_id = condid; + memcpy(out->key.condition_key, msg + ANSER_WIRE_HDR_LEN, keylen); + out->key.condition_key[keylen] = '\0'; + out->key_len = (int) keylen; + out->part_index = (int) part; + out->total_parts = (int) total; + out->flags = (int) flags; + out->body = msg + ANSER_WIRE_HDR_LEN + keylen; + out->body_len = (int) bodylen; + out->crc = crc; + + return true; +} + +/* + * Verify a parsed message against its checksum. + * + * 'body' is the decoded body, which is what the producer checksummed -- so + * where it is covered this validates the base64 round trip as well. Pass + * NULL/0 for a message that has no body, and note that a type which opts out of + * checksumming its body still has its header and key checked. + */ +bool +AnserWireCheckCrc(const AnserWireMsg *msg, const void *body, Size body_len) +{ + pg_crc32c crc; + + INIT_CRC32C(crc); + COMP_CRC32C(crc, msg->wire, ANSER_WIRE_CRC_OFFSET); + COMP_CRC32C(crc, msg->key.condition_key, msg->key_len); + if (body != NULL && body_len > 0 && + AnserPayloadChecksumsBody(msg->payload_type)) + COMP_CRC32C(crc, body, body_len); + FIN_CRC32C(crc); + + return (uint32) crc == msg->crc; +} + +/* + * Coordinator-local producer. + * + * A producer running on the QD has no dispatch connection to itself, so it + * folds straight into the same channel table the hook uses. + */ +bool +AnserDispatchLocalPublish(const AnserChannelKey *channel_key, char payload_type, + uint32 part_index, uint32 total_parts, + const void *payload, Size payload_len, + bool cancelled) +{ + const AnserPayloadOps *ops; + AnserDispChannel *chan; + MemoryContext oldcxt; + + if (channel_key == NULL) + return false; + if (!cancelled && payload_len > (Size) gp_anser_max_info_size) + cancelled = true; + + ops = AnserPayloadLookup(payload_type); + if (ops == NULL) + { + elog(LOG, "anser: unknown payload type '%c' for condition %u; ignoring", + payload_type, channel_key->condition_id); + return false; + } + + anser_disp_init(); + oldcxt = MemoryContextSwitchTo(AnserDispContext); + + chan = anser_disp_lookup(channel_key, true); + if (chan != NULL) + { + anser_disp_apply_part(chan, ops, payload, payload_len, + (int) total_parts, cancelled); + ANSER_DEBUG("anser: QD local %s part cond=%u (part %u of %u) %d/%d bytes=%zu -> %s", + ops->name, channel_key->condition_id, part_index, total_parts, + chan->parts_received, chan->expected_parts, payload_len, + chan->cancelled ? "cancelled" : + chan->complete ? "complete" : "collecting"); + if (chan->complete) + anser_disp_deliver(chan); + } + + MemoryContextSwitchTo(oldcxt); + return chan != NULL; +} + +/* + * Coordinator-local consumer. + * + * Does not wait: on the coordinator the producer side of the join has already + * run by the time the probe side asks for the filter, so either the channel is + * complete or it never will be (a squelched producer, say) and we fail open. + */ +bool +AnserDispatchLocalConsume(const AnserChannelKey *channel_key, char payload_type, + void **payload, Size *payload_len, bool *cancelled) +{ + AnserDispChannel *chan; + + if (payload != NULL) + *payload = NULL; + if (payload_len != NULL) + *payload_len = 0; + if (cancelled != NULL) + *cancelled = false; + + if (channel_key == NULL || AnserDispChannels == NULL) + return false; + + chan = anser_disp_lookup(channel_key, false); + if (chan == NULL || !chan->complete) + return false; + + if (chan->cancelled || chan->payload == NULL) + { + if (cancelled != NULL) + *cancelled = true; + return false; + } + + /* Same check the segment path makes on delivery; see anser_inbox_take. */ + if (chan->ops == NULL || chan->ops->code != payload_type) + { + elog(WARNING, "anser: condition %u holds payload type '%c', expected '%c'", + channel_key->condition_id, + chan->ops != NULL ? chan->ops->code : '?', payload_type); + if (cancelled != NULL) + *cancelled = true; + return false; + } + + if (payload != NULL) + { + *payload = palloc(chan->payload_len); + memcpy(*payload, chan->payload, chan->payload_len); + } + if (payload_len != NULL) + *payload_len = chan->payload_len; + + return true; +} diff --git a/gpcontrib/anser/src/anserfilter.c b/gpcontrib/anser/src/anserfilter.c new file mode 100644 index 00000000000..88933861748 --- /dev/null +++ b/gpcontrib/anser/src/anserfilter.c @@ -0,0 +1,376 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserfilter.c + * Bloom-filter payload helpers for Anser channels. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserfilter.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "anser.h" +#include "anserfilter.h" +#include "common/hashfn.h" +#include "port/pg_bitutils.h" + +static bool AnserBloomValidateHeader(const AnserBloomPartHeader *header, + Size payload_len); + +/* + * Map Anser's payload budget to bloom_create's work_mem (KB): the space left for + * the bitset after the part header. Callers must have checked that + * max_payload_bytes leaves room for a header. + */ +static int +AnserBloomWorkMemKb(Size max_payload_bytes) +{ + return (int) ((max_payload_bytes - sizeof(AnserBloomPartHeader)) / 1024); +} + +uint64 +AnserBloomSeed(const char *condition_key) +{ + if (condition_key == NULL) + return 0; + + return hash_bytes_extended((const unsigned char *) condition_key, + strlen(condition_key), 0); +} + +/* + * Build an empty bloom filter for a channel from the caller's parameters. + * + * The producer builds its filter here; the consumer rebuilds an identical one + * from the received bitset (AnserBloomDeserializePart -> bloom_create_from_bitset) + * using the SAME (total_elems, max_payload_bytes, seed) -- carried in the plan + * node's custom_private and derived from the shared condition key -- so every + * segment and the consumer realize a byte-for-byte identical filter shape. This + * is why the serialized part header does not need to carry the bitset parameters: + * the reconstructing side already knows them. + * + * Sizing is bloom_create's, but the decision to keep the result is ours. Two + * ways it can come back unusable: + * + * - Too large to send. bloom_create ends with Max(1 MB, bitset), a floor + * that overrides the work_mem cap, so a payload cap below 1 MB yields a + * bitset that cannot be shipped. + * - Too thin to help. bloom_create has no minimum density (unlike + * bloom_create_aggresive, which refuses below 1.6 bits/key), so a large + * enough total_elems against a fixed cap gets you a filter that matches + * almost everything. + * + * Both are judged from the filter's own accessors rather than by re-deriving + * bloom_create's arithmetic here: my_bloom_power() and the floor are private to + * lib/bloomfilter.c, and a copy of them would go quietly out of date on a + * kernel rebase. The cost of learning the answer this way is one palloc0 that + * is immediately freed -- against a build-side scan, which is what returning + * NULL here avoids, that is nothing. + */ +bloom_filter * +AnserBloomCreate(int64 total_elems, Size max_payload_bytes, uint64 seed) +{ + bloom_filter *filter; + Size serialized; + double bits_per_key; + + /* + * These were an Assert on the grounds that internal callers always pass a + * payload cap with room for a header. They are checks because the caller + * is a plan node, and its parameters arrived from the coordinator in + * custom_private -- not somewhere an assertion belongs. The cap also + * cannot merely be >= the header: AnserBloomWorkMemKb subtracts the header + * from a Size, so a smaller cap would underflow to an enormous work_mem. + */ + if (total_elems <= 0 || max_payload_bytes <= sizeof(AnserBloomPartHeader)) + return NULL; + + filter = bloom_create(total_elems, AnserBloomWorkMemKb(max_payload_bytes), + seed); + serialized = AnserBloomSerializedSize(filter); + bits_per_key = (double) bloom_total_bits(filter) / (double) total_elems; + + if (serialized > max_payload_bytes) + { + ANSER_DEBUG("anser: not building a filter: smallest bitset serializes to %zu bytes, cap is %zu", + serialized, max_payload_bytes); + bloom_free(filter); + return NULL; + } + + if (bits_per_key < ANSER_BLOOM_MIN_BITS_PER_KEY) + { + ANSER_DEBUG("anser: not building a filter for %ld key(s) in %zu bytes: %.2f bits/key, below the %.1f floor", + (long) total_elems, max_payload_bytes, bits_per_key, + ANSER_BLOOM_MIN_BITS_PER_KEY); + bloom_free(filter); + return NULL; + } + + return filter; +} + +/* + * Is this serialized payload still selective enough to be worth delivering? + * + * Counts the set bits in the wire form, so the coordinator can ask it of a + * merged accumulator without rebuilding a filter -- which is the point, because + * the union of N parts is denser than any one of them. Three parts at 60% fill + * OR together to as much as 94%, so the producers' own checks do not protect + * consumers from the merged result; this is the check that does. + * + * Fill, not false positive rate: the serialized part carries no hash count, so + * fill is all there is to go on here. ANSER_BLOOM_MERGED_MAX_FILL is set + * accordingly -- high enough that it cannot reject a filter that is still good + * at a large k, which makes it a backstop rather than a tuning knob. + */ +bool +AnserBloomPartWorthSending(const void *payload, Size payload_len) +{ + const char *bits; + Size bitset_bytes; + uint64 bits_set; + double fill; + + if (!AnserBloomLooksLikePart(payload, payload_len)) + return false; + + bitset_bytes = payload_len - sizeof(AnserBloomPartHeader); + if (bitset_bytes == 0) + return false; + + bits = (const char *) payload + sizeof(AnserBloomPartHeader); + bits_set = pg_popcount(bits, (int) bitset_bytes); + fill = (double) bits_set / (double) (bitset_bytes * BITS_PER_BYTE); + + if (fill > ANSER_BLOOM_MERGED_MAX_FILL) + { + ANSER_DEBUG("anser: merged filter is %.1f%% full, above the %.0f%% limit; not worth delivering", + fill * 100.0, ANSER_BLOOM_MERGED_MAX_FILL * 100.0); + return false; + } + + return true; +} + +Size +AnserBloomSerializedSize(const bloom_filter *filter) +{ + if (filter == NULL) + return 0; + + return sizeof(AnserBloomPartHeader) + bloom_bitset_bytes(filter); +} + +/* + * Does this payload look like a serialized bloom part? Used by the in-place fold + * to confirm both the accumulator and the incoming payload are well-formed parts + * before OR-ing their bitsets. A false positive is effectively impossible: a + * part must carry the ABF1 magic, a known version, and sane part counts. + */ +bool +AnserBloomLooksLikePart(const void *payload, Size payload_len) +{ + if (payload == NULL || payload_len < sizeof(AnserBloomPartHeader)) + return false; + + return AnserBloomValidateHeader((const AnserBloomPartHeader *) payload, + payload_len); +} + +/* + * Fold an incoming part into an accumulator part IN PLACE. + * + * When a merged part and the incoming part are the same serialized size (they + * share bitset params derived from the condition key), the union is a pure + * bitwise OR of the two bitsets plus a bump of the merged header's fold count -- + * no reallocation. This mutates `acc` directly, so the caller must hold whatever + * lock guards the buffer (AnserChannelLock, for the channel payload). + * + * Returns true only when the in-place union applied. It returns false -- and + * leaves `acc` untouched (all checks run before any write) -- when the union + * cannot be done by raw OR: sizes differ, either side is not a valid part, or + * the filter parameters disagree. Since every part on a channel shares the same + * (condition-key-derived) parameters and therefore the same serialized size, the + * first part is stored verbatim and every later part folds in here; a false + * return means a malformed/mismatched payload and the caller cancels the channel. + */ +bool +AnserBloomFoldPartInPlace(void *acc, Size acc_len, + const void *part, Size part_len) +{ + AnserBloomPartHeader *ah; + unsigned char *abits; + const unsigned char *pbits; + Size bitset_bytes; + Size i; + + /* Only a same-sized, valid part-vs-part union can be done by raw OR. */ + if (acc_len != part_len || + !AnserBloomLooksLikePart(acc, acc_len) || + !AnserBloomLooksLikePart(part, part_len)) + return false; + + ah = (AnserBloomPartHeader *) acc; + + /* + * Equal serialized size is sufficient: every part on a channel is built by + * bloom_create from the same (condition-key-derived) parameters, so equal + * length implies an identical bitset shape. The bitset is whatever follows + * the header, so its length is the payload length minus the header. + */ + bitset_bytes = acc_len - sizeof(AnserBloomPartHeader); + abits = (unsigned char *) acc + sizeof(AnserBloomPartHeader); + pbits = (const unsigned char *) part + sizeof(AnserBloomPartHeader); + + for (i = 0; i < bitset_bytes; i++) + abits[i] |= pbits[i]; + + /* One more segment part folded into the running merged part. */ + ah->total_parts += 1; + + return true; +} + +/* + * Serialize one filter as a wire part: an AnserBloomPartHeader followed by the + * raw bitset. Fails (returns false) on bogus arguments or a too-small buffer. + */ +bool +AnserBloomSerializePart(const bloom_filter *filter, uint32 part_index, + uint32 total_parts, void *buffer, Size buffer_size, + Size *payload_len) +{ + AnserBloomPartHeader header; + Size bitset_bytes; + Size total_len; + + if (payload_len != NULL) + *payload_len = 0; + + if (filter == NULL || buffer == NULL || total_parts == 0 || + part_index >= total_parts) + return false; + + bitset_bytes = bloom_bitset_bytes(filter); + total_len = sizeof(AnserBloomPartHeader) + bitset_bytes; + if (buffer_size < total_len) + return false; + + MemSet(&header, 0, sizeof(header)); + header.magic = ANSER_BLOOM_PART_MAGIC; + header.version = ANSER_BLOOM_PART_VERSION; + header.part_index = part_index; + header.total_parts = total_parts; + + memcpy(buffer, &header, sizeof(header)); + memcpy((char *) buffer + sizeof(header), bloom_bitset_data(filter), + bitset_bytes); + + if (payload_len != NULL) + *payload_len = total_len; + return true; +} + +/* + * Rebuild the filter from a received merged part. + * + * The bitset parameters are NOT taken from the wire header: the caller passes + * the same (total_elems, max_payload_bytes, seed) used to produce the filter -- + * it holds them in the plan node, so both ends agree by construction. We rebuild + * the empty filter from those, then load the received bitset into it. The wire + * header is still validated (magic/version/counts) and, crucially, the received + * bitset length must exactly match the size the local parameters imply; any + * mismatch (version/parameter skew, truncation) returns NULL so the consumer + * fails open rather than loading a wrongly-shaped bitset. part_index/total_parts + * are surfaced from the header for diagnostics. + */ +bloom_filter * +AnserBloomDeserializePart(const void *payload, Size payload_len, + int64 total_elems, Size max_payload_bytes, uint64 seed, + uint32 *part_index, uint32 *total_parts) +{ + const AnserBloomPartHeader *header; + bloom_filter *filter; + + if (part_index != NULL) + *part_index = 0; + if (total_parts != NULL) + *total_parts = 0; + + if (payload == NULL || payload_len < sizeof(AnserBloomPartHeader)) + return NULL; + + header = (const AnserBloomPartHeader *) payload; + if (!AnserBloomValidateHeader(header, payload_len)) + return NULL; + + if (max_payload_bytes <= sizeof(AnserBloomPartHeader)) + return NULL; + if (total_elems < 1) + total_elems = 1; + + /* + * Build the filter straight from the received bitset, sized by our own + * parameters. bloom_create_from_bitset returns NULL unless the received + * length is exactly the size those parameters imply, so the fail-open + * described above happens here. A filter is thus only ever populated at + * construction and, from then on, only grown by add/union -- never re-set. + */ + filter = bloom_create_from_bitset(total_elems, + AnserBloomWorkMemKb(max_payload_bytes), + seed, + (const unsigned char *) payload + + sizeof(AnserBloomPartHeader), + payload_len - sizeof(AnserBloomPartHeader)); + if (filter == NULL) + return NULL; + + if (part_index != NULL) + *part_index = header->part_index; + if (total_parts != NULL) + *total_parts = header->total_parts; + return filter; +} + +/* + * Validate the wire framing of a part header. The bitset parameters are not + * carried on the wire (both ends rebuild the filter from the shared plan + * parameters), so this only checks the framing: magic/version, a sane fold + * count, and that the payload carries a header plus at least some bitset. The + * authoritative size check -- that the received bitset matches the size the local + * parameters imply -- is done in AnserBloomDeserializePart. + */ +static bool +AnserBloomValidateHeader(const AnserBloomPartHeader *header, Size payload_len) +{ + if (header == NULL) + return false; + + if (header->magic != ANSER_BLOOM_PART_MAGIC || + header->version != ANSER_BLOOM_PART_VERSION) + return false; + + if (header->total_parts == 0 || header->part_index >= header->total_parts) + return false; + + return payload_len > sizeof(AnserBloomPartHeader); +} diff --git a/gpcontrib/anser/src/anserinit.c b/gpcontrib/anser/src/anserinit.c new file mode 100644 index 00000000000..da6cb30e96d --- /dev/null +++ b/gpcontrib/anser/src/anserinit.c @@ -0,0 +1,328 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserinit.c + * Module entry point: GUCs and the core hooks Anser hangs off. + * + * Anser is a shared_preload_libraries extension. Everything it needs from the + * server is reached through an existing extensibility point: + * + * planner_hook runtime-filter injection + * RegisterCustomScanMethods the injected plan nodes + * cdbdisp_notify_hook parts arriving from segments + * ExecutorRun/Finish_hook knowing whether a query is still executing + * ExecutorEnd_hook dropping a query's channels + * + * It must be preloaded, because a segment backend deserializing a dispatched + * plan has no opportunity to load the library first. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserinit.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/xact.h" +#include "anser.h" +#include "anserplan.h" +#include "ansersideband.h" +#include "cdb/cdbdisp.h" +#include "cdb/cdbvars.h" +#include "executor/executor.h" +#include "miscadmin.h" +#include "optimizer/planner.h" +#include "utils/guc.h" + +PG_MODULE_MAGIC; + +void _PG_init(void); + +bool gp_anser_enable = false; +bool gp_anser_runtime_filter = false; +bool gp_anser_debug = false; +int gp_anser_max_info_size = 64 * 1024 * 1024 + 1024 * 1024; +int gp_anser_timeout_ms = 100000; + +static void anser_define_gucs(void); +static PlannedStmt *anser_planner(Query *parse, const char *query_string, + int cursorOptions, ParamListInfo boundParams, + OptimizerOptions *optimizer_options); + +static planner_hook_type prev_planner_hook = NULL; +static ExecutorRun_hook_type prev_ExecutorRun_hook = NULL; +static ExecutorFinish_hook_type prev_ExecutorFinish_hook = NULL; +static ExecutorEnd_hook_type prev_ExecutorEnd_hook = NULL; +static cdbdisp_notify_hook_type prev_cdbdisp_notify_hook = NULL; + +/* + * How many queries are executing right now. A query's channels may only be + * dropped when nothing is left running, and the count has to be taken over + * execution rather than over ExecutorEnd: a statement run through SPI from a + * function body goes through its whole Start/Run/End inside the *outer* + * query's Run, so by the time the inner ExecutorEnd fires the outer one has + * not entered its own End yet. Counting ends alone makes that inner End look + * outermost, and dropping channels there takes the running query's filters + * with it -- the exchange still happens, but every consumer then fails open. + * + * Nested execution is nested inside Run (and inside Finish, where AFTER + * triggers run), which is why those are the two that count. ExecutorEnd only + * reads the result: a query's own End always runs after its own Run returned, + * so an outermost query still sees zero. + */ +static int anser_exec_nesting = 0; + +static bool anser_notify_hook(struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify); +static void anser_executor_run(QueryDesc *queryDesc, ScanDirection direction, + uint64 count, bool execute_once); +static void anser_executor_finish(QueryDesc *queryDesc); +static void anser_executor_end(QueryDesc *queryDesc); +static void anser_xact_callback(XactEvent event, void *arg); + +void +_PG_init(void) +{ + anser_define_gucs(); + + /* + * Only a preloaded library can be relied on to have installed its hooks in + * every backend. Loaded any other way, Anser stays inert: the GUCs exist + * (so a stray setting is not an error) but nothing is wired up. + */ + if (!process_shared_preload_libraries_in_progress) + return; + + prev_planner_hook = planner_hook; + planner_hook = anser_planner; + + /* + * Channels live in backend memory on both ends, so they need a point to be + * dropped. ExecutorEnd covers the normal path; the transaction callback + * catches queries that end by erroring. + */ + prev_ExecutorRun_hook = ExecutorRun_hook; + ExecutorRun_hook = anser_executor_run; + prev_ExecutorFinish_hook = ExecutorFinish_hook; + ExecutorFinish_hook = anser_executor_finish; + prev_ExecutorEnd_hook = ExecutorEnd_hook; + ExecutorEnd_hook = anser_executor_end; + RegisterXactCallback(anser_xact_callback, NULL); + + /* + * Handle Anser notifies arriving from QEs on the dispatch connections. + * Installed unconditionally: it is inert until a segment sends one, and a + * QE that never dispatches never calls it. + */ + prev_cdbdisp_notify_hook = cdbdisp_notify_hook; + cdbdisp_notify_hook = anser_notify_hook; + + /* + * The producer and consumer nodes travel to the segments inside dispatched + * plans, so every backend must be able to resolve their CustomScan methods + * by name. Registering here covers QD and QE alike. + */ + AnserRegisterRuntimeFilterMethods(); +} + +/* + * The subsystem's GUCs. All are "anser.*"-qualified because they belong to a + * loadable module; the C variables keep their gp_anser_ names. + */ +static void +anser_define_gucs(void) +{ + DefineCustomBoolVariable("anser.enable", + "Enables the Anser adaptive information sharing subsystem.", + "When disabled, the plan pass never injects anything and no filters are exchanged.", + &gp_anser_enable, + false, + PGC_SIGHUP, + 0, + NULL, NULL, NULL); + + DefineCustomBoolVariable("anser.runtime_filter", + "Enables injection of Anser runtime bloom filters into plans.", + "Requires anser.enable; the plan pass is a no-op otherwise.", + &gp_anser_runtime_filter, + false, + PGC_USERSET, + GUC_EXPLAIN, + NULL, NULL, NULL); + + /* + * GUC_GPDB_NEED_SYNC on everything a QE reads. A custom GUC gets + * GUC_GPDB_NO_SYNC by default -- gpdb_assign_sync_flag() says so in as many + * words for "the third-part libraries gucs introduced by customer" + * (guc_gp.c) -- and a QE created after the SET then never receives the + * value. The QEs alive at the time do get it, because the SET itself is + * dispatched to them as a command, so the setting appears to work right up + * until a query needs a wider gang and the new processes fall back to the + * compiled-in default. That is not a setting anyone can reason about. + */ + DefineCustomBoolVariable("anser.debug", + "Logs each step of the Anser filter exchange.", + "Traces publish, merge, delivery and receive in the log of the process each happens in.", + &gp_anser_debug, + false, + PGC_USERSET, + GUC_GPDB_NEED_SYNC, + NULL, NULL, NULL); + + DefineCustomIntVariable("anser.max_info_size", + "Sets the maximum byte size of one Anser information record.", + "Caps the serialized payload a channel may carry, and with it the effective bloom-filter size. The default holds a full 64 MB bitset plus its serialized-part header.", + &gp_anser_max_info_size, + 64 * 1024 * 1024 + 1024 * 1024, 1, INT_MAX, + PGC_USERSET, + 0, + NULL, NULL, NULL); + + DefineCustomIntVariable("anser.timeout_ms", + "Sets how long an Anser consumer waits for its filter.", + "On expiry the consumer runs unfiltered. The deadline matters because a producer that gets squelched never publishes at all. The default is generous because delivery is serial: the coordinator writes the merged filter to one subscriber at a time, so the last of a wide slice waits for all the writes before it.", + &gp_anser_timeout_ms, + 100000, 0, INT_MAX, + PGC_USERSET, + GUC_UNIT_MS | GUC_GPDB_NEED_SYNC, + NULL, NULL, NULL); + + MarkGUCPrefixReserved("anser"); +} + +/* + * Plan the query as usual, then hand the finished tree to the runtime-filter + * pass. Wrapping the hook this way covers both optimizers, because ORCA is + * dispatched from inside standard_planner(). + */ +static PlannedStmt * +anser_planner(Query *parse, const char *query_string, int cursorOptions, + ParamListInfo boundParams, OptimizerOptions *optimizer_options) +{ + PlannedStmt *result; + + if (prev_planner_hook) + result = prev_planner_hook(parse, query_string, cursorOptions, + boundParams, optimizer_options); + else + result = standard_planner(parse, query_string, cursorOptions, + boundParams, optimizer_options); + + AnserApplyRuntimeFilters(result); + + return result; +} + +/* + * Dispatch a QE notify to Anser, then to whoever held the hook before us. + * + * cdbdisp_notify_hook is a single pointer, so an extension that overwrites it + * silently swallows every notify the other one was waiting for. Anser's + * handler already declines anything that is not addressed to it -- it returns + * false for any channel other than "anser_rf" -- so all that is missing is the + * fallthrough. Returning false from here means nobody claimed the notify and + * the dispatcher should handle it itself. + */ +static bool +anser_notify_hook(struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify) +{ + if (AnserDispatchNotifyHandler(dispatchResult, notify)) + return true; + + if (prev_cdbdisp_notify_hook) + return prev_cdbdisp_notify_hook(dispatchResult, notify); + + return false; +} + +/* Count a query as executing for as long as it is running. */ +static void +anser_executor_run(QueryDesc *queryDesc, ScanDirection direction, + uint64 count, bool execute_once) +{ + anser_exec_nesting++; + PG_TRY(); + { + if (prev_ExecutorRun_hook) + prev_ExecutorRun_hook(queryDesc, direction, count, execute_once); + else + standard_ExecutorRun(queryDesc, direction, count, execute_once); + } + PG_FINALLY(); + { + anser_exec_nesting--; + } + PG_END_TRY(); +} + +/* The same, for the phase that runs AFTER triggers. */ +static void +anser_executor_finish(QueryDesc *queryDesc) +{ + anser_exec_nesting++; + PG_TRY(); + { + if (prev_ExecutorFinish_hook) + prev_ExecutorFinish_hook(queryDesc); + else + standard_ExecutorFinish(queryDesc); + } + PG_FINALLY(); + { + anser_exec_nesting--; + } + PG_END_TRY(); +} + +/* + * Drop the transport's per-query state, once nothing is executing. + * + * See anser_exec_nesting: a query ending while another is still running is an + * inner statement of that query, and its channels are not the ones to drop. + */ +static void +anser_executor_end(QueryDesc *queryDesc) +{ + if (prev_ExecutorEnd_hook) + prev_ExecutorEnd_hook(queryDesc); + else + standard_ExecutorEnd(queryDesc); + + if (anser_exec_nesting == 0) + AnserSidebandResetAll(); +} + +static void +anser_xact_callback(XactEvent event, void *arg) +{ + /* + * Deliberately does not touch anser_exec_nesting. PG_FINALLY unwinds it + * on the error path, and XACT_EVENT_ABORT is only reached from + * AbortTransaction() at a statement boundary, by which point every + * executor frame is gone -- so there is nothing here to correct. Forcing + * it to zero would instead risk driving it negative if a frame ever did + * outlive the callback, and a negative count never compares equal to zero + * again: the resets below would stop happening for the rest of the + * session. auto_explain and pg_stat_statements keep their counters the + * same way, on PG_FINALLY alone. + */ + if (event == XACT_EVENT_ABORT || event == XACT_EVENT_PARALLEL_ABORT) + AnserSidebandResetAll(); +} diff --git a/gpcontrib/anser/src/anserpayload.c b/gpcontrib/anser/src/anserpayload.c new file mode 100644 index 00000000000..2926a15a3e4 --- /dev/null +++ b/gpcontrib/anser/src/anserpayload.c @@ -0,0 +1,108 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserpayload.c + * The registry of payload types. + * + * One row per kind of runtime information a channel can carry. See + * anserpayload.h for what a row means and how to add one. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserpayload.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "anserfilter.h" +#include "anserpayload.h" + +/* + * Indexed by the wire byte itself, so a lookup is one load and registering a + * type is one line (the pattern syscache.c:154 uses for its cache + * descriptors). Unregistered slots are all-zero, which is the answer we want + * for every question asked of them: no fold, no checksum, no name. + * + * Only two of the 256 slots are used, so this costs 8 KB of read-only data to + * hold mostly zeroes. That is the whole point: the alternative ways to reach a + * descriptor in constant time -- a second array mapping code to row, or a + * switch -- are extra code that has to be kept in step with the table, and the + * 8 KB buys them all away. A linear search over a two-row table would be just + * as fast in practice; what it would not be is impossible to get wrong. + */ +static const AnserPayloadOps AnserPayloadTable[PG_UINT8_MAX + 1] = +{ + /* + * A subscription: header and key only. It has no body to fold or to + * checksum, but its header still is checksummed -- that is what protects + * the condition key it is asking to be served on. + */ + [(unsigned char) ANSER_PAYLOAD_NONE] = { + ANSER_PAYLOAD_NONE, "none", false, NULL, NULL + }, + + /* + * A bloom filter part. AnserBloomFoldPartInPlace already has exactly the + * fold contract -- it validates both sides before touching the accumulator + * and ORs the bitsets -- so it is used directly rather than wrapped, as is + * the fill check for worth_delivering. + */ + [(unsigned char) ANSER_PAYLOAD_BLOOM] = { + ANSER_PAYLOAD_BLOOM, "bloom", true, AnserBloomFoldPartInPlace, + AnserBloomPartWorthSending + } +}; + +/* + * Resolve a code, or NULL if nothing has registered it. + * + * The NULL matters: 'code' reaches us straight off the wire, so "no such type" + * is a case that has to be reportable rather than silently defaulted. It is + * also the diagnostic that catches a payload type given a code in the header + * but no row in the table. + */ +const AnserPayloadOps * +AnserPayloadLookup(char code) +{ + /* + * Cast before indexing. 'char' is signed on x86, and the code is an + * untrusted byte: without this, a 0x80-0xff code from a corrupted or + * hostile message would index the table at a negative offset. + */ + const AnserPayloadOps *ops = &AnserPayloadTable[(unsigned char) code]; + + if (ops->code == '\0') + return NULL; + + /* The index is authoritative; a row filed under the wrong code is a typo. */ + Assert(ops->code == code); + + return ops; +} + +bool +AnserPayloadChecksumsBody(char code) +{ + /* + * No presence check: an unregistered code lands on a zeroed slot, and + * "false" is exactly right for it -- there is nothing to checksum that we + * would know how to use anyway. + */ + return AnserPayloadTable[(unsigned char) code].checksum_body; +} diff --git a/gpcontrib/anser/src/anserplan.c b/gpcontrib/anser/src/anserplan.c new file mode 100644 index 00000000000..8a00fe2b3d1 --- /dev/null +++ b/gpcontrib/anser/src/anserplan.c @@ -0,0 +1,788 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserplan.c + * Post-planning transformation that injects Anser runtime bloom-filter + * producer/consumer CustomScan nodes into a finished plan tree. + * + * The pass runs once from planner() (after both the Postgres planner and ORCA, + * and after set_plan_references / the cdbllize slice passes), recognizes one + * supported join shape, and inserts a producer above the base scan the join's + * build key comes from and a consumer above the base scan its probe key comes + * from. Either side may be reached through the joins before it, so a chain of + * joins gets a filter per join, and two joins reaching the same relation stack + * their filters on it. See anserplan.h for the rationale. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserplan.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "anser.h" +#include "anserfilter.h" +#include "anserplan.h" +#include "cdb/cdbvars.h" +#include "catalog/pg_type.h" +#include "nodes/nodeFuncs.h" +#include "nodes/pg_list.h" +#include "utils/acl.h" +#include "utils/fmgroids.h" +#include "utils/lsyscache.h" + +/* Runtime-filter bloom size bounds (realized bitset bytes). */ +#define ANSER_RF_MIN_BYTES (1024 * 1024) /* bloom_create's 1 MB floor */ +#define ANSER_RF_MAX_BYTES (64 * 1024 * 1024) +#define ANSER_RF_HEADER_ROOM 64 + +/* Per-statement state for the injection pass. */ +typedef struct AnserInjectCtx +{ + PlannedStmt *stmt; /* for slices[]: see AnserSliceProducers */ + int slice_index; /* slice the subtree being walked runs in */ + Bitmapset *nestloop_params; /* PARAM_EXECs an enclosing nested loop + * reassigns per outer tuple; see + * anser_inject_walk */ + uint32 next_condition_id; + int next_plan_node_id; + List *consumer_keys; /* condition_keys already given a consumer node; + * enforces one consumer per channel (see + * anser_try_inject) */ +} AnserInjectCtx; + +static int anser_max_plan_node_id(Plan *plan); +static bool anser_hashjoin_keys(HashJoin *hj, AttrNumber *inner_attno, + AttrNumber *outer_attno); +static void anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx); +static void anser_inject_walk(Plan *plan, AnserInjectCtx *ctx); + +void +AnserApplyRuntimeFilters(PlannedStmt *stmt) +{ + /* + * Opt-in and coordinator-only: the pass runs on the QD where the whole + * PlannedStmt is available, and only when the operator has enabled the + * Anser subsystem and the runtime-filter feature. Anything else is left + * completely untouched. + */ + if (!gp_anser_enable || !gp_anser_runtime_filter) + return; + if (Gp_role != GP_ROLE_DISPATCH) + return; + if (stmt == NULL || stmt->commandType != CMD_SELECT || stmt->planTree == NULL) + return; + + { + /* + * Zeroed rather than filled field by field. The fields below say what + * they mean and are kept for that, but a pointer field added later and + * missed here would hold whatever was on the stack -- and the first + * thing done with nestloop_params is bms_copy(), which reads a word + * count out of it and allocates that much. + */ + AnserInjectCtx ctx = {0}; + ListCell *lc; + int maxid; + + /* + * Injected nodes need plan_node_ids unique across the whole statement. + * set_plan_references already numbered every existing node, so continue + * past the current maximum (planTree + subplans). + * + * We walk the tree because neither planner's id counter survives to + * this hook (the Postgres planner counts in a standard_planner() + * local; ORCA counts inside its DXL translation context) and + * PlannedStmt carries no max-id field -- walking is the only + * planner-agnostic option, and cheap at this hook point. + * + * We take the max, not the node count: "count == next free id" + * assumes dense numbering, which ORCA's CIdGenerator and third-party + * planner_hooks do not promise. max + 1 is correct under any + * assignment scheme. + */ + maxid = anser_max_plan_node_id(stmt->planTree); + foreach(lc, stmt->subplans) + maxid = Max(maxid, anser_max_plan_node_id((Plan *) lfirst(lc))); + + ctx.stmt = stmt; + ctx.slice_index = 0; /* the root runs in the coordinator's slice */ + ctx.next_condition_id = 0; + ctx.next_plan_node_id = maxid + 1; + ctx.consumer_keys = NIL; + ctx.nestloop_params = NULL; /* no loop encloses the root */ + + anser_inject_walk(stmt->planTree, &ctx); + } +} + +/* + * Largest plan_node_id in a plan subtree. + * + * This has to see every node in the subtree, because the result is the + * high-water mark injected nodes are numbered from: an id it misses is an id we + * hand out a second time. So unlike anser_inject_walk, which deliberately only + * follows the shapes it injects into, this descends every child list there is. + * Getting that wrong corrupts a plan the pass would otherwise have left alone -- + * an Append (a partitioned table, a UNION) is enough to trigger it. + * + * InitPlans need no arm here: a SubPlan only references stmt->subplans by index, + * and AnserApplyRuntimeFilters walks that list separately. + */ +static int +anser_max_plan_node_id(Plan *plan) +{ + int m; + ListCell *lc; + + if (plan == NULL) + return 0; + + m = plan->plan_node_id; + m = Max(m, anser_max_plan_node_id(outerPlan(plan))); + m = Max(m, anser_max_plan_node_id(innerPlan(plan))); + + switch (nodeTag(plan)) + { + case T_Append: + foreach(lc, ((Append *) plan)->appendplans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + break; + case T_MergeAppend: + foreach(lc, ((MergeAppend *) plan)->mergeplans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + break; + case T_Sequence: + foreach(lc, ((Sequence *) plan)->subplans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + break; + case T_BitmapAnd: + foreach(lc, ((BitmapAnd *) plan)->bitmapplans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + break; + case T_BitmapOr: + foreach(lc, ((BitmapOr *) plan)->bitmapplans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + break; + case T_SubqueryScan: + m = Max(m, anser_max_plan_node_id(((SubqueryScan *) plan)->subplan)); + break; + case T_CustomScan: + foreach(lc, ((CustomScan *) plan)->custom_plans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + break; + default: + break; + } + + return m; +} + +/* + * Compute the bloom sizing to hand both the producer and consumer helpers, from + * the estimated build cardinality. Both call AnserBloomCreate (== bloom_create) + * with the SAME (total_elems, max_payload) so they realize an identical filter; + * we mirror bloom_create's own math here so `planned_bytes` (shown in EXPLAIN) + * equals the realized bitset: target ~2 bytes/element, floor at 1 MB, cap at the + * server payload budget, round DOWN to a power of two. + */ +bool +AnserRuntimeFilterSize(double est_rows, int64 *total_elems, int64 *max_payload, + int64 *planned_bytes) +{ + int64 cap_bytes; + int64 elems; + int64 target_bytes; + int64 realized; + + /* Largest bitset that fits the server payload cap, and our own ceiling. */ + cap_bytes = Min((int64) ANSER_RF_MAX_BYTES, + (int64) gp_anser_max_info_size - ANSER_RF_HEADER_ROOM); + if (cap_bytes < ANSER_RF_MIN_BYTES) + return false; /* cap too small to hold even a floor-sized filter */ + + /* + * The element count stays the honest estimate; only the *size* is clamped. + * + * It is tempting to clamp elems instead -- it is the number that drives the + * size, so bounding it bounds the bitset in one step. That is a trap. + * total_elems is also what optimal_k() sizes the hash count from, so + * understating it understates k: a 128M-row build side reported as 33.5M + * gets k=10, when the optimum for 128M keys in a 512 Mbit filter is k=3. + * Since the producer inserts all 128M keys regardless of what we wrote + * down, the result is a filter with a 38% false positive rate where 15% was + * available. Lowering the declared count never makes a filter fit; it only + * relabels it, and then misconfigures it. + * + * INT32 is the real bound on elems: custom_private carries it as an Integer + * node. The density check below refuses anything remotely near that. + */ + elems = (est_rows > 0.0) ? (int64) est_rows : 1; + if (elems < 1) + elems = 1; + if (elems > PG_INT32_MAX) + elems = PG_INT32_MAX; + + target_bytes = Min(cap_bytes, Max((int64) ANSER_RF_MIN_BYTES, elems * 2)); + realized = ANSER_RF_MIN_BYTES; + while ((realized << 1) <= target_bytes) + realized <<= 1; + + /* + * Refuse a join whose build side cannot be usefully summarized within the + * payload cap. + * + * Refusing here is the cheapest possible outcome: no producer, no consumer, + * no channel, and nothing for a consumer to wait on and time out against. + * The runtime checks in anserfilter.c exist for when this estimate turns + * out to be wrong, not instead of this one. + */ + if (realized * (double) BITS_PER_BYTE / (double) elems + < ANSER_BLOOM_MIN_BITS_PER_KEY) + return false; + + *total_elems = elems; + *max_payload = cap_bytes + ANSER_RF_HEADER_ROOM; + *planned_bytes = realized; + return true; +} + +/* + * Match a single-column equijoin over plain (by-value) Vars and return the + * inner (build) and outer (probe) key attnos. After set_plan_references the + * operands are INNER_VAR / OUTER_VAR references into the join's child tlists. + */ +static bool +anser_hashjoin_keys(HashJoin *hj, AttrNumber *inner_attno, AttrNumber *outer_attno) +{ + OpExpr *op; + Node *l; + Node *r; + Var *outer_var; + Var *inner_var; + + if (list_length(hj->hashclauses) != 1) + return false; + op = (OpExpr *) linitial(hj->hashclauses); + if (!IsA(op, OpExpr) || list_length(op->args) != 2) + return false; + + l = (Node *) linitial(op->args); + r = (Node *) lsecond(op->args); + while (l != NULL && IsA(l, RelabelType)) + l = (Node *) ((RelabelType *) l)->arg; + while (r != NULL && IsA(r, RelabelType)) + r = (Node *) ((RelabelType *) r)->arg; + if (l == NULL || r == NULL || !IsA(l, Var) || !IsA(r, Var)) + return false; + + if (((Var *) l)->varno == OUTER_VAR && ((Var *) r)->varno == INNER_VAR) + { + outer_var = (Var *) l; + inner_var = (Var *) r; + } + else if (((Var *) l)->varno == INNER_VAR && ((Var *) r)->varno == OUTER_VAR) + { + outer_var = (Var *) r; + inner_var = (Var *) l; + } + else + return false; + + /* + * Producer and consumer hash the raw Datum bytes, which is only correct + * when SQL equality coincides with bitwise Datum equality of the key. + * That requires the SAME by-value type on both sides: cross-type equijoins + * (e.g. float4 = float8, date = timestamp) hash different bit patterns for + * equal values, and floats are excluded even same-typed because -0.0 and + * 0.0 compare equal but are not bitwise equal. Anything looser could + * prune rows that actually join. + */ + if (inner_var->vartype != outer_var->vartype) + return false; + if (!get_typbyval(inner_var->vartype)) + return false; + if (inner_var->vartype == FLOAT4OID || inner_var->vartype == FLOAT8OID) + return false; + + *inner_attno = inner_var->varattno; + *outer_attno = outer_var->varattno; + return true; +} + +/* + * Follow one side of a join down to the base SeqScan its key column comes + * from, mapping the key attno through each targetlist on the way. Used for + * both sides: the producer goes above the scan the build key comes from, the + * consumer above the scan the probe key comes from. + * + * Wrapping the base scan (rather than an intermediate node) keeps the injected + * CustomScan's custom_scan_tlist made of base-relation Vars, which (a) deparses + * cleanly in EXPLAIN and (b) is the proven-safe "leaf child" case for MPP + * slice/gang setup. (a) is not a preference: after set_plan_references an + * intermediate node's targetlist holds OUTER_VAR/INNER_VAR Vars, and a + * CustomScan keeps its child in custom_plans rather than in lefttree, so + * copying such a targetlist into custom_scan_tlist leaves EXPLAIN resolving an + * OUTER_VAR against a node with no outer plan -- get_variable() raises "bogus + * varno" (ruleutils.c) and the plan cannot be printed at all. + * + * The descent goes through HashJoins as well as the single-child passthroughs + * (Hash, Motion), which is what lets a chain of joins be filtered: by the third + * join one side is the result of the first two, and the key column still traces + * back to one base relation through it. A filter built that way holds the + * relation's whole key column rather than the subset the earlier joins would + * have left -- a superset, so no row that could join is ever rejected, but less + * selective than filtering on the intermediate result itself. Filtering on the + * intermediate would mean wrapping it, which (a) above rules out. + * + * It also goes through our own nodes, so the second join to reach a relation + * stacks its filter under the first one's rather than replacing it. + * + * On success *parent_out is the node holding the base scan -- pass it to + * anser_relink_child(), which knows that ours hold it in custom_plans and + * everything else in the outer link. A SeqScan is only accepted through one of + * those two: the inner child of a HashJoin is always a Hash. + */ +bool +AnserResolveKeyScan(Plan *top, AttrNumber key_attno, int slice_index, + Plan **parent_out, Plan **scan_out, + AttrNumber *attno_out, int *slice_out) +{ + Plan *node = top; + AttrNumber attno = key_attno; + + for (;;) + { + TargetEntry *tle; + Var *var; + Plan *child; + bool via_outer; + + if (node == NULL || + attno < 1 || attno > list_length(node->targetlist)) + return false; + + tle = (TargetEntry *) list_nth(node->targetlist, attno - 1); + if (tle == NULL || !IsA(tle->expr, Var)) + return false; + var = (Var *) tle->expr; + + /* + * Which child supplies this column. A join is the only node here with + * two of them, and only a HashJoin is descended through -- an Append or + * a SetOp would make the column come from several relations at once, + * and then no single scan holds the keys the filter would claim. + * + * One of our own nodes is a passthrough with an identity targetlist, so + * its columns are its child's columns in the same order and the attno + * carries over unchanged. Descending through it is what lets a second + * join filter a relation a first join already filters. + */ + if (var->varno == OUTER_VAR) + { + child = outerPlan(node); + via_outer = true; + } + else if (var->varno == INNER_VAR && IsA(node, HashJoin)) + { + child = innerPlan(node); + via_outer = false; + } + else if (var->varno == INDEX_VAR && AnserIsRuntimeFilterScan(node)) + { + child = (Plan *) linitial(((CustomScan *) node)->custom_plans); + via_outer = true; + } + else + return false; + + if (child == NULL) + return false; + + if (via_outer && IsA(child, SeqScan)) + { + *parent_out = node; + *scan_out = child; + *attno_out = var->varattno; + *slice_out = slice_index; + return true; + } + if (!IsA(child, Hash) && !IsA(child, Motion) && !IsA(child, HashJoin) && + !AnserIsRuntimeFilterScan(child)) + return false; + + /* + * Descending through a Motion moves us into its sending slice, which + * is where the producer will end up -- and whose width decides how + * many parts the coordinator has to wait for. + */ + if (IsA(child, Motion)) + slice_index = ((Motion *) child)->motionID; + + node = child; + attno = var->varattno; + } +} + +/* + * Put `child` where the scan the descent ended on used to be. The link is the + * outer one for every node the descent walks except one of ours, which holds + * its child in custom_plans -- that is the case where a second join filters a + * relation a first join already filters, and the new node goes below the old + * one so that both see every row. + */ +static void +anser_relink_child(Plan *parent, Plan *child) +{ + if (AnserIsRuntimeFilterScan(parent)) + linitial(((CustomScan *) parent)->custom_plans) = child; + else + outerPlan(parent) = child; +} + +/* + * If this HashJoin is the supported shape, inject a producer above the base + * scan its build key comes from and a consumer above the base scan its probe + * key comes from. + */ +static void +anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) +{ + Plan *hash = innerPlan(hj); /* build side */ + Plan *probe = outerPlan(hj); /* probe side */ + Plan *build_parent; + Plan *build_scan; + Plan *probe_parent; + Plan *probe_scan; + AttrNumber inner_attno; + AttrNumber outer_attno; + AttrNumber build_attno; + AttrNumber probe_attno; + int build_slice; + int probe_slice; + int n_producers; + int64 total_elems; + int64 max_payload; + int64 planned_bytes; + uint32 condition_id; + char condition_key[ANSER_CONDITION_KEY_SIZE]; + CustomScan *producer; + CustomScan *consumer; + ListCell *lc; + + if (hj->join.jointype != JOIN_INNER && hj->join.jointype != JOIN_RIGHT) + return; + if (hash == NULL || !IsA(hash, Hash)) + return; + if (probe == NULL) + return; + + /* + * Refuse a build side that depends on a parameter an enclosing nested loop + * reassigns. Such a side yields a different set of keys per outer tuple, + * and nothing here rebuilds: the rescan callbacks rescan their child and + * keep everything else, so the producer stays published and the consumer + * keeps the filter it already received. The second iteration would then be + * probed against the first iteration's keys -- a filter that is no longer a + * superset, which is the one way this feature can drop a row that joins. + * + * extParam is the whole build subtree's dependency on parameters set + * outside it (SS_finalize_plan fills it in before set_plan_references, so + * it is still here), and ctx->nestloop_params is what the nested loops we + * are nested inside actually reassign. An InitPlan's output parameter + * appears in extParam too but is evaluated once, so intersecting against + * the nestloop set rather than testing extParam for emptiness keeps those + * plans eligible. + * + * Only the build side is tested. A parameterized probe side is rescanned + * too, but the filter it probes was built from keys that did not change, so + * reusing it stays correct -- and skipping the rebuild is then a saving. + * + * The alternative is to re-exchange per iteration, which needs a generation + * in the channel key and a barrier across every segment per outer tuple. + * Not worth it for a shape this narrow. + */ + if (bms_overlap(hash->extParam, ctx->nestloop_params)) + return; + + if (!anser_hashjoin_keys(hj, &inner_attno, &outer_attno)) + return; + if (!AnserResolveKeyScan(hash, inner_attno, ctx->slice_index, + &build_parent, &build_scan, &build_attno, + &build_slice)) + return; + + /* + * Where the filter is applied. A probe side that is already a scan is the + * simple case; anything else -- a Motion over the result of the joins + * before this one -- is descended the same way the build side is, and the + * consumer lands on the base scan the probe key comes from. + * + * Filtering that scan is right for the same reason filtering the probe + * side is: every row the probe subtree produces carries that relation's + * key column unchanged, so a row of it whose key the build side does not + * have cannot reach the join's output through any path. A row dropped + * under an outer join inside the subtree comes back NULL-extended instead, + * and a NULL key joins nothing either. + */ + if (IsA(probe, SeqScan)) + { + probe_parent = (Plan *) hj; + probe_scan = probe; + probe_attno = outer_attno; + probe_slice = ctx->slice_index; /* nothing descended through */ + } + else if (!AnserResolveKeyScan(probe, outer_attno, ctx->slice_index, + &probe_parent, &probe_scan, &probe_attno, + &probe_slice)) + return; + + /* + * Nothing to gain from filtering a relation with its own keys, and the + * producer would have to finish before the consumer under it could start. + */ + if (probe_scan == build_scan) + return; + + /* + * Only filter across a Motion. Slices are divided by Motions and nothing + * else, so two scans in one slice are a co-located join, and that is a + * different proposition in both directions. + * + * It is what makes the exchange safe. An Anser exchange is a + * synchronisation point: the consumer stops until the coordinator has + * heard from every producer. Within one slice those are the same process, + * run one after another, so the wait can be on something that has not been + * reached yet -- a consumer placed under an intervening HashJoin waits for + * a producer sitting in that join's inner side, which cannot start until + * the outer side yields the tuple the consumer is holding. defer_first + * covers exactly one tuple of that. Across a Motion the two are separate + * processes and the question does not arise. + * + * And it is what makes it worth doing. Where a Motion already carries the + * rows, the barrier is paid for by work the query was doing anyway; in a + * co-located join it is new cost against a join that was already local, + * and whether it pays for itself is a measurement nobody has made. Until + * someone does, decline. + */ + if (build_slice == probe_slice) + return; + + /* + * How many producers will there be? One per process of the build scan's + * slice. Declining when we cannot tell is the safe direction: a count that + * is too low makes the coordinator complete the channel early and ship a + * filter missing keys, which drops joinable rows. + */ + n_producers = AnserSliceProducers(ctx->stmt, build_slice); + if (n_producers <= 0) + return; + /* + * Sized from the scan the producer wraps, not from the Hash: those are the + * rows whose keys actually go into the filter. They are the same thing + * when the build side is one scan under a Hash, and they are not when the + * descent came through a join -- there the Hash holds the joined result and + * the scan holds the whole key column, which is what gets inserted. + */ + if (!AnserRuntimeFilterSize(build_scan->plan_rows, &total_elems, &max_payload, + &planned_bytes)) + return; + + condition_id = ctx->next_condition_id++; + snprintf(condition_key, sizeof(condition_key), "anser_rf_%u", condition_id); + + /* + * One consumer per channel. Two consumer nodes sharing a channel would + * both be served -- the coordinator pushes to every subscriber -- but they + * would also both count toward nothing: the channel's part count comes from + * the producers, so a second consumer only multiplies deliveries. More to + * the point, the two would be indistinguishable in the debug trace and in + * any future per-channel accounting, so keep the invariant. + * + * Minting a unique condition_id per injection makes this hold by + * construction, so the check never fires. It stays as a guard against + * channel-key collisions if the key derivation ever changes (e.g. keys + * derived from the build's semantic identity, where two joins could share + * one channel). + */ + foreach(lc, ctx->consumer_keys) + { + if (strcmp((const char *) lfirst(lc), condition_key) == 0) + return; + } + + /* Producer wraps the build base scan; keyed by the mapped build attno. */ + producer = AnserBuildBloomProducerScan(build_scan, build_attno, condition_id, + condition_key, total_elems, max_payload, + planned_bytes, n_producers); + producer->scan.plan.plan_node_id = ctx->next_plan_node_id++; + anser_relink_child(build_parent, (Plan *) producer); + + /* + * Consumer wraps the probe base scan; keyed by the mapped probe attno. + * + * Whether it must let the join's first outer tuple through unfiltered + * comes down to whether ExecHashJoin prefetches one before building its + * hash table. It skips the prefetch when it null-fills the inner side -- + * HJ_FILL_INNER, which for the join types injected into means a right join + * -- and otherwise decides on a cost comparison we are not going to + * reproduce here. So: rule it out only where the join type rules it out, + * and defer everywhere else. Wrongly deferring gives up pushdown for that + * consumer; wrongly not deferring deadlocks it against its own producer. + */ + consumer = AnserBuildBloomConsumerScan(probe_scan, probe_attno, condition_id, + condition_key, total_elems, max_payload, + planned_bytes, n_producers, + hj->join.jointype != JOIN_RIGHT); + consumer->scan.plan.plan_node_id = ctx->next_plan_node_id++; + anser_relink_child(probe_parent, (Plan *) consumer); + + /* Record the channel so no later join can add a second consumer on it. */ + ctx->consumer_keys = lappend(ctx->consumer_keys, pstrdup(condition_key)); +} + +/* + * Manual in-place traversal (recurse spine + custom_plans). We do not use + * plan_tree_mutator, whose CustomScan arm does not descend into custom_plans. + */ +static void +anser_inject_walk(Plan *plan, AnserInjectCtx *ctx) +{ + int save_slice = ctx->slice_index; + + if (plan == NULL) + return; + + /* + * A Motion is the boundary between slices, and everything below one runs + * in the sending slice. cdbllize stamps that slice's index into motionID + * (cdbllize.c: "motion->motionID = sendSlice->sliceIndex") and then clears + * senderSliceInfo, so motionID is the only durable way to get here from a + * finished plan. + */ + if (IsA(plan, Motion)) + ctx->slice_index = ((Motion *) plan)->motionID; + + if (IsA(plan, HashJoin)) + anser_try_inject((HashJoin *) plan, ctx); + + anser_inject_walk(outerPlan(plan), ctx); + + /* + * A nested loop re-executes its inner side once per outer tuple, after + * assigning that tuple's values to the PARAM_EXECs in nestParams. Carry + * those paramids down the inner side so anser_try_inject can tell whether a + * build side it is looking at will be rebuilt with different values. + */ + if (IsA(plan, NestLoop) && ((NestLoop *) plan)->nestParams != NIL) + { + Bitmapset *saved = ctx->nestloop_params; + Bitmapset *inner = bms_copy(saved); + ListCell *lc; + + foreach(lc, ((NestLoop *) plan)->nestParams) + inner = bms_add_member(inner, + ((NestLoopParam *) lfirst(lc))->paramno); + + ctx->nestloop_params = inner; + anser_inject_walk(innerPlan(plan), ctx); + ctx->nestloop_params = saved; + bms_free(inner); + } + else + anser_inject_walk(innerPlan(plan), ctx); + + if (IsA(plan, CustomScan)) + { + ListCell *lc; + + foreach(lc, ((CustomScan *) plan)->custom_plans) + anser_inject_walk((Plan *) lfirst(lc), ctx); + } + + ctx->slice_index = save_slice; +} + +/* + * How many processes execute a slice -- which is how many producers will + * publish a part, and therefore how many the coordinator must wait for. + * + * This mirrors FillSliceGangInfo (execUtils.c), which has a different answer + * per gang type: numsegments * parallel_workers for a plain reader or writer, + * but one process for the coordinator's own slice, parallel_workers for a + * singleton reader, and one set per targeted segment under direct dispatch. + * parallel_workers == 0 means one. Each is handled below, because over- + * counting costs every consumer the full anser.timeout_ms: the channel never + * reaches the part count it is waiting for, so it never completes and nobody + * is ever told. Under-counting would be worse -- a filter short of one + * segment's keys rejects rows that join -- which is why none of these guesses. + * + * Both optimizers are covered, and they do differ: for the same query the + * Postgres planner may give a serial build slice (Broadcast Motion 2:8 over a + * plain Seq Scan, so 2 producers) where GPORCA gives a parallel one (Broadcast + * Motion 16:16 over a Parallel Seq Scan, so 16). Reading it from the slice + * means neither case needs special handling. + */ +int +AnserSliceProducers(PlannedStmt *stmt, int slice_index) +{ + PlanSlice *slice; + int factor; + + if (stmt == NULL || stmt->slices == NULL || + slice_index < 0 || slice_index >= stmt->numSlices) + return 0; /* unknown: caller declines to inject */ + + slice = &stmt->slices[slice_index]; + + /* The coordinator's own slice is one process and has no gang. */ + if (slice->gangType == GANGTYPE_UNALLOCATED || + slice->gangType == GANGTYPE_ENTRYDB_READER) + return 1; + + factor = slice->parallel_workers > 0 ? slice->parallel_workers : 1; + + /* + * A singleton reader runs on one segment, whatever the cluster's width: + * FillSliceGangInfo gives it planNumSegments = 1 * factor and fills its + * segment list with factor copies of one segindex. numsegments is not + * even meaningful here -- plannodes.h scopes that field to the + * PRIMARY_READER/WRITER cases. + */ + if (slice->gangType == GANGTYPE_SINGLETON_READER) + return factor; + + /* + * Direct dispatch narrows a reader gang to the segments it actually + * needs, and FillSliceGangInfo builds the segment list from contentIds -- + * factor processes for each. planNumSegments keeps saying + * numsegments * factor, so the segment list is what to count. + * + * An empty list yields zero, which the caller reads as "unknown" and + * declines on, and that is the right answer: a slice dispatched nowhere + * publishes nothing. + */ + if (slice->directDispatch.isDirectDispatch) + return list_length(slice->directDispatch.contentIds) * factor; + + if (slice->numsegments <= 0) + return 0; + + return slice->numsegments * factor; +} diff --git a/gpcontrib/anser/src/anserplanexec.c b/gpcontrib/anser/src/anserplanexec.c new file mode 100644 index 00000000000..1baec034829 --- /dev/null +++ b/gpcontrib/anser/src/anserplanexec.c @@ -0,0 +1,1021 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * anserplanexec.c + * CustomScan providers that carry the Anser runtime bloom filter through + * the executor: a pass-through "producer" above the hash build input that + * observes the build join key and publishes a bloom filter, and a + * pass-through "consumer" above the probe scan that prunes rows whose key is + * definitely absent from the received (unioned) filter. + * + * Both are thin drivers over the helper library in + * anserbloom.h, which selects transport by role + * (segment -> libpq to QD; coordinator -> direct shmem) and unions parts. The + * plan-injection pass (anserplan.c) builds the nodes and stashes the parameters + * in CustomScan.custom_private using the layout in AnserRfPrivateIndex below. + * + * IDENTIFICATION + * gpcontrib/anser/src/anserplanexec.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "access/skey.h" +#include "anser.h" +#include "anserbloom.h" +#include "anserfilter.h" +#include "anserplan.h" +#include "cdb/cdbutil.h" +#include "cdb/cdbvars.h" +#include "commands/explain.h" +#include "executor/executor.h" +#include "lib/bloomfilter.h" +#include "nodes/extensible.h" +#include "nodes/makefuncs.h" +#include "nodes/nodeFuncs.h" +#include "nodes/pg_list.h" +#include "nodes/value.h" +#include "utils/guc.h" + +/* + * Positional layout of CustomScan.custom_private shared with anserplan.c. + * Integer nodes except the last two, which are Strings. Both providers read + * the same list (each ignores fields it does not need). + */ +typedef enum AnserRfPrivateIndex +{ + ANSER_RF_PRIV_CONDITION_ID = 0, /* Integer: channel condition_id */ + ANSER_RF_PRIV_KEY_ATTNO, /* Integer: build/probe key attno */ + ANSER_RF_PRIV_TOTAL_ELEMS, /* Integer: bloom sizing (producer) */ + ANSER_RF_PRIV_MAX_PAYLOAD, /* Integer: bloom sizing (producer) */ + ANSER_RF_PRIV_PLANNED_BYTES, /* Integer: planned bitset bytes (EXPLAIN) */ + ANSER_RF_PRIV_N_PRODUCERS, /* Integer: processes publishing a part */ + ANSER_RF_PRIV_DEFER_FIRST, /* Integer: consumer must let the join's + * empty-outer prefetch through */ + ANSER_RF_PRIV_CONDITION_KEY, /* String: channel condition_key */ + ANSER_RF_PRIV__COUNT +} AnserRfPrivateIndex; + +/* Registration-timeout for the consumer's wait-for-registration phase. */ +#define ANSER_RF_REGISTRATION_TIMEOUT_MS ((long) gp_anser_timeout_ms) + +/* + * Producer scan state: feeds the build key of every child tuple into the + * filter and publishes this backend's part once the child is exhausted. + */ +typedef struct AnserBloomProduceScanState +{ + CustomScanState csstate; + AnserBloomFilterProduceState *produce; + AttrNumber key_attno; + int64 planned_bytes; + bool started; /* this process actually executed the node */ + bool my_slice; /* ... and was always going to: the node's + * slice is the one this process runs */ + bool published; +} AnserBloomProduceScanState; + +/* + * Consumer scan state: receives and unions the bloom parts on first use, + * then prunes probe rows whose key is definitely absent from the filter. + */ +typedef struct AnserBloomConsumeScanState +{ + CustomScanState csstate; + AnserBloomFilterConsumeState *consume; + bloom_filter *filter; /* NULL => fail open (pass everything) */ + AttrNumber key_attno; + int64 planned_bytes; + bool received; /* have we run the receive/union yet? */ + bool defer_first; /* this join prefetches one outer tuple before + * building its hash; see anser_consume_receive() */ + bool probed; /* has the join pulled a tuple from us before? */ + bool pushed_down; /* filter handed to the child scan as an + * SK_BLOOM_FILTER scan key (the scan filters, + * we pass through) */ +} AnserBloomConsumeScanState; + +/* Provider callbacks. */ +static Node *anser_produce_create_state(CustomScan *cscan); +static void anser_produce_begin(CustomScanState *node, EState *estate, int eflags); +static TupleTableSlot *anser_produce_exec(CustomScanState *node); +static void anser_produce_end(CustomScanState *node); +static void anser_produce_rescan(CustomScanState *node); +static void anser_produce_explain(CustomScanState *node, List *ancestors, + ExplainState *es); + +static Node *anser_consume_create_state(CustomScan *cscan); +static void anser_consume_begin(CustomScanState *node, EState *estate, int eflags); +static TupleTableSlot *anser_consume_exec(CustomScanState *node); +static void anser_consume_end(CustomScanState *node); +static void anser_consume_rescan(CustomScanState *node); +static void anser_consume_explain(CustomScanState *node, List *ancestors, + ExplainState *es); + +static const CustomScanMethods anser_produce_scan_methods = +{ + .CustomName = "Anser Bloom Producer", + .CreateCustomScanState = anser_produce_create_state, +}; + +static const CustomScanMethods anser_consume_scan_methods = +{ + .CustomName = "Anser Bloom Consumer", + .CreateCustomScanState = anser_consume_create_state, +}; + +static const CustomExecMethods anser_produce_exec_methods = +{ + .CustomName = "Anser Bloom Producer", + .BeginCustomScan = anser_produce_begin, + .ExecCustomScan = anser_produce_exec, + .EndCustomScan = anser_produce_end, + .ReScanCustomScan = anser_produce_rescan, + .ExplainCustomScan = anser_produce_explain, +}; + +static const CustomExecMethods anser_consume_exec_methods = +{ + .CustomName = "Anser Bloom Consumer", + .BeginCustomScan = anser_consume_begin, + .ExecCustomScan = anser_consume_exec, + .EndCustomScan = anser_consume_end, + .ReScanCustomScan = anser_consume_rescan, + .ExplainCustomScan = anser_consume_explain, +}; + +void +AnserRegisterRuntimeFilterMethods(void) +{ + RegisterCustomScanMethods(&anser_produce_scan_methods); + RegisterCustomScanMethods(&anser_consume_scan_methods); +} + +/* ---- node builders (called from the injection pass in anserplan.c) ---- */ + +/* + * Identity output targetlist for a pass-through CustomScan: a Var per child + * column referencing the scan tuple via INDEX_VAR, so ExecScan projects the + * child tuple through unchanged. (Post-setrefs we build this by hand rather + * than relying on set_customscan_references.) + */ +static List * +anser_rf_identity_tlist(List *child_tlist) +{ + List *tlist = NIL; + ListCell *lc; + AttrNumber attno = 0; + + foreach(lc, child_tlist) + { + TargetEntry *tle = (TargetEntry *) lfirst(lc); + Var *var; + + attno++; + var = makeVar(INDEX_VAR, attno, + exprType((Node *) tle->expr), + exprTypmod((Node *) tle->expr), + exprCollation((Node *) tle->expr), + 0); + tlist = lappend(tlist, + makeTargetEntry((Expr *) var, attno, + tle->resname ? pstrdup(tle->resname) : NULL, + tle->resjunk)); + } + + return tlist; +} + +static CustomScan * +anser_build_rf_scan(const CustomScanMethods *methods, Plan *child, + AttrNumber key_attno, uint32 condition_id, + const char *condition_key, int64 total_elems, + Size max_payload_bytes, int64 planned_bytes, + int n_producers, bool defer_first) +{ + CustomScan *cs = makeNode(CustomScan); + List *priv = NIL; + + /* custom_private, in AnserRfPrivateIndex order. */ + priv = lappend(priv, makeInteger((int) condition_id)); + priv = lappend(priv, makeInteger((int) key_attno)); + priv = lappend(priv, makeInteger((int) total_elems)); + priv = lappend(priv, makeInteger((int) max_payload_bytes)); + priv = lappend(priv, makeInteger((int) planned_bytes)); + priv = lappend(priv, makeInteger(n_producers)); + priv = lappend(priv, makeInteger(defer_first ? 1 : 0)); + priv = lappend(priv, makeString(pstrdup(condition_key))); + + cs->scan.plan.targetlist = anser_rf_identity_tlist(child->targetlist); + cs->scan.plan.qual = NIL; + cs->scan.plan.lefttree = NULL; + cs->scan.plan.righttree = NULL; + cs->scan.plan.startup_cost = child->startup_cost; + cs->scan.plan.total_cost = child->total_cost; + cs->scan.plan.plan_rows = child->plan_rows; + cs->scan.plan.plan_width = child->plan_width; + /* + * Not parallel-aware, and that is not an omission. parallel_aware only + * drives PostgreSQL's DSM callbacks, which are reached from Gather; an MPP + * parallel slice has no Gather and no ParallelContext, so the callbacks + * would never fire. Each process of the slice is a full QE with its own + * dispatch connection, so each publishes and subscribes for itself and the + * coordinator folds the lot -- which is why n_producers above has to be the + * slice's process count rather than the segment count. + */ + cs->scan.plan.parallel_aware = false; + cs->scan.plan.parallel_safe = child->parallel_safe; + cs->scan.plan.flow = (Flow *) copyObject(child->flow); + cs->scan.scanrelid = 0; + cs->flags = 0; + cs->custom_plans = list_make1(child); + cs->custom_exprs = NIL; + cs->custom_private = priv; + cs->custom_scan_tlist = copyObject(child->targetlist); + cs->methods = methods; + + return cs; +} + +/* + * Is this one of our own nodes? + * + * The injection pass has to recognise them because it can meet one where it + * expects a scan: two joins whose keys trace to the same base relation both + * want to filter it, and the second arrives to find the first already there. + * + * Compared by method table rather than by name, which holds in any backend and + * not just the one that built the plan: a CustomScan travels as its + * methods->CustomName, and the reader resolves that name through + * GetCustomScanMethods() back to the very table RegisterCustomScanMethods() + * stored -- ours, because _PG_init registers it in every backend. A process + * that had not registered it could not deserialize the plan at all. + */ +bool +AnserIsRuntimeFilterScan(const Plan *plan) +{ + const CustomScan *cscan; + + if (plan == NULL || !IsA(plan, CustomScan)) + return false; + + cscan = (const CustomScan *) plan; + return cscan->methods == &anser_produce_scan_methods || + cscan->methods == &anser_consume_scan_methods; +} + +CustomScan * +AnserBuildBloomProducerScan(Plan *child, AttrNumber key_attno, + uint32 condition_id, const char *condition_key, + int64 total_elems, Size max_payload_bytes, + int64 planned_bytes, int n_producers) +{ + return anser_build_rf_scan(&anser_produce_scan_methods, child, key_attno, + condition_id, condition_key, total_elems, + max_payload_bytes, planned_bytes, n_producers, + false); +} + +CustomScan * +AnserBuildBloomConsumerScan(Plan *child, AttrNumber key_attno, + uint32 condition_id, const char *condition_key, + int64 total_elems, Size max_payload_bytes, + int64 planned_bytes, int n_producers, + bool defer_first) +{ + return anser_build_rf_scan(&anser_consume_scan_methods, child, key_attno, + condition_id, condition_key, total_elems, + max_payload_bytes, planned_bytes, n_producers, + defer_first); +} + +/* ---- shared helpers ---- */ + +static void +anser_rf_build_key(CustomScan *cscan, AnserChannelKey *key) +{ + List *priv = cscan->custom_private; + int condition_id = intVal(list_nth(priv, ANSER_RF_PRIV_CONDITION_ID)); + char *condition_key = strVal(list_nth(priv, ANSER_RF_PRIV_CONDITION_KEY)); + + MemSet(key, 0, sizeof(*key)); + key->gp_session_id = gp_session_id; + key->gp_command_count = gp_command_count; + key->condition_id = (uint32) condition_id; + strlcpy(key->condition_key, condition_key, ANSER_CONDITION_KEY_SIZE); +} + +/* + * How many parts the coordinator must fold, and this backend's part index. + * + * The count comes from the plan, not from getgpsegmentCount(), because a + * parallel slice runs numsegments * parallel_workers processes and every one + * of them publishes its own part. Counting segments instead would make the + * coordinator declare the channel complete after the first nsegments parts + * and hand consumers a filter missing most of the build keys -- a bloom filter + * short of keys gives false negatives, which silently drops joinable rows. + * Only the planner knows the slice width, so it stamps it into custom_private + * (see AnserRuntimeFilterSize's caller in anserplan.c). + * + * part_index is diagnostics only: completion is by count, and the serialized + * part carries its own (0, 1). Under parallel execution the workers of one + * segment therefore share an index, which is why the trace prints the pid too. + */ +static void +anser_rf_part_info(CustomScan *cscan, uint32 *part_index, uint32 *total_parts) +{ + int n_producers = intVal(list_nth(cscan->custom_private, + ANSER_RF_PRIV_N_PRODUCERS)); + + if (Gp_role == GP_ROLE_EXECUTE) + { + *part_index = (uint32) GpIdentity.segindex; + *total_parts = (uint32) Max(n_producers, 1); + } + else + { + /* + * The coordinator produces locally as a single part. This is the + * whole-plan-on-the-QD case (no gang), not the parallel one. + */ + *part_index = 0; + *total_parts = 1; + } +} + +/* Move the child tuple into our scan slot (positional pass-through). */ +static TupleTableSlot * +anser_rf_child_slot(CustomScanState *node, TupleTableSlot *childslot) +{ + ExecCopySlot(node->ss.ss_ScanTupleSlot, childslot); + return node->ss.ss_ScanTupleSlot; +} + +static bool +anser_rf_recheck(CustomScanState *node, TupleTableSlot *slot) +{ + return true; +} + +/* ---- producer ---- */ + +static Node * +anser_produce_create_state(CustomScan *cscan) +{ + AnserBloomProduceScanState *st = (AnserBloomProduceScanState *) + newNode(sizeof(AnserBloomProduceScanState), T_CustomScanState); + + st->csstate.methods = &anser_produce_exec_methods; + return (Node *) st; +} + +static void +anser_produce_begin(CustomScanState *node, EState *estate, int eflags) +{ + AnserBloomProduceScanState *st = (AnserBloomProduceScanState *) node; + CustomScan *cscan = (CustomScan *) node->ss.ps.plan; + List *priv = cscan->custom_private; + Plan *child = (Plan *) linitial(cscan->custom_plans); + AnserChannelKey key; + uint32 part_index; + uint32 total_parts; + int64 total_elems = intVal(list_nth(priv, ANSER_RF_PRIV_TOTAL_ELEMS)); + Size max_payload = (Size) intVal(list_nth(priv, ANSER_RF_PRIV_MAX_PAYLOAD)); + + st->key_attno = (AttrNumber) intVal(list_nth(priv, ANSER_RF_PRIV_KEY_ATTNO)); + st->planned_bytes = intVal(list_nth(priv, ANSER_RF_PRIV_PLANNED_BYTES)); + st->started = false; + st->published = false; + + /* + * Whether this process is the one that runs the node's slice -- which is + * not the same question as whether the node ends up executing, and has to + * be asked here rather than at end time. During initialization + * currentSliceId names the slice being initialized, alien or not; once + * execution starts it always names the local one, so the distinction is + * gone by then. + */ + st->my_slice = (estate->currentSliceId == LocallyExecutingSliceIndex(estate)); + + anser_rf_build_key(cscan, &key); + anser_rf_part_info(cscan, &part_index, &total_parts); + + st->produce = ExecInitAnserBloomFilterProduce(&key, total_elems, max_payload, + part_index, total_parts); + ANSER_DEBUG("anser: producer init cond=%u part=%u/%u elems=" INT64_FORMAT " payload=%zu state=%s", + key.condition_id, part_index, total_parts, total_elems, + max_payload, st->produce != NULL ? "ok" : "NULL"); + + node->custom_ps = list_make1(ExecInitNode(child, estate, eflags)); +} + +/* + * Pass-through that feeds the build key of every child tuple into the filter, + * and publishes once the child is exhausted. + */ +static TupleTableSlot * +anser_produce_next(CustomScanState *node) +{ + AnserBloomProduceScanState *st = (AnserBloomProduceScanState *) node; + PlanState *child = (PlanState *) linitial(node->custom_ps); + TupleTableSlot *slot; + + /* + * Every process that deserializes the plan builds this node, but only the + * ones running its slice ever execute it; the rest must stay silent. + */ + if (!st->started) + { + st->started = true; + + /* + * No filter means the estimate that justified this node was wrong by + * enough that one cannot usefully be built. Say so now, on the first + * tuple, rather than after scanning the whole build side: the consumers + * are sitting on their timeout, and every millisecond of it is wasted. + * + * This is also the earliest point at which cancelling is safe. Doing + * it at ExecInit time would cancel channels from the coordinator, which + * builds this node for every slice but executes none of them. + */ + if (st->produce != NULL && !st->published && + !ExecAnserBloomFilterProduceHasFilter(st->produce)) + { + ANSER_DEBUG("anser: producer has no usable filter; cancelling before the scan"); + (void) ExecAnserBloomFilterProduceCancel(st->produce); + st->published = true; + } + } + + slot = ExecProcNode(child); + + if (TupIsNull(slot)) + { + if (!st->published) + { + ANSER_DEBUG("anser: producer child exhausted, publishing (state=%s)", + st->produce != NULL ? "ok" : "NULL"); + + (void) ExecAnserBloomFilterProducePublish(st->produce); + st->published = true; + } + return NULL; + } + + if (st->produce != NULL) + { + bool isnull; + Datum value = slot_getattr(slot, st->key_attno, &isnull); + + ExecAnserBloomFilterProduceAddDatum(st->produce, value, isnull); + } + + return anser_rf_child_slot(node, slot); +} + +static TupleTableSlot * +anser_produce_exec(CustomScanState *node) +{ + return ExecScan(&node->ss, + (ExecScanAccessMtd) anser_produce_next, + (ExecScanRecheckMtd) anser_rf_recheck); +} + +static void +anser_produce_end(CustomScanState *node) +{ + AnserBloomProduceScanState *st = (AnserBloomProduceScanState *) node; + + /* + * Consumers must be told, one way or another, by every process the + * channel is counting on. Two cases reach here without having published: + * + * The producer ran and was abandoned mid-scan (squelch, or an error + * upstream), so the part it promised will never come. + * + * Or it never ran at all although this process owns its slice -- which + * happens in a co-located join, where ExecHashJoin skips building the + * hash table on a segment whose outer side turned out to be empty. The + * node is initialized, never executed, and used to leave silently; the + * channel then sat one part short of complete and every consumer waited + * out the whole of anser.timeout_ms for it. + * + * What must still stay silent is a node belonging to another slice. + * Cancelling there would destroy a channel this process has no part in. + */ + if (!st->published && st->produce != NULL && (st->started || st->my_slice)) + { + (void) ExecAnserBloomFilterProduceCancel(st->produce); + st->published = true; + } + + if (st->produce != NULL) + ExecEndAnserBloomFilterProduce(st->produce); + st->produce = NULL; + if (node->custom_ps != NIL) + ExecEndNode((PlanState *) linitial(node->custom_ps)); +} + +/* + * Rescan the child and keep every piece of filter state: the producer stays + * published, the consumer keeps the filter it received, and no second exchange + * happens. + * + * That is only correct because the build side cannot produce different keys on + * a second pass, which the injection pass guarantees by refusing any join whose + * build subtree depends on a parameter an enclosing nested loop reassigns (see + * the extParam test in anser_try_inject). Given that, every iteration wants + * the same filter, so rebuilding it would be pure cost -- and a channel is + * keyed per statement, so a second exchange could not be told apart from the + * first anyway. + * + * If that gate is ever loosened, this is the code that has to change with it. + */ +static void +anser_produce_rescan(CustomScanState *node) +{ + if (node->custom_ps != NIL) + ExecReScan((PlanState *) linitial(node->custom_ps)); +} + +static void +anser_produce_explain(CustomScanState *node, List *ancestors, ExplainState *es) +{ + AnserBloomProduceScanState *st = (AnserBloomProduceScanState *) node; + char buf[64]; + + ExplainPropertyInteger("Bloom Filter Size", "bytes", st->planned_bytes, es); + + /* + * The realized filter size is planned_bytes by construction: producer and + * consumer build identical filters from the same plan parameters (see + * anser_rf_size in anserplan.c), so no execution stats are needed. + */ + snprintf(buf, sizeof(buf), "memory=" INT64_FORMAT "kB", + st->planned_bytes / 1024); + ExplainPropertyText("Bloom Filter Stats", buf, es); +} + +/* ---- consumer ---- */ + +static Node * +anser_consume_create_state(CustomScan *cscan) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) + newNode(sizeof(AnserBloomConsumeScanState), T_CustomScanState); + + st->csstate.methods = &anser_consume_exec_methods; + return (Node *) st; +} + +static void +anser_consume_begin(CustomScanState *node, EState *estate, int eflags) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) node; + CustomScan *cscan = (CustomScan *) node->ss.ps.plan; + List *priv = cscan->custom_private; + Plan *child = (Plan *) linitial(cscan->custom_plans); + AnserChannelKey key; + uint32 part_index; + uint32 expected_parts; + int64 total_elems = intVal(list_nth(priv, ANSER_RF_PRIV_TOTAL_ELEMS)); + Size max_payload = (Size) intVal(list_nth(priv, ANSER_RF_PRIV_MAX_PAYLOAD)); + + st->key_attno = (AttrNumber) intVal(list_nth(priv, ANSER_RF_PRIV_KEY_ATTNO)); + st->planned_bytes = intVal(list_nth(priv, ANSER_RF_PRIV_PLANNED_BYTES)); + st->filter = NULL; + st->received = false; + st->defer_first = (intVal(list_nth(priv, ANSER_RF_PRIV_DEFER_FIRST)) != 0); + st->probed = false; + st->pushed_down = false; + + anser_rf_build_key(cscan, &key); + anser_rf_part_info(cscan, &part_index, &expected_parts); + + st->consume = ExecInitAnserBloomFilterConsume(&key, total_elems, max_payload, + expected_parts); + + node->custom_ps = list_make1(ExecInitNode(child, estate, eflags)); +} + +/* + * On first call, block to receive and union the bloom parts. A NULL filter + * (channel cancelled / unreachable / feature degraded) means fail open: pass + * every row through unfiltered. + */ +static void +anser_consume_receive(AnserBloomConsumeScanState *st) +{ + if (st->received) + return; + + /* + * Not on the first tuple, when the join is one that prefetches. + * + * ExecHashJoin pulls one tuple from its outer side -- us -- before it + * builds the hash table, so that it can skip the build when the outer side + * turns out to be empty (nodeHashjoin.c, the hj_FirstOuterTupleSlot fetch + * in HJ_BUILD_HASHTABLE). Waiting there waits for the inner side the same + * join has not started; when the join is co-located -- no Motion between + * the two, producer and consumer in one slice -- that is this very + * process, and nothing but the deadline ends it. + * + * Letting that one tuple through unfiltered costs a wasted probe and can + * never cost a row: the join still applies the real condition, and passing + * rows a filter would have rejected is the safe direction. By the next + * call the hash table is built, so the producer's scan has been drained + * and its part published. + * + * It is not free, which is why it is conditional: the scan has started by + * then, and anser_consume_pushdown() has to run before that to get its key + * into the scan descriptor. So a deferred consumer keeps the filter and + * probes it itself. The planner sets the flag whenever it cannot rule the + * prefetch out, which is the conservative direction -- guessing "no + * prefetch" wrongly deadlocks, guessing "prefetch" wrongly only gives up + * pushdown. + */ + if (st->defer_first && !st->probed) + { + st->probed = true; + return; + } + + st->received = true; + + if (st->consume == NULL) + return; + + if (ExecAnserBloomFilterConsume(st->consume, ANSER_RF_REGISTRATION_TIMEOUT_MS)) + st->filter = ExecAnserBloomFilterConsumerGetFilter(st->consume); + else + st->filter = NULL; + + /* + * Diagnostic: a NULL filter means we fail open (no pruning). Log why -- + * cancelled delivery vs. too few bloom parts unioned -- so runtime-filter + * misbehavior is visible in the server log. + */ + if (st->filter == NULL) + elog(LOG, + "anser bloom consumer: no filter, failing open (cancelled=%d, received_parts=%u)", + ExecAnserBloomFilterConsumerWasCancelled(st->consume), + ExecAnserBloomFilterConsumerReceivedParts(st->consume)); +} + +/* + * Is this PlanState one of our own nodes? + * + * The exec method table is the thing to compare, because our own + * CreateCustomScanState callback assigns it (anser_consume_create_state) in + * the process that is about to run the node. It is a plain in-process pointer + * with nothing in between, so the comparison means the same thing wherever it + * runs -- which matters, because a consumer executes on the coordinator as + * well as on a segment: a slice with GANGTYPE_UNALLOCATED has no gang, and + * ExecAnserBloomFilterConsume takes the AnserDispatchLocalConsume path for it. + * + * AnserIsRuntimeFilterScan answers the same question about a Plan, and is what + * the injection pass uses. Either would work here; this walk has the PlanState + * in hand, so it asks the shorter question. + */ +static bool +anser_is_rf_scan_state(const PlanState *ps) +{ + const CustomScanState *css; + + if (ps == NULL || !IsA(ps, CustomScanState)) + return false; + + css = (const CustomScanState *) ps; + return css->methods == &anser_produce_exec_methods || + css->methods == &anser_consume_exec_methods; +} + +/* + * Can `ps` take an SK_BLOOM_FILTER scan key for the column it emits at output + * targetlist position `attno`? On true, *sk_attno_out is the attribute number + * the key must carry; on false it is InvalidAttrNumber. + * + * `attno` is a position in the node's *output* targetlist, which is how + * anser_consume_next numbers things, because the slot it probes has already + * been projected. A scan key is numbered differently: PassByBloomFilter reads + * ss_ScanTupleSlot -- the table tuple, before projection -- so the key has to + * carry the table's own attribute number. The two coincide only when the scan + * emits every column in order; for a projecting scan they do not, and + * filtering the wrong column drops rows that can join. On probe(payload, id) + * with a targetlist of just [id], `attno` is 1 while the table attno is 2: + * a key carrying 1 tests `payload` against a filter built on `id` and discards + * every row. + * + * Only a SeqScan is accepted, because nodeSeqscan.c is the only consumer of + * this scan-key shape that sees an unprojected table tuple. DynamicSeqScan + * also carries a `filters` list, but it applies the filter to its child + * SeqScan's *projected* slot and remaps varattnos per partition, so it would + * need different numbering -- and the injection pass cannot reach one anyway, + * since AnserResolveKeyScan only terminates on a plain SeqScan. + */ +bool +AnserPushdownAccepts(PlanState *ps, AttrNumber attno, AttrNumber *sk_attno_out) +{ + SeqScanState *sss; + List *tlist; + TargetEntry *tle; + Var *var; + + if (sk_attno_out != NULL) + *sk_attno_out = InvalidAttrNumber; + + if (ps == NULL || !IsA(ps, SeqScanState)) + return false; + sss = (SeqScanState *) ps; + + /* + * With filter_in_seqscan off the scan hands its keys to the table AM + * instead of evaluating them itself, and nothing guarantees the AM + * understands SK_BLOOM_FILTER. + */ + if (!sss->filter_in_seqscan) + return false; + + tlist = ps->plan->targetlist; + if (attno < 1 || attno > list_length(tlist)) + return false; + tle = (TargetEntry *) list_nth(tlist, attno - 1); + if (tle == NULL || !IsA(tle->expr, Var)) + return false; /* a computed column has no table attno */ + var = (Var *) tle->expr; + + /* + * Only a plain user column of this very relation can be pushed. A + * whole-row reference (varattno 0) or a system column (negative) is not + * something PassByBloomFilter can hash the way the producer did, and a Var + * belonging to another range table entry would send the scan looking at an + * attno that means nothing to it. + */ + if (var->varno != ((Scan *) ps->plan)->scanrelid || var->varattno < 1) + return false; + + if (sk_attno_out != NULL) + *sk_attno_out = var->varattno; + return true; +} + +/* + * Walk down from `top` following the column at output position `attno`, and + * return the deepest node that would accept a scan key for it -- or NULL if + * none would. *sk_attno_out is the attribute number for that node. + * + * Deepest, because a filter applied further down rejects the row before more + * of the plan has touched it; this is the same reason RPT+ pushes its min-max + * filters "to the deepest possible operators". The walk matters because our + * nodes stack: when two joins trace their probe key to the same relation, the + * second consumer is inserted *under* the first, so the upper one's child is a + * CustomScanState rather than the scan. Before this walk existed the upper + * consumer simply declined and probed every tuple itself. + * + * Descent follows the column's provenance, so it is a path rather than a tree + * sweep: at each step exactly one child supplies the column. It continues + * only through our own nodes, which is the complete set of things that can sit + * between a consumer and its scan -- the injection pass puts the consumer + * directly above the scan, and the only thing that later gets between them is + * another one of ours. + */ +PlanState * +AnserPushdownTarget(PlanState *top, AttrNumber attno, AttrNumber *sk_attno_out) +{ + PlanState *best = NULL; + AttrNumber best_sk = InvalidAttrNumber; + PlanState *cur = top; + AttrNumber cur_attno = attno; + + while (cur != NULL) + { + AttrNumber sk_attno; + List *tlist; + TargetEntry *tle; + Var *var; + + if (AnserPushdownAccepts(cur, cur_attno, &sk_attno)) + { + best = cur; + best_sk = sk_attno; + } + + if (!anser_is_rf_scan_state(cur)) + break; + + /* Follow the column into our child, remapping the position. */ + tlist = cur->plan->targetlist; + if (cur_attno < 1 || cur_attno > list_length(tlist)) + break; + tle = (TargetEntry *) list_nth(tlist, cur_attno - 1); + if (tle == NULL || !IsA(tle->expr, Var)) + break; + var = (Var *) tle->expr; + if (var->varno != INDEX_VAR || var->varattno < 1) + break; /* not the identity tlist we build */ + + cur_attno = var->varattno; + cur = (PlanState *) linitial(((CustomScanState *) cur)->custom_ps); + } + + if (sk_attno_out != NULL) + *sk_attno_out = best_sk; + return best; +} + +/* + * Hand the received filter to the deepest scan that will take it, as an + * SK_BLOOM_FILTER scan key -- the exact structure PassByBloomFilter consumes + * (nodeSeqscan.c), so the scan does the pruning itself and a table AM that + * supports SCAN_SUPPORT_RUNTIME_FILTER may additionally use the key for + * block-level pruning. Runs before the child's first ExecProcNode, so the key + * is in node->filters when the scan descriptor is created. The filter stays + * owned by us; anser_consume_end ends the child before freeing it. + * + * Declining (leaving pushed_down false) costs nothing but the pushdown: the + * caller then probes the filter itself in anser_consume_next. + */ +static void +anser_consume_pushdown(CustomScanState *node) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) node; + PlanState *child = (PlanState *) linitial(node->custom_ps); + PlanState *target; + AttrNumber sk_attno; + ScanKey sk; + MemoryContext oldcxt; + + if (!gp_enable_runtime_filter_pushdown) + return; + + target = AnserPushdownTarget(child, st->key_attno, &sk_attno); + if (target == NULL) + return; + + /* AnserPushdownAccepts admits nothing else, and the cast below needs it. */ + Assert(IsA(target, SeqScanState)); + + oldcxt = MemoryContextSwitchTo(node->ss.ps.state->es_query_cxt); + sk = (ScanKey) palloc0(sizeof(ScanKeyData)); + sk->sk_attno = sk_attno; + sk->sk_flags = SK_BLOOM_FILTER; + sk->sk_argument = PointerGetDatum(st->filter); + ((SeqScanState *) target)->filters = + lappend(((SeqScanState *) target)->filters, sk); + MemoryContextSwitchTo(oldcxt); + + st->pushed_down = true; +} + +static TupleTableSlot * +anser_consume_next(CustomScanState *node) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) node; + PlanState *child = (PlanState *) linitial(node->custom_ps); + + anser_consume_receive(st); + + /* + * When the pushdown machinery is enabled, hand the filter to the scan and + * pass tuples through untouched; otherwise probe the filter here. + */ + if (st->filter != NULL && !st->pushed_down) + anser_consume_pushdown(node); + + for (;;) + { + TupleTableSlot *slot = ExecProcNode(child); + bool isnull; + Datum value; + + if (TupIsNull(slot)) + return NULL; + + /* + * Fail open (no usable filter) or pushed down (the scan filters via + * PassByBloomFilter): pass everything. + */ + if (st->filter == NULL || st->pushed_down) + return anser_rf_child_slot(node, slot); + + /* + * key_attno is an output-targetlist position, which is how this slot + * is numbered: it has already been projected. The pushdown path sees + * the table tuple instead and needs the table attno -- see + * anser_consume_pushdown. + */ + value = slot_getattr(slot, st->key_attno, &isnull); + + /* NULLs never match an equijoin key; let the join handle them. */ + if (!isnull) + { + /* + * Count lookups/prunes via instrumentation (nfiltered1/nfiltered2), + * not local state: these fields ride the CdbExplain_StatInst wire + * format back to the QD, so EXPLAIN ANALYZE shows them for + * segment-executed nodes too. + */ + InstrCountFiltered1(node, 1); + if (bloom_lacks_element(st->filter, (unsigned char *) &value, + sizeof(Datum))) + { + /* Definitely absent from the build side -> prune. */ + InstrCountFiltered2(node, 1); + continue; + } + } + + return anser_rf_child_slot(node, slot); + } +} + +static TupleTableSlot * +anser_consume_exec(CustomScanState *node) +{ + return ExecScan(&node->ss, + (ExecScanAccessMtd) anser_consume_next, + (ExecScanRecheckMtd) anser_rf_recheck); +} + +static void +anser_consume_end(CustomScanState *node) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) node; + + /* + * End the child first: when the filter was pushed down, the child's scan + * key references it, so the filter must outlive the child node. + */ + if (node->custom_ps != NIL) + ExecEndNode((PlanState *) linitial(node->custom_ps)); + if (st->consume != NULL) + ExecEndAnserBloomFilterConsume(st->consume); + st->consume = NULL; + st->filter = NULL; +} + +/* Keeps the received filter; see anser_produce_rescan for why that is sound. */ +static void +anser_consume_rescan(CustomScanState *node) +{ + if (node->custom_ps != NIL) + ExecReScan((PlanState *) linitial(node->custom_ps)); +} + +static void +anser_consume_explain(CustomScanState *node, List *ancestors, ExplainState *es) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) node; + + ExplainPropertyInteger("Bloom Filter Size", "bytes", st->planned_bytes, es); + + if (es->analyze && node->ss.ps.instrument != NULL) + { + char buf[128]; + double nloops = node->ss.ps.instrument->nloops; + double nfiltered = node->ss.ps.instrument->nfiltered2; + double nchecked = node->ss.ps.instrument->nfiltered1; + + /* + * checked/rejected come from nfiltered1/nfiltered2, which cdbexplain + * transports from the segments and deposits into this node's + * instrument. As with all per-node EXPLAIN ANALYZE stats in MPP, + * these are the winning segment's values (max ntuples/nloops), not a + * cluster-wide sum. memory is planned_bytes: the unioned filter's + * size is identical on every segment by construction (anser_rf_size). + * When the filter was pushed into the child scan, pruning happens + * there instead (its "Rows Removed by Pushdown Runtime Filter" line), + * so our counters stay zero. + */ + if (st->pushed_down) + snprintf(buf, sizeof(buf), + "memory=" INT64_FORMAT "kB (pushed down to Seq Scan)", + st->planned_bytes / 1024); + else + snprintf(buf, sizeof(buf), + "memory=" INT64_FORMAT "kB checked=%.0f rejected=%.0f", + st->planned_bytes / 1024, nchecked, nfiltered); + ExplainPropertyText("Bloom Filter Stats", buf, es); + + if (!st->pushed_down && nloops > 0) + ExplainPropertyFloat("Rows Removed by Bloom Filter", NULL, + nfiltered / nloops, 0, es); + } +} diff --git a/gpcontrib/anser/src/ansersideband.c b/gpcontrib/anser/src/ansersideband.c new file mode 100644 index 00000000000..d3b077661fb --- /dev/null +++ b/gpcontrib/anser/src/ansersideband.c @@ -0,0 +1,702 @@ +/*------------------------------------------------------------------------- + * + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * ansersideband.c + * Segment side of the dispatch-connection transport. + * + * A producer sends its part as a NOTIFY and moves on; a consumer subscribes, + * then blocks on its own dispatch socket until the coordinator pushes the + * merged filter back. Both directions reuse the connection the dispatcher + * already owns, so there is no second connection to open and nothing to + * authenticate. + * + * Reading the frontend socket in the middle of executing a query is the + * pattern cdb_sequence_nextval_qe() established (commands/sequence.c); the + * loop below mirrors its use of pq_startmsgread/pq_getbyte_if_available, but + * sleeps on the socket instead of spinning. + * + * IDENTIFICATION + * gpcontrib/anser/src/ansersideband.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include "anser.h" +#include "ansersideband.h" +#include "cdb/cdbvars.h" +#include "commands/async.h" +#include "common/base64.h" +#include "libpq/libpq-be.h" +#include "libpq/libpq.h" +#include "libpq/pqformat.h" +#include "miscadmin.h" +#include "nodes/pg_list.h" +#include "port/pg_bswap.h" +#include "port/pg_crc32c.h" +#include "storage/latch.h" +#include "tcop/dest.h" +#include "tcop/tcopprot.h" +#include "utils/memutils.h" +#include "utils/timestamp.h" +#include "utils/wait_event.h" + +/* How long to sleep between wakeups while waiting for a delivery. */ +#define ANSER_SIDEBAND_POLL_MS 100L + +/* + * A delivery that arrived while we were waiting for a different channel. + * + * One slice can host consumers for more than one condition, and the + * coordinator pushes each channel as soon as it completes, so messages can + * arrive in an order we did not ask for. Rather than discard them (which + * would cost that consumer its filter), park them here and check the inbox + * before touching the socket. + */ +typedef struct AnserInboxEntry +{ + AnserChannelKey key; + char payload_type; + char *payload; /* NULL when cancelled */ + Size payload_len; + bool cancelled; +} AnserInboxEntry; + +static List *AnserInbox = NIL; + +static bool anser_sideband_send(const char *payload); +static bool anser_inbox_take(const AnserChannelKey *key, char payload_type, + void **payload, Size *payload_len, bool *cancelled); +static bool anser_sideband_read_one(long timeout_ms); + +/* + * Publish one part. Fire-and-forget: the coordinator does not acknowledge, + * because nothing on this side needs to wait for it. + */ +bool +AnserSidebandPublish(const AnserChannelKey *channel_key, char payload_type, + uint32 part_index, uint32 total_parts, + const void *payload, Size payload_len, bool cancelled) +{ + char *msg; + int flags = cancelled ? ANSER_WIRE_F_CANCELLED : 0; + bool ok; + + if (channel_key == NULL) + return false; + + if (!cancelled && payload_len > (Size) gp_anser_max_info_size) + { + /* Too large to ship; tell the coordinator so consumers stop waiting. */ + flags = ANSER_WIRE_F_CANCELLED; + payload = NULL; + payload_len = 0; + } + + msg = AnserWireFormat(channel_key, ANSER_WIRE_KIND_PART, payload_type, + part_index, total_parts, flags, + (flags & ANSER_WIRE_F_CANCELLED) ? NULL : payload, + (flags & ANSER_WIRE_F_CANCELLED) ? 0 : payload_len); + ok = anser_sideband_send(msg); + ANSER_DEBUG("anser: seg%d published cond=%u type=%c part=%u/%u bytes=%zu cancelled=%d sent=%d", + GpIdentity.segindex, channel_key->condition_id, payload_type, + part_index, total_parts, payload_len, + (flags & ANSER_WIRE_F_CANCELLED) ? 1 : 0, ok ? 1 : 0); + pfree(msg); + + return ok; +} + +/* + * Subscribe, then wait for the merged payload. + * + * Returns true with *payload set, or false for "run unfiltered" -- including + * on timeout. The deadline exists because a producer that gets squelched + * never publishes anything: ExecSquelchNode does not call CustomScan + * callbacks, it only marks the node (execAmi.c), so without a deadline this + * wait could outlive the reason for it. + */ +bool +AnserSidebandConsumeWait(const AnserChannelKey *channel_key, char payload_type, + void **payload, Size *payload_len, + bool *cancelled, long timeout_ms) +{ + char *msg; + TimestampTz start; + int reads = 0; + + if (payload != NULL) + *payload = NULL; + if (payload_len != NULL) + *payload_len = 0; + if (cancelled != NULL) + *cancelled = false; + + if (channel_key == NULL || MyProcPort == NULL || + MyProcPort->sock == PGINVALID_SOCKET) + { + /* + * No way to reach the coordinator from here, so this consumer will run + * unfiltered without ever having asked for anything. Which of the + * three reasons it was matters: a process with no Port is one the + * sideband was never designed for, while a bad socket on a process + * that has one is a different problem entirely. + */ + ANSER_DEBUG("anser: seg%d cannot consume cond=%u: key=%s port=%s sock=%d role=%d writer=%d dest=%d", + GpIdentity.segindex, + channel_key != NULL ? channel_key->condition_id : 0, + channel_key != NULL ? "ok" : "NULL", + MyProcPort != NULL ? "ok" : "NULL", + MyProcPort != NULL ? (int) MyProcPort->sock : -1, + (int) Gp_role, Gp_is_writer ? 1 : 0, + (int) whereToSendOutput); + return false; + } + + /* It may already be here: the coordinator pushes as soon as it can. */ + if (anser_inbox_take(channel_key, payload_type, payload, payload_len, cancelled)) + return payload != NULL && *payload != NULL; + + /* + * With no deadline to wait out there is nothing to subscribe for: we would + * give up on the next line anyway, and the only lasting effect would be the + * coordinator later pushing a message to a process that has stopped reading + * for it. Such a delivery is not free -- it arrives while this QE is + * somewhere else in the plan, and whatever reads the connection then has to + * recognise and skip it. So do not ask in the first place. + * + * This only narrows the window. Any deadline can expire with the + * subscription already registered, and there is no unsubscribe, so every + * reader of this connection still has to tolerate a message that is not its + * own (see cdb_sequence_nextval_qe). + */ + if (timeout_ms == 0) + { + ANSER_DEBUG("anser: seg%d not subscribing to cond=%u: no deadline to wait out", + GpIdentity.segindex, channel_key->condition_id); + return false; + } + + /* + * A subscription carries nothing, so its payload type is NONE -- what this + * consumer expects to receive is checked on delivery, not announced here. + */ + msg = AnserWireFormat(channel_key, ANSER_WIRE_KIND_SUBSCRIBE, + ANSER_PAYLOAD_NONE, 0, 0, 0, NULL, 0); + if (!anser_sideband_send(msg)) + { + ANSER_DEBUG("anser: seg%d could not subscribe to cond=%u (role=%d writer=%d dest=%d)", + GpIdentity.segindex, channel_key->condition_id, + (int) Gp_role, Gp_is_writer ? 1 : 0, + (int) whereToSendOutput); + pfree(msg); + return false; + } + pfree(msg); + ANSER_DEBUG("anser: seg%d subscribed cond=%u, waiting up to %ld ms", + GpIdentity.segindex, channel_key->condition_id, timeout_ms); + + start = GetCurrentTimestamp(); + for (;;) + { + CHECK_FOR_INTERRUPTS(); + + /* + * The coordinator has what it needs and told us to wrap up -- a LIMIT + * that filled, typically. CHECK_FOR_INTERRUPTS does not cover this: + * it is a flag the executor is expected to notice, which is why + * nodeHashjoin.c tests it beside the same call. Without that, a + * finished query is held open by a consumer still waiting on a filter + * nobody will use, for up to anser.timeout_ms. + */ + if (QueryFinishPending) + { + ANSER_DEBUG("anser: seg%d abandoning cond=%u: the query is finishing", + GpIdentity.segindex, channel_key->condition_id); + return false; + } + + if (anser_inbox_take(channel_key, payload_type, payload, payload_len, + cancelled)) + return payload != NULL && *payload != NULL; + + if (timeout_ms >= 0 && + TimestampDifferenceExceeds(start, GetCurrentTimestamp(), timeout_ms)) + { + /* + * Withdraw the subscription on the way out. From here on this + * process is somewhere else in the plan and no longer reading for + * a filter, so a delivery would put 1-64 MB on a connection whose + * reader has moved on -- and whatever reads it next has to + * recognise and skip it. + * + * Best effort, and a narrowing rather than a guarantee: the + * coordinator may already be inside its delivery when this + * arrives. That is why the push never blocks and why every + * reader still tolerates a message that is not its own. + */ + msg = AnserWireFormat(channel_key, ANSER_WIRE_KIND_UNSUBSCRIBE, + ANSER_PAYLOAD_NONE, 0, 0, 0, NULL, 0); + if (!anser_sideband_send(msg)) + ANSER_DEBUG("anser: seg%d could not unsubscribe from cond=%u", + GpIdentity.segindex, channel_key->condition_id); + pfree(msg); + + /* + * Report what we saw, not just that we waited: "nothing arrived" + * and "something arrived for another channel" are different bugs. + */ + ANSER_DEBUG("anser: seg%d gave up on cond=%u after %ld ms (read %d message(s), %d unclaimed)", + GpIdentity.segindex, channel_key->condition_id, + timeout_ms, reads, list_length(AnserInbox)); + return false; + } + + /* + * Any message we read lands in the inbox; the loop then rechecks + * whether it was the one we wanted. A read failure means the + * connection is gone, which the interconnect will report far more + * usefully than we can -- stop waiting and let the query run + * unfiltered. + */ + if (!anser_sideband_read_one(ANSER_SIDEBAND_POLL_MS)) + return false; + reads = list_length(AnserInbox); + } +} + +/* + * Wait briefly for one sideband message and stash it in the inbox. + * + * Returns false only when the connection is unusable; a timeout with nothing + * to read is a normal true. + */ +static bool +anser_sideband_read_one(long timeout_ms) +{ + unsigned char qtype; + int retval; + StringInfoData buf; + AnserInboxEntry *entry; + MemoryContext oldcxt; + char paytype; + uint32 crc; + int condid; + int flags; + int keylen; + int paylen; + const char *keyptr; + const char *payptr; + + pq_startmsgread(); + retval = pq_getbyte_if_available(&qtype); + if (retval == 0) + { + /* Nothing buffered: sleep on the socket rather than spinning. */ + pq_endmsgread(); + + ResetLatch(MyLatch); + (void) WaitLatchOrSocket(MyLatch, + WL_LATCH_SET | WL_SOCKET_READABLE | + WL_TIMEOUT | WL_EXIT_ON_PM_DEATH, + MyProcPort->sock, + timeout_ms, + PG_WAIT_EXTENSION); + return true; + } + + if (retval == EOF) + { + pq_endmsgread(); + elog(LOG, "anser: dispatch connection closed while awaiting a filter"); + return false; + } + + if (qtype != GP_SIDEBAND_MESSAGE) + { + /* + * The one type that can legitimately turn up is '?', the nextval + * response: the QD sends it and cancels the QEs when it fails to get a + * value, so it can arrive with nobody waiting for it. We do not skip + * it, because that would consume a reply a later nextval might be + * about to wait for, and we are not the owner of that exchange. Other + * types should not reach us at all. + * + * Either way there is no way back by the time we are here: + * pq_getbyte_if_available() has already taken the byte, and the + * backend cannot push one in front of the stream, so the message can + * neither be handed to the command loop nor left for its owner. The + * connection is one byte short from here on. + * + * Terminating is therefore the only honest outcome. Returning and + * running unfiltered -- the instinct everywhere else in this code -- + * would be much worse than losing the filter: every later read lands + * at the wrong offset, and the failure surfaces somewhere else + * entirely as an absurd allocation request or "invalid frontend + * message type". It has to be FATAL rather than ERROR because the + * connection cannot be reused: an ERROR would return this QE to the + * gang pool with a desynchronised stream, poisoning a later query. + * + * The mirror case -- a sideband message reaching the nextval reader -- + * is the common one, and that reader skips it rather than dying: it + * knows our message is unowned by then, which we cannot say of its. + */ + pq_endmsgread(); + ereport(FATAL, + (errcode(ERRCODE_PROTOCOL_VIOLATION), + errmsg("anser: unexpected message type '%c' while awaiting a filter", + (char) qtype), + errdetail("The message boundary was lost; this connection cannot be used further."))); + } + + initStringInfo(&buf); + if (pq_getmessage(&buf, gp_anser_max_info_size + ANSER_CONDITION_KEY_SIZE + 64) != 0) + { + /* + * pq_getmessage clears the reading-message flag when it succeeds, but + * not on its EOF paths; clear it by hand so we do not trip the + * assertion in a later pq_startmsgread. + */ + pq_endmsgread(); + pfree(buf.data); + elog(LOG, "anser: could not read filter message"); + return false; + } + + paytype = (char) pq_getmsgint(&buf, 4); + crc = (uint32) pq_getmsgint(&buf, 4); + condid = pq_getmsgint(&buf, 4); + flags = pq_getmsgint(&buf, 4); + keylen = pq_getmsgint(&buf, 4); + if (keylen < 0 || keylen >= ANSER_CONDITION_KEY_SIZE) + { + pfree(buf.data); + elog(LOG, "anser: filter message has a bad condition key"); + return false; + } + keyptr = pq_getmsgbytes(&buf, keylen); + paylen = pq_getmsgint(&buf, 4); + if (paylen < 0 || paylen > gp_anser_max_info_size) + { + pfree(buf.data); + elog(LOG, "anser: filter message has a bad length"); + return false; + } + payptr = paylen > 0 ? pq_getmsgbytes(&buf, paylen) : NULL; + + if (crc != (uint32) AnserWirePushCrc(paytype, (uint32) condid, + (uint32) flags, keyptr, keylen, + payptr, paylen)) + { + /* + * Drop it and keep waiting: the deadline will expire and this consumer + * will run unfiltered. Using the filter anyway is the one thing we must + * not do -- a bit corrupted 1 -> 0 silently drops joinable rows. + */ + pfree(buf.data); + elog(LOG, "anser: checksum mismatch on filter for condition %u; discarding", + (uint32) condid); + return true; + } + + /* + * The inbox outlives this call and the memory context it was reached in, + * so anchor it somewhere stable; AnserSidebandResetAll drops it. + */ + oldcxt = MemoryContextSwitchTo(TopMemoryContext); + entry = palloc0(sizeof(AnserInboxEntry)); + entry->key.gp_session_id = gp_session_id; + entry->key.gp_command_count = gp_command_count; + entry->key.condition_id = (uint32) condid; + memcpy(entry->key.condition_key, keyptr, keylen); + entry->key.condition_key[keylen] = '\0'; + entry->payload_type = paytype; + entry->cancelled = (flags & ANSER_WIRE_F_CANCELLED) != 0; + if (!entry->cancelled && paylen > 0) + { + entry->payload = palloc(paylen); + memcpy(entry->payload, payptr, paylen); + entry->payload_len = paylen; + } + AnserInbox = lappend(AnserInbox, entry); + MemoryContextSwitchTo(oldcxt); + ANSER_DEBUG("anser: seg%d received cond=%u type=%c bytes=%zu cancelled=%d", + GpIdentity.segindex, (uint32) condid, paytype, + entry->payload_len, entry->cancelled ? 1 : 0); + + pfree(buf.data); + return true; +} + +/* Claim a delivery for this channel, if one has arrived. */ +static bool +anser_inbox_take(const AnserChannelKey *key, char payload_type, void **payload, + Size *payload_len, bool *cancelled) +{ + ListCell *lc; + + foreach(lc, AnserInbox) + { + AnserInboxEntry *entry = (AnserInboxEntry *) lfirst(lc); + + if (entry->key.condition_id != key->condition_id || + strncmp(entry->key.condition_key, key->condition_key, + ANSER_CONDITION_KEY_SIZE) != 0) + continue; + + /* + * Right channel, wrong kind of information. The coordinator stamps + * the type from the parts it folded, so this means a producer and a + * consumer disagree about what the channel carries -- claim the entry + * to stop waiting on it, and treat it as a cancellation so this + * consumer runs unfiltered. An empty delivery carries no type to + * check. + */ + if (!entry->cancelled && entry->payload != NULL && + entry->payload_type != payload_type) + { + elog(WARNING, "anser: condition %u delivered payload type '%c', expected '%c'", + key->condition_id, entry->payload_type, payload_type); + entry->cancelled = true; + } + + if (cancelled != NULL) + *cancelled = entry->cancelled; + if (!entry->cancelled && entry->payload != NULL) + { + if (payload != NULL) + { + *payload = palloc(entry->payload_len); + memcpy(*payload, entry->payload, entry->payload_len); + } + if (payload_len != NULL) + *payload_len = entry->payload_len; + } + + AnserInbox = foreach_delete_current(AnserInbox, lc); + if (entry->payload != NULL) + pfree(entry->payload); + pfree(entry); + return true; + } + + return false; +} + +/* Drop any deliveries nobody claimed. */ +void +AnserSidebandResetInbox(void) +{ + ListCell *lc; + + foreach(lc, AnserInbox) + { + AnserInboxEntry *entry = (AnserInboxEntry *) lfirst(lc); + + if (entry->payload != NULL) + pfree(entry->payload); + pfree(entry); + } + list_free(AnserInbox); + AnserInbox = NIL; +} + +void +AnserSidebandResetAll(void) +{ + AnserSidebandResetInbox(); + AnserDispatchReset(); +} + +/* + * Build a QE -> QD payload: fixed-width header, then the key, then the base64 + * body. See ansersideband.h for the layout and AnserWireParse() for the + * reader. + */ +char * +AnserWireFormat(const AnserChannelKey *channel_key, char kind, + char payload_type, uint32 part_index, uint32 total_parts, + int flags, const void *payload, Size payload_len) +{ + StringInfoData buf; + char hdr[ANSER_WIRE_HDR_LEN + 1]; + int hdrlen; + int keylen = (int) strlen(channel_key->condition_key); + int bodylen = 0; + char *body = NULL; + pg_crc32c crc; + + /* + * What the checksum covers has to be exactly what goes on the wire, so the + * bytes it will cover are recorded here, as the body is encoded, rather + * than re-derived from the arguments afterwards. Deriving them again would + * mean covering a payload that was suppressed -- a cancelled message + * carries no body however it was called -- and the reader, seeing no body, + * would compute a different checksum and discard a perfectly good message. + */ + const void *crc_body = NULL; + Size crc_body_len = 0; + + if (!(flags & ANSER_WIRE_F_CANCELLED) && payload != NULL && payload_len > 0) + { + int maxlen = pg_b64_enc_len((int) payload_len); + + if (maxlen > ANSER_WIRE_MAX_BODYLEN) + { + /* + * No header can describe a body this large. gp_anser_max_info_size + * keeps us far away from this, so it is a belt-and-braces check on + * the field width rather than a reachable path. + */ + bodylen = 0; + flags |= ANSER_WIRE_F_CANCELLED; + } + else + { + body = palloc(maxlen + 1); + bodylen = pg_b64_encode((const char *) payload, (int) payload_len, + body, maxlen); + if (bodylen < 0) + { + pfree(body); + body = NULL; + bodylen = 0; + flags |= ANSER_WIRE_F_CANCELLED; + } + else + { + crc_body = payload; + crc_body_len = payload_len; + } + } + } + + Assert(keylen <= ANSER_WIRE_MAX_KEYLEN); + + /* + * Format the header with a zero CRC, checksum it together with the key and + * the raw body, then overwrite the CRC field in place -- it sits at a fixed + * offset, so one pass suffices and the checksum still covers every header + * field. Checksumming the body before base64 rather than after means a + * mangled encoding is caught too, and whether the body is covered at all is + * the payload type's call (anserpayload.h). + */ + hdrlen = snprintf(hdr, sizeof(hdr), ANSER_WIRE_HDR_FORMAT, + kind, payload_type, + (uint32) channel_key->gp_session_id, + (uint32) channel_key->gp_command_count, + channel_key->condition_id, + part_index, total_parts, + (uint32) flags, (uint32) keylen, (uint32) bodylen, 0U); + if (hdrlen != ANSER_WIRE_HDR_LEN) + { + /* + * Cannot happen -- every field is width-limited -- but complain rather + * than throw: this is reached from producer teardown, and the + * coordinator's length cross-check will reject the message anyway, + * which costs the filter and not the query. + */ + elog(WARNING, "anser: formatted a %d byte header, expected %d", + hdrlen, ANSER_WIRE_HDR_LEN); + } + + INIT_CRC32C(crc); + COMP_CRC32C(crc, hdr, ANSER_WIRE_CRC_OFFSET); + COMP_CRC32C(crc, channel_key->condition_key, keylen); + if (crc_body != NULL && AnserPayloadChecksumsBody(payload_type)) + COMP_CRC32C(crc, crc_body, crc_body_len); + FIN_CRC32C(crc); + snprintf(hdr + ANSER_WIRE_CRC_OFFSET, 9, "%08x", (uint32) crc); + + initStringInfo(&buf); + appendBinaryStringInfo(&buf, hdr, ANSER_WIRE_HDR_LEN); + appendBinaryStringInfo(&buf, channel_key->condition_key, keylen); + if (bodylen > 0) + appendBinaryStringInfo(&buf, body, bodylen); + + if (body != NULL) + pfree(body); + + return buf.data; +} + +/* + * Checksum of a QD -> QE push. Lives here, on the reading side, but is called + * from anserdispatch.c too: one definition means the writer and the reader + * cannot disagree about what is covered -- including whether the body is, which + * follows from the payload type. Integers go in network byte order so the + * value does not depend on the host. + */ +pg_crc32c +AnserWirePushCrc(char payload_type, uint32 condition_id, uint32 flags, + const char *key, int keylen, const void *body, int bodylen) +{ + pg_crc32c crc; + uint32 fields[4]; + uint32 belen; + + fields[0] = pg_hton32((uint32) (unsigned char) payload_type); + fields[1] = pg_hton32(condition_id); + fields[2] = pg_hton32(flags); + fields[3] = pg_hton32((uint32) keylen); + + INIT_CRC32C(crc); + COMP_CRC32C(crc, fields, sizeof(fields)); + if (keylen > 0) + COMP_CRC32C(crc, key, keylen); + belen = pg_hton32((uint32) bodylen); + COMP_CRC32C(crc, &belen, sizeof(belen)); + if (bodylen > 0 && AnserPayloadChecksumsBody(payload_type)) + COMP_CRC32C(crc, body, bodylen); + FIN_CRC32C(crc); + + return crc; +} + +/* + * Hand a payload to the coordinator. + * + * NotifyMyFrontEnd enforces no length limit of its own -- the ~8 KB cap + * applies to the SQL-level NOTIFY, which has to fit its queue page -- so a + * multi-megabyte part is fine here. The payload is base64, hence free of the + * NUL that would truncate it in pq_sendstring(). + */ +static bool +anser_sideband_send(const char *payload) +{ + if (whereToSendOutput != DestRemote) + { + /* + * Reached by any process that runs the plan without a frontend to + * answer to. Both directions of the protocol come through here, so + * this is the single place that says "this process is not on the + * sideband at all" -- for a publisher as much as for a subscriber. + */ + ANSER_DEBUG("anser: seg%d has no frontend to send on (role=%d writer=%d dest=%d port=%s sock=%d)", + GpIdentity.segindex, (int) Gp_role, Gp_is_writer ? 1 : 0, + (int) whereToSendOutput, + MyProcPort != NULL ? "ok" : "NULL", + MyProcPort != NULL ? (int) MyProcPort->sock : -1); + return false; + } + + NotifyMyFrontEnd(ANSER_NOTIFY_CHANNEL, payload, gp_session_id); + pq_flush(); + return true; +} diff --git a/pom.xml b/pom.xml index dde5c846b44..412c19ca527 100644 --- a/pom.xml +++ b/pom.xml @@ -1438,6 +1438,10 @@ code or new licensing patterns. gpcontrib/reject_partition_fullscan/Makefile gpcontrib/reject_partition_fullscan/reject_partition_fullscan.control + gpcontrib/anser/anser_test.control + gpcontrib/anser/**/*.sql + gpcontrib/anser/**/*.out + diff --git a/src/Makefile.global.in b/src/Makefile.global.in index ee1c76e0276..4bd5158ddfb 100644 --- a/src/Makefile.global.in +++ b/src/Makefile.global.in @@ -268,6 +268,7 @@ BITCODE_CXXFLAGS = @BITCODE_CXXFLAGS@ have_yaml = @have_yaml@ YAML_LIBS = @YAML_LIBS@ with_zstd = @with_zstd@ +with_anser = @with_anser@ ZSTD_CFLAGS = @ZSTD_CFLAGS@ ZSTD_LIBS = @ZSTD_LIBS@ EVENT_LIBS = @EVENT_LIBS@ diff --git a/src/backend/cdb/dispatcher/cdbdisp_async.c b/src/backend/cdb/dispatcher/cdbdisp_async.c index eb8e4714396..19924b92714 100644 --- a/src/backend/cdb/dispatcher/cdbdisp_async.c +++ b/src/backend/cdb/dispatcher/cdbdisp_async.c @@ -43,6 +43,7 @@ #include "miscadmin.h" #include "commands/sequence.h" #include "access/xact.h" +#include "utils/faultinjector.h" #include "utils/timestamp.h" #define DISPATCH_WAIT_TIMEOUT_MSEC 2000 @@ -116,6 +117,12 @@ static void cdbdisp_waitDispatchFinish_async(struct CdbDispatcherState *ds); static bool cdbdisp_checkForCancel_async(struct CdbDispatcherState *ds); static int *cdbdisp_getWaitSocketFds_async(struct CdbDispatcherState *ds, int *nsocks); +/* + * Lets an extension handle its own NOTIFY channels arriving from QEs; see the + * declaration in cdbdisp.h for the contract. + */ +cdbdisp_notify_hook_type cdbdisp_notify_hook = NULL; + DispatcherInternalFuncs DispatcherAsyncFuncs = { cdbdisp_checkForCancel_async, @@ -136,6 +143,8 @@ static void checkDispatchResult(CdbDispatcherState *ds, int timeout_sec); static bool processResults(CdbDispatchResult *dispatchResult); +static void processNotifies(CdbDispatchResult *dispatchResult); + static void signalQEs(CdbDispatchCmdAsync *pParms); @@ -458,6 +467,7 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) int timeout = 0; bool sentSignal = false; struct pollfd *fds; + bool *buffered; struct timeval start_ts, now; #ifdef USE_INTERNAL_FTS uint8 ftsVersion = 0; @@ -467,6 +477,12 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) db_count = pParms->dispatchCount; fds = (struct pollfd *) palloc(db_count * sizeof(struct pollfd)); + /* + * Parallel to fds[]: whether that connection already holds a complete + * message in libpq's buffer rather than in its socket. See the loop below. + */ + buffered = (bool *) palloc(db_count * sizeof(bool)); + /* * OK, we are finished submitting the command to the segdbs. Now, we have * to wait for them to finish. @@ -478,6 +494,7 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) int n; int nfds = 0; int ack_count = 0; + bool anyBuffered = false; PGconn *conn; /* @@ -540,14 +557,52 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) if (conn->outCount > 0) { /* + * Non-blocking, and the POLLOUT below is what finishes the + * job. A blocking flush here waits in pqWait() with no + * deadline and no CHECK_FOR_INTERRUPTS, so a peer that has + * stopped reading its end takes the whole dispatcher with it + * -- including this function's other role, which is to notice + * a cancel for the caller (cdbdisp_checkForCancel_async + * arrives here with timeout_sec = 0 precisely so as not to + * wait). cdbdisp_waitDispatchFinish_async already drains its + * output this way, so this is the same shape, not a new one. + * * Don't error out here, let following poll() routine to * handle it. */ - if (pqFlush(conn) < 0) + if (pqFlushNonBlocking(conn) < 0) elog(LOG, "Failed flushing outbound data to %s:%s", segdbDesc->whoami, PQerrorMessage(conn)); } + /* + * Take any notifications off this connection before deciding + * whether to wait on it. + * + * The buffered[] check below cannot see them. PQisBusy() parses + * whatever is already in the buffer, and libpq's parser handles a + * NOTIFY by queueing it on the connection and carrying on + * (pqParseInput3's 'A' arm calls getNotify(), then breaks); it + * never touches asyncStatus. So a notification can be complete + * and ready while PQisBusy() still answers true for the command + * that has not finished, leaving buffered[] false. + * + * That would only be survivable while the socket stayed readable, + * and it need not be: the flush just above can have absorbed those + * very bytes, because a write that cannot complete in one go reads + * the peer's pending output to avoid deadlocking against it. The + * notification is then in our own memory with nothing left in the + * socket, and poll() sleeps until the QE happens to send something + * else -- which, for a sender that does not wait for a reply, is + * not until its command completes, since tuples travel over the + * interconnect rather than over this connection. + * + * Draining here costs a pointer check when nothing is queued. + * PQnotifies() runs the parser itself, so this also does the + * parsing PQisBusy() would have done a few lines below. + */ + processNotifies(dispatchResult); + /* * Add socket to fd_set if still connected. */ @@ -555,6 +610,44 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) Assert(sock >= 0); fds[nfds].fd = sock; fds[nfds].events = POLLIN; + + /* + * Anything the flush above could not place goes out when the peer + * makes room, which is what this asks to be woken for. Only the + * events mask grows: the array keeps one entry per connection in + * the same order, so handlePollSuccess() -- which walks it with + * its own counter and asserts the socket matches -- is unaffected, + * and it tests POLLIN alone. + */ + if (conn->outCount > 0) + fds[nfds].events |= POLLOUT; + + /* + * poll() reports what is in the socket, which is only the same + * thing as "what is left to read" while we are the only thing + * moving bytes off this connection. Anything else that writes to + * it through libpq breaks that: when a write cannot complete in one + * go, pqSendSome() calls pqReadData() to absorb whatever the peer + * has already sent (fe-misc.c), deliberately, so that a large write + * cannot deadlock against a peer that is writing back at us. The + * bytes then sit in conn->inBuffer, the socket goes quiet, and a QE + * that has finished and gone idle will never make it readable + * again -- so the results are in our own memory and we sleep + * forever waiting to be told about them. + * + * PQisBusy() runs libpq's parser over the buffer it already has and + * never touches the socket, so asking is free. A false answer means + * a complete message is ready now; take it in this round instead of + * polling, exactly as libpq's own async protocol prescribes (call + * poll() only once PQisBusy() says there is nothing to parse). + * + * This is the same class of lost wakeup as the outbound flush just + * above, and the same reason for handling it here. + */ + buffered[nfds] = !PQisBusy(conn); + if (buffered[nfds]) + anyBuffered = true; + nfds++; } @@ -582,6 +675,14 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) else timeout = DISPATCH_WAIT_CANCEL_TIMEOUT_MSEC; + /* + * Something is already parseable, so there is nothing to wait for. + * Still poll, with no timeout, to pick up whatever else has arrived in + * the same round. + */ + if (anyBuffered) + timeout = 0; + n = poll(fds, nfds, timeout); /* @@ -620,6 +721,25 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) if (timeout_sec >= 0 && diff_us >= timeout_sec * 1000000L) break; } + /* + * Buffered input counts as readable. poll() cleared revents and + * reported only the sockets, so put the buffered ones back before + * handing the array on -- handlePollSuccess() walks the same + * connections in the same order and reads revents to decide which to + * process. Checked ahead of the n == 0 arm: a round that found + * something to parse is not an expired wait, and treating it as one + * would signal the QEs and then loop back to the same state forever. + */ + else if (anyBuffered) + { + for (i = 0; i < nfds; i++) + { + if (buffered[i]) + fds[i].revents |= POLLIN; + } + + handlePollSuccess(pParms, fds); + } /* If the time limit expires, poll() returns 0 */ else if (n == 0) { @@ -657,6 +777,7 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) handlePollSuccess(pParms, fds); } + pfree(buffered); pfree(fds); } @@ -968,6 +1089,31 @@ checkSegmentAlive(CdbDispatchCmdAsync *pParms) } } +/* + * Write a sideband message nobody asked for, to order the stream a particular + * way under the nextval_sideband_before_response fault. + * + * Only the framing matters, so the body is a marker rather than anything a + * subscriber could use: every reader of this connection has to get past a + * message that is not its own, and what is inside it never comes into that. + * A QE that happens to be waiting for a real filter logs that it could not + * read one and carries on unfiltered, which is the same fail-open path as a + * truncated delivery. + */ +static inline void +send_fake_sideband_message(PGconn *conn) +{ + static const char marker[] = "fault"; + + if (pqPutMsgStart(GP_SIDEBAND_MESSAGE, conn) < 0) + elog(ERROR, "Failed to send sideband message: %s", PQerrorMessage(conn)); + pqPutnchar(marker, sizeof(marker), conn); + if (pqPutMsgEnd(conn) < 0) + elog(ERROR, "Failed to send sideband message: %s", PQerrorMessage(conn)); + if (pqFlush(conn) < 0) + elog(ERROR, "Failed to send sideband message: %s", PQerrorMessage(conn)); +} + static inline void send_sequence_response(PGconn *conn, Oid oid, int64 last, int64 cached, int64 increment, bool overflow, bool error) { @@ -988,6 +1134,110 @@ send_sequence_response(PGconn *conn, Oid oid, int64 last, int64 cached, int64 in elog(ERROR, "Failed to send sequence response: %s", PQerrorMessage(conn)); } +/* + * Hand over any notifications this QE has sent us. + * + * Kept separate from processResults() because it must run on every path that + * consumed input, not only the one where the QE still has work to do. A + * notification that arrives in the same read as the QE's command completion is + * already parsed out of the socket and queued on the connection, but that + * connection is about to be dropped from the poll set (stillRunning goes + * false), so nothing would ever wake us for it again -- the notification would + * be silently lost. nextval() never hit this because its QE blocks for a + * reply, leaving the command incomplete; a fire-and-forget sender does hit it. + */ +static void +processNotifies(CdbDispatchResult *dispatchResult) +{ + SegmentDatabaseDescriptor *segdbDesc = dispatchResult->segdbDesc; + + PGnotify *qnotifies = PQnotifies(segdbDesc->conn); + while(qnotifies && elog_geterrcode() == 0) + { + if (strcmp(qnotifies->relname, CDB_NOTIFY_NEXTVAL) == 0) + { + /* + * If there was nextval request then respond back on this libpq + * connection with the next value. Check and process nextval + * message only if QD has not already hit the error. Since QD could + * have hit the error while processing the previous nextval_qd() + * request itself and since full error handling is not complete yet + * (ex: releasing all the locks, etc.), shouldn't attempt to call + * nextval_qd() again. + */ + + CHECK_FOR_INTERRUPTS(); + + int64 last; + int64 cached; + int64 increment; + bool overflow; + Oid dbid; + Oid seq_oid; + + if (sscanf(qnotifies->extra, "%u:%u", &dbid, &seq_oid) != 2) + elog(ERROR, "invalid nextval message"); + + if (dbid != MyDatabaseId) + elog(ERROR, "nextval message database id:%u doesn't match my database id:%u", + dbid, MyDatabaseId); + + PG_TRY(); + { + nextval_qd(seq_oid, &last, &cached, &increment, &overflow); + } + PG_CATCH(); + { + send_sequence_response(segdbDesc->conn, seq_oid, last, cached, increment, overflow, true /* error */); + PG_RE_THROW(); + } + PG_END_TRY(); + + /* + * Put a sideband message in front of the reply, so the QE meets + * one while it is reading for something else. That ordering is a + * race in the field -- it needs a consumer to have given up on a + * filter the coordinator then delivers anyway -- and this makes it + * certain instead. The QE must skip the message and go on waiting + * for its reply; taking the type byte and erroring would leave the + * body in the stream and desynchronise the connection. + */ + if (SIMPLE_FAULT_INJECTOR("nextval_sideband_before_response") == + FaultInjectorTypeSkip) + send_fake_sideband_message(segdbDesc->conn); + + /* respond back on this libpq connection with the next value */ + send_sequence_response(segdbDesc->conn, seq_oid, last, cached, increment, overflow, false /* error */); + } + else if (strcmp(qnotifies->relname, CDB_NOTIFY_ENDPOINT_ACK) == 0) + { + qnotifies->next = (struct pgNotify *) dispatchResult->ackPGNotifies; + dispatchResult->ackPGNotifies = qnotifies; + + /* Don't free the notify here since it in queue now */ + qnotifies = NULL; + } + else if (cdbdisp_notify_hook != NULL && + cdbdisp_notify_hook(dispatchResult, qnotifies)) + { + /* Consumed by an extension; nothing further to do here. */ + } + else + { + /* Got an unknown PGnotify, just record it in log */ + if (qnotifies->relname) + elog(LOG, "got an unknown notify message : %s", qnotifies->relname); + } + + if (qnotifies) + PQfreemem(qnotifies); + qnotifies = PQnotifies(segdbDesc->conn); + } + + forwardQENotices(); + +} + /* * Receive and process input from one QE. * @@ -1058,7 +1308,12 @@ processResults(CdbDispatchResult *dispatchResult) if (!pRes) { ELOG_DISPATCHER_DEBUG("%s -> idle", segdbDesc->whoami); - /* this is normal end of command */ + /* + * Normal end of command. Take any notifications with us: this + * connection is about to leave the poll set, so this is the last + * chance to see them. + */ + processNotifies(dispatchResult); return true; } @@ -1155,72 +1410,7 @@ processResults(CdbDispatchResult *dispatchResult) } forwardQENotices(); - - PGnotify *qnotifies = PQnotifies(segdbDesc->conn); - while(qnotifies && elog_geterrcode() == 0) - { - if (strcmp(qnotifies->relname, CDB_NOTIFY_NEXTVAL) == 0) - { - /* - * If there was nextval request then respond back on this libpq - * connection with the next value. Check and process nextval - * message only if QD has not already hit the error. Since QD could - * have hit the error while processing the previous nextval_qd() - * request itself and since full error handling is not complete yet - * (ex: releasing all the locks, etc.), shouldn't attempt to call - * nextval_qd() again. - */ - - CHECK_FOR_INTERRUPTS(); - - int64 last; - int64 cached; - int64 increment; - bool overflow; - Oid dbid; - Oid seq_oid; - - if (sscanf(qnotifies->extra, "%u:%u", &dbid, &seq_oid) != 2) - elog(ERROR, "invalid nextval message"); - - if (dbid != MyDatabaseId) - elog(ERROR, "nextval message database id:%u doesn't match my database id:%u", - dbid, MyDatabaseId); - - PG_TRY(); - { - nextval_qd(seq_oid, &last, &cached, &increment, &overflow); - } - PG_CATCH(); - { - send_sequence_response(segdbDesc->conn, seq_oid, last, cached, increment, overflow, true /* error */); - PG_RE_THROW(); - } - PG_END_TRY(); - /* respond back on this libpq connection with the next value */ - send_sequence_response(segdbDesc->conn, seq_oid, last, cached, increment, overflow, false /* error */); - } - else if (strcmp(qnotifies->relname, CDB_NOTIFY_ENDPOINT_ACK) == 0) - { - qnotifies->next = (struct pgNotify *) dispatchResult->ackPGNotifies; - dispatchResult->ackPGNotifies = qnotifies; - - /* Don't free the notify here since it in queue now */ - qnotifies = NULL; - } - else - { - /* Got an unknown PGnotify, just record it in log */ - if (qnotifies->relname) - elog(LOG, "got an unknown notify message : %s", qnotifies->relname); - } - - if (qnotifies) - PQfreemem(qnotifies); - qnotifies = PQnotifies(segdbDesc->conn); - } - - forwardQENotices(); + processNotifies(dispatchResult); return false; /* we must keep on monitoring this socket */ } diff --git a/src/backend/commands/sequence.c b/src/backend/commands/sequence.c index 29992eb02fd..bf49e3d758d 100644 --- a/src/backend/commands/sequence.c +++ b/src/backend/commands/sequence.c @@ -2149,22 +2149,51 @@ cdb_sequence_nextval_qe(Relation seqrel, /* * Read nextval response from QD. + * + * The loop is here because this is not the only thing the QD sends down + * this connection: a sideband message may arrive for a waiter that has + * already stopped waiting for it, and it can land in the middle of this + * exchange. Skipping one and carrying on is the only safe answer, since + * the type byte has been taken by then and there is no way to put it back + * for someone else to read. */ - do + for (;;) { - pq_startmsgread(); - retval = pq_getbyte_if_available(&qtype); - if (retval == 0) + StringInfoData sideband; + + do { - pq_endmsgread(); - CHECK_FOR_INTERRUPTS(); - } + pq_startmsgread(); + retval = pq_getbyte_if_available(&qtype); + if (retval == 0) + { + pq_endmsgread(); + CHECK_FOR_INTERRUPTS(); + } - if (retval == EOF) + if (retval == EOF) + ereport(ERROR, + (errcode(ERRCODE_INTERNAL_ERROR), + errmsg("nextval: connection is gone unexpectedly"))); + } while (retval != 1); + + if (qtype != GP_SIDEBAND_MESSAGE) + break; + + /* + * Not ours. Read the body out whole and drop it: whoever it was meant + * for is no longer reading, and leaving it in the stream would put + * every later read at the wrong offset -- which does not surface here + * but as an absurd allocation or an invalid message type somewhere + * else entirely, after the connection has gone back to the gang pool. + */ + initStringInfo(&sideband); + if (pq_getmessage(&sideband, PQ_LARGE_MESSAGE_LIMIT) != 0) ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("nextval: connection is gone unexpectedly"))); - } while (retval != 1); + pfree(sideband.data); + } if (qtype == 'X') ereport(ERROR, (errcode(ERRCODE_INTERNAL_ERROR), errmsg("nextval: QD closed the connection"))); diff --git a/src/backend/lib/bloomfilter.c b/src/backend/lib/bloomfilter.c index bde46f5b6e6..c7f0ed3f0fd 100644 --- a/src/backend/lib/bloomfilter.c +++ b/src/backend/lib/bloomfilter.c @@ -293,6 +293,47 @@ mod_m(uint32 val, uint64 m) return val & (m - 1); } +Size +bloom_bitset_bytes(const bloom_filter *filter) +{ + return filter != NULL ? (Size) (filter->m / BITS_PER_BYTE) : 0; +} + +const unsigned char * +bloom_bitset_data(const bloom_filter *filter) +{ + return filter != NULL ? filter->bitset : NULL; +} + +/* + * Create a filter sized exactly as bloom_create(total_elems, work_mem, seed) and + * initialize its bitset from the supplied bytes in one step. Returns NULL if the + * supplied length is not the filter's bitset size, so a wrongly-sized bitset is + * rejected rather than partially loaded. This is the only supported way to + * populate a filter from serialized bytes; afterwards a filter is only ever grown + * by bloom_add_element (or a raw bitwise OR of two equal-sized serialized parts, + * as Anser's coordinator fold does). + */ +bloom_filter * +bloom_create_from_bitset(int64 total_elems, int bloom_work_mem, uint64 seed, + const unsigned char *bitset, Size bitset_len) +{ + bloom_filter *filter; + + if (bitset == NULL) + return NULL; + + filter = bloom_create(total_elems, bloom_work_mem, seed); + if (bitset_len != (Size) (filter->m / BITS_PER_BYTE)) + { + bloom_free(filter); + return NULL; + } + + memcpy(filter->bitset, bitset, bitset_len); + return filter; +} + double bloom_false_positive_rate(bloom_filter *filter) { diff --git a/src/backend/nodes/gen_node_support.pl b/src/backend/nodes/gen_node_support.pl index df7b8e43e97..efb1103ce5d 100755 --- a/src/backend/nodes/gen_node_support.pl +++ b/src/backend/nodes/gen_node_support.pl @@ -1434,10 +1434,16 @@ sub elem ! unless $no_read; # for out and read fast + # + # Binary, to match READ_STRING_VAR below: a 4-byte length then the + # bytes. This used to be a copy of the text branch above, which + # wrote " :methods " and an outToken() into the binary stream; the + # reader then took the first four ASCII bytes of that (" :me") as + # the string length and asked for a 1.7 GB allocation. Any plan + # containing a CustomScan failed to deserialize on the segments. print $ofaf q{ /* CustomName is a key to lookup CustomScanMethods */ - appendStringInfoString(str, " :methods "); - outToken(str, node->methods->CustomName); + WRITE_STRING_VAR(node->methods->CustomName); }; print $rfaf q! { diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c index 806ccfcef85..394f8c9b39f 100644 --- a/src/backend/tcop/postgres.c +++ b/src/backend/tcop/postgres.c @@ -596,6 +596,11 @@ SocketBackend(StringInfo inBuf) doing_extended_query_message = false; break; + case GP_SIDEBAND_MESSAGE: /* Cloudberry QD -> QE sideband data */ + maxmsglen = PQ_LARGE_MESSAGE_LIMIT; + doing_extended_query_message = false; + break; + default: /* @@ -6437,6 +6442,18 @@ PostgresMain(const char *dbname, const char *username) */ break; + case GP_SIDEBAND_MESSAGE: /* Cloudberry QD -> QE sideband data */ + /* + * Accept but ignore this message. A QE reads sideband data + * explicitly at the point it expects it; reaching the command + * loop means nobody is waiting for it any more -- typically + * the waiter was interrupted (query cancel) after the QD had + * already written the message. Same reasoning as '?' above, + * but a wider window, since a sideband waiter may block for + * the lifetime of a query. + */ + break; + default: ereport(FATAL, (errcode(ERRCODE_PROTOCOL_VIOLATION), diff --git a/src/include/catalog/catversion.h b/src/include/catalog/catversion.h index c9c2fd83275..8ec2c11f0e9 100644 --- a/src/include/catalog/catversion.h +++ b/src/include/catalog/catversion.h @@ -60,6 +60,6 @@ */ /* 3yyymmddN */ -#define CATALOG_VERSION_NO 302608261 +#define CATALOG_VERSION_NO 302609151 #endif diff --git a/src/include/cdb/cdbdisp.h b/src/include/cdb/cdbdisp.h index 9fac725c5fd..3c7179fffe6 100644 --- a/src/include/cdb/cdbdisp.h +++ b/src/include/cdb/cdbdisp.h @@ -22,11 +22,34 @@ #define CDB_MOTION_LOST_CONTACT_STRING "Interconnect error master lost contact with segment." struct CdbDispatchResults; /* #include "cdb/cdbdispatchresult.h" */ +struct CdbDispatchResult; /* #include "cdb/cdbdispatchresult.h" */ struct CdbPgResults; struct Gang; /* #include "cdb/cdbgang.h" */ struct ResourceOwnerData; +struct pgNotify; /* #include "libpq-fe.h" */ enum GangType; +/* + * Hook for an extension to consume a NOTIFY sent by a QE on its dispatch + * connection, alongside the sequence and endpoint-ack channels the dispatcher + * handles itself. Return true if the notify was consumed, false to let the + * dispatcher fall through to its own handling. + * + * 'dispatchResult' identifies the sending QE and, through its meleeResults, + * gives access to every connection of the current dispatch -- which is what + * lets a handler answer on other QEs' connections (see GP_SIDEBAND_MESSAGE). + * + * The hook runs inside the QD's dispatch-result processing, which is reached + * from the interconnect wait loop while the query is executing. It must + * therefore be quick, must not re-enter the dispatcher, and should not throw + * for conditions it can recover from: an error here propagates into the + * running query. The PGnotify belongs to the caller and is freed after the + * hook returns, so a handler must copy anything it wants to keep. + */ +typedef bool (*cdbdisp_notify_hook_type) (struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify); +extern PGDLLIMPORT cdbdisp_notify_hook_type cdbdisp_notify_hook; + /* * Types of message to QE when we wait for it. */ diff --git a/src/include/cdb/cdbvars.h b/src/include/cdb/cdbvars.h index 5389e5a3979..efd05538a17 100644 --- a/src/include/cdb/cdbvars.h +++ b/src/include/cdb/cdbvars.h @@ -802,6 +802,19 @@ extern const char * lookup_autostats_mode_by_value(GpAutoStatsModeValue val); */ #define CDB_NOTIFY_ENDPOINT_ACK "ack_notify" +/* + * Message type for QD -> QE sideband data sent on the dispatch connection + * outside the normal query protocol, in the same spirit as the '?' sequence + * response. The QE reads it explicitly where it expects it; a message that + * arrives unexpectedly (typically because the QE was cancelled while waiting + * for one) is accepted and discarded by the command loop, so it cannot break + * message-boundary sync. + * + * Unlike '?', a sideband message may be large: it is read with + * PQ_LARGE_MESSAGE_LIMIT. + */ +#define GP_SIDEBAND_MESSAGE '!' + typedef enum WarehouseStatus { WAREHOUSE_STATUS_CREATING, diff --git a/src/include/lib/bloomfilter.h b/src/include/lib/bloomfilter.h index 05b736f566e..51afa223e03 100644 --- a/src/include/lib/bloomfilter.h +++ b/src/include/lib/bloomfilter.h @@ -27,5 +27,11 @@ extern double bloom_false_positive_rate(bloom_filter *filter); extern uint64 bloom_total_bits(bloom_filter *filter); extern bloom_filter *bloom_create_aggresive(int64 total_elems, int work_mem, uint64 seed); +extern Size bloom_bitset_bytes(const bloom_filter *filter); +extern const unsigned char *bloom_bitset_data(const bloom_filter *filter); +extern bloom_filter *bloom_create_from_bitset(int64 total_elems, + int bloom_work_mem, uint64 seed, + const unsigned char *bitset, + Size bitset_len); #endif /* BLOOMFILTER_H */ diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile index 77af107b55e..8b67ea905c7 100644 --- a/src/test/modules/Makefile +++ b/src/test/modules/Makefile @@ -10,6 +10,7 @@ SUBDIRS = \ delay_execution \ dummy_index_am \ dummy_seclabel \ + libpq_isbusy_notify \ libpq_pipeline \ plsample \ snapshot_too_old \ diff --git a/src/test/modules/libpq_isbusy_notify/.gitignore b/src/test/modules/libpq_isbusy_notify/.gitignore new file mode 100644 index 00000000000..5dd57c4b4f5 --- /dev/null +++ b/src/test/modules/libpq_isbusy_notify/.gitignore @@ -0,0 +1,5 @@ +# Generated subdirectories +/log/ +/results/ +/tmp_check/ +/libpq_isbusy_notify diff --git a/src/test/modules/libpq_isbusy_notify/Makefile b/src/test/modules/libpq_isbusy_notify/Makefile new file mode 100644 index 00000000000..7ea27d27d95 --- /dev/null +++ b/src/test/modules/libpq_isbusy_notify/Makefile @@ -0,0 +1,27 @@ +# src/test/modules/libpq_isbusy_notify/Makefile + +PGFILEDESC = "libpq_isbusy_notify - test program for PQisBusy and queued notifications" +PGAPPICON = win32 + +PROGRAM = libpq_isbusy_notify +OBJS = $(WIN32RES) libpq_isbusy_notify.o + +NO_INSTALL = 1 + +# libpq-int.h: the test builds a connection state by hand rather than reading +# one off a socket, which is the only way to reach the case without a server. +PG_CPPFLAGS = -I$(libpq_srcdir) +PG_LIBS_INTERNAL += $(libpq_pgport) + +TAP_TESTS = 1 + +ifdef USE_PGXS +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) +else +subdir = src/test/modules/libpq_isbusy_notify +top_builddir = ../../../.. +include $(top_builddir)/src/Makefile.global +include $(top_srcdir)/contrib/contrib-global.mk +endif diff --git a/src/test/modules/libpq_isbusy_notify/libpq_isbusy_notify.c b/src/test/modules/libpq_isbusy_notify/libpq_isbusy_notify.c new file mode 100644 index 00000000000..f11d0435d46 --- /dev/null +++ b/src/test/modules/libpq_isbusy_notify/libpq_isbusy_notify.c @@ -0,0 +1,162 @@ +/*------------------------------------------------------------------------- + * + * libpq_isbusy_notify.c + * PQisBusy() does not report a notification that is ready to collect. + * + * libpq's parser handles a NOTIFY by queueing it on the connection and + * carrying on: pqParseInput3's 'A' arm calls getNotify() and then breaks, + * never touching asyncStatus. PQisBusy() reports asyncStatus, so while the + * command that is in flight has not finished it keeps answering true -- even + * though a complete notification is sitting in conn->notifyHead with nothing + * left unread in the connection's buffer. + * + * Anything that treats !PQisBusy() as "nothing is ready for me" therefore has + * a blind spot, and the blind spot bites when the bytes reached the buffer + * without the socket staying readable. That is not exotic: a write that + * cannot complete in one go reads the peer's pending output to avoid + * deadlocking against it (pqSendSome -> pqReadData, fe-misc.c), so the + * notification can already be in our memory while poll() has nothing to + * report. The coordinator's dispatcher hit exactly this, which is why it now + * drains notifications before deciding whether to wait + * (checkDispatchResult, cdbdisp_async.c). + * + * This test pins the libpq behaviour that reasoning depends on. No server is + * involved and nothing is read from a socket: the input buffer is filled by + * hand with one complete NOTIFY message, which models the state after the + * bytes have already been absorbed. If libpq ever changes so that a queued + * notification stops PQisBusy() returning true, this fails -- and the drain in + * the dispatcher has become unnecessary. + * + * IDENTIFICATION + * src/test/modules/libpq_isbusy_notify/libpq_isbusy_notify.c + * + *------------------------------------------------------------------------- + */ +#include "postgres_fe.h" + +#include + +#include "libpq-fe.h" +#include "libpq-int.h" + +/* A NOTIFY as it appears on the wire, minus the leading message type byte. */ +static const char notify_channel[] = "anser"; +static const char notify_payload[] = "part"; + +int +main(void) +{ + PGconn *conn; + uint32 netlen; + uint32 netpid; + int msglen; + int pos = 0; + int busy; + int unread; + PGnotify *notify; + int failures = 0; + + /* + * PQconnectStart() allocates the buffers and returns without waiting for + * the connection, which is all we need -- the path is deliberately one + * that cannot exist, and the resulting status is overwritten below. We + * never read or write the socket, so no server is required. + */ + conn = PQconnectStart("host=/nonexistent-libpq-isbusy-notify dbname=postgres"); + if (conn == NULL) + { + fprintf(stderr, "could not allocate a connection\n"); + return 2; + } + + /* 4-byte length, 4-byte sender pid, then two NUL-terminated strings. */ + msglen = 4 + 4 + sizeof(notify_channel) + sizeof(notify_payload); + if (conn->inBufSize < msglen + 1) + { + fprintf(stderr, "input buffer is too small for the test message\n"); + PQfinish(conn); + return 2; + } + + /* + * Put the connection in the state it would be in with a command still in + * flight and one complete notification already parsed off the socket. + */ + conn->status = CONNECTION_OK; + conn->asyncStatus = PGASYNC_BUSY; + conn->inStart = conn->inCursor = 0; + + conn->inBuffer[pos++] = 'A'; + netlen = htonl(msglen); + memcpy(conn->inBuffer + pos, &netlen, 4); + pos += 4; + netpid = htonl(123); + memcpy(conn->inBuffer + pos, &netpid, 4); + pos += 4; + memcpy(conn->inBuffer + pos, notify_channel, sizeof(notify_channel)); + pos += sizeof(notify_channel); + memcpy(conn->inBuffer + pos, notify_payload, sizeof(notify_payload)); + pos += sizeof(notify_payload); + conn->inEnd = pos; + + /* Parses the buffer; never touches the socket. */ + busy = PQisBusy(conn); + unread = conn->inEnd - conn->inStart; + + printf("PQisBusy=%d notify_queued=%d unread_bytes=%d\n", + busy, conn->notifyHead != NULL, unread); + + /* + * The three halves of the trap, each worth failing on separately: the + * notification is ready, the connection still reports itself busy, and + * there is nothing left that would make the socket readable again. + */ + if (conn->notifyHead == NULL) + { + fprintf(stderr, "FAIL: the notification was not queued\n"); + failures++; + } + if (!busy) + { + fprintf(stderr, + "FAIL: PQisBusy() reported the notification, so a caller " + "watching it alone would not miss one\n"); + failures++; + } + if (unread != 0) + { + fprintf(stderr, + "FAIL: %d byte(s) left unparsed, so the message was not " + "complete and the test proved nothing\n", unread); + failures++; + } + + /* And it really is collectable, by the call that does look for it. */ + notify = PQnotifies(conn); + if (notify == NULL) + { + fprintf(stderr, "FAIL: PQnotifies() returned nothing\n"); + failures++; + } + else + { + printf("PQnotifies: channel=%s payload=%s\n", + notify->relname, notify->extra); + if (strcmp(notify->relname, notify_channel) != 0 || + strcmp(notify->extra, notify_payload) != 0) + { + fprintf(stderr, "FAIL: the notification did not round-trip\n"); + failures++; + } + PQfreemem(notify); + } + + /* + * Nothing was ever connected, so stop PQfinish() from trying to send a + * terminate message down a socket we invented a state for. + */ + conn->status = CONNECTION_BAD; + PQfinish(conn); + + return failures == 0 ? 0 : 1; +} diff --git a/src/test/modules/libpq_isbusy_notify/meson.build b/src/test/modules/libpq_isbusy_notify/meson.build new file mode 100644 index 00000000000..c13e1dfcdbd --- /dev/null +++ b/src/test/modules/libpq_isbusy_notify/meson.build @@ -0,0 +1,47 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +libpq_isbusy_notify_sources = files( + 'libpq_isbusy_notify.c', +) + +if host_system == 'windows' + libpq_isbusy_notify_sources += rc_bin_gen.process(win32ver_rc, extra_args: [ + '--NAME', 'libpq_isbusy_notify', + '--FILEDESC', 'libpq_isbusy_notify - test program for PQisBusy and queued notifications',]) +endif + +libpq_isbusy_notify = executable('libpq_isbusy_notify', + libpq_isbusy_notify_sources, + dependencies: [frontend_code, libpq], + kwargs: default_bin_args + { + 'install': false, + }, +) +testprep_targets += libpq_isbusy_notify + +tests += { + 'name': 'libpq_isbusy_notify', + 'sd': meson.current_source_dir(), + 'bd': meson.current_build_dir(), + 'tap': { + 'tests': [ + 't/001_isbusy_notify.pl', + ], + 'deps': [libpq_isbusy_notify], + }, +} diff --git a/src/test/modules/libpq_isbusy_notify/t/001_isbusy_notify.pl b/src/test/modules/libpq_isbusy_notify/t/001_isbusy_notify.pl new file mode 100644 index 00000000000..1b29f773e0f --- /dev/null +++ b/src/test/modules/libpq_isbusy_notify/t/001_isbusy_notify.pl @@ -0,0 +1,33 @@ + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +use strict; +use warnings; + +use PostgreSQL::Test::Utils; +use Test::More; + +# No cluster is started. The program constructs the connection state it needs +# by hand and never reads or writes a socket, which is the point: the state +# under test is "the bytes are already in our buffer and the socket has gone +# quiet", awkward to provoke against a live server and trivial to build. +command_ok( + ['libpq_isbusy_notify'], + 'PQisBusy() stays true while a complete notification is queued'); + +done_testing(); diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build index 9556bac4ad4..e2f8387fe33 100644 --- a/src/test/modules/meson.build +++ b/src/test/modules/meson.build @@ -6,6 +6,7 @@ subdir('delay_execution') subdir('dummy_index_am') subdir('dummy_seclabel') subdir('ldap_password_func') +subdir('libpq_isbusy_notify') subdir('libpq_pipeline') subdir('plsample') subdir('snapshot_too_old')