From 4249b78fa668462dd7103d86b0653f5b63cd4558 Mon Sep 17 00:00:00 2001 From: Leonid Borchuk Date: Thu, 15 Oct 2026 13:58:45 +0300 Subject: [PATCH 1/5] Let an extension share runtime information between QEs Anser needs to move a small piece of information -- today a bloom filter over a join key -- from the segments to the coordinator and back, over the dispatch connection that is already open. Four things in the backend stand in the way, and none of them mentions Anser. A QE can already send a NOTIFY to the coordinator, but the dispatcher dropped any it did not recognise. Add cdbdisp_notify_hook so an extension can claim one, and keep the ones that arrive in the same read as a command completion instead of losing them. The other direction had no message type at all. Add GP_SIDEBAND_MESSAGE, which the QE command loop accepts and discards when nobody is waiting -- a waiter can be cancelled after the coordinator has written, and an unknown type would otherwise be fatal. A CustomScan travelled through the binary plan writer as the text " :methods " plus a token, and the reader took the first four bytes of that as a length and asked for 1.7 GB. Any plan with a CustomScan failed to deserialize on the segments; write the name as a counted string instead. catversion is bumped accordingly. Finally, a bloom filter cannot be shipped without reaching its bitset, so expose it along with the sizing its holder must agree on. Co-Authored-By: Claude Opus 5 --- src/backend/cdb/dispatcher/cdbdisp_async.c | 235 +++++++++++++++------ src/backend/lib/bloomfilter.c | 41 ++++ src/backend/nodes/gen_node_support.pl | 10 +- src/backend/tcop/postgres.c | 17 ++ src/include/catalog/catversion.h | 2 +- src/include/cdb/cdbdisp.h | 23 ++ src/include/cdb/cdbvars.h | 13 ++ src/include/lib/bloomfilter.h | 6 + 8 files changed, 277 insertions(+), 70 deletions(-) diff --git a/src/backend/cdb/dispatcher/cdbdisp_async.c b/src/backend/cdb/dispatcher/cdbdisp_async.c index eb8e4714396..536ef3c15ae 100644 --- a/src/backend/cdb/dispatcher/cdbdisp_async.c +++ b/src/backend/cdb/dispatcher/cdbdisp_async.c @@ -116,6 +116,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 +142,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 +466,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 +476,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 +493,7 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) int n; int nfds = 0; int ack_count = 0; + bool anyBuffered = false; PGconn *conn; /* @@ -555,6 +571,33 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) Assert(sock >= 0); fds[nfds].fd = sock; fds[nfds].events = POLLIN; + + /* + * 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 +625,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 +671,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 +727,7 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) handlePollSuccess(pParms, fds); } + pfree(buffered); pfree(fds); } @@ -988,6 +1059,96 @@ 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(); + /* 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 +1219,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 +1321,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/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 */ From 474c4989551528f2fb3c7e4a54af3ddfbfd8f98f Mon Sep 17 00:00:00 2001 From: Leonid Borchuk Date: Thu, 15 Oct 2026 13:59:18 +0300 Subject: [PATCH 2/5] Feature: Anser runtime filters for hash joins Anser is a publish/subscribe facility for MPP execution. Producers on the segments publish something about a running query, the coordinator combines the per-segment parts into one, and consumers on the segments receive it and prune work with it. Today that is a bloom filter over a hash join's build key, used to skip probe rows that cannot join. A channel is the rendezvous for one such piece of information in one query, keyed by session, command, and the condition it describes. It lives only in the coordinator backend running the query, for exactly as long as that query runs: no shared memory, no background worker, no second connection to authenticate. Parts travel up as a NOTIFY and the merged payload comes back as a sideband message. Both nodes wrap a base scan rather than an intermediate node, which makes the filter a superset of the keys the joins below would have left -- less selective, never wrong. The direction is fixed to build -> probe, because a consumer on the inner side would wait on a producer the join is not yet reading, which is a deadlock and not a slow path. Every failure ends in unfiltered execution: a filter too small to reject anything, a squelched producer, a malformed part, a missed deadline. The planner gate is the one that costs nothing, and the later ones exist because a row estimate can be wrong. Built only with --with-anser and off by default; the subsystem creates no catalog objects and is configured entirely by GUCs. Includes the regression suites and the CI job that runs them. Co-Authored-By: Claude Opus 5 --- .github/workflows/build-cloudberry.yml | 14 + configure | 32 + configure.ac | 7 + .../scripts/configure-cloudberry.sh | 2 + gpcontrib/Makefile | 7 + gpcontrib/anser/Makefile | 103 +++ gpcontrib/anser/README.md | 454 ++++++++++ gpcontrib/anser/anser_test--1.0.sql | 99 +++ gpcontrib/anser/anser_test.control | 22 + gpcontrib/anser/expected/anser_multijoin.out | 258 ++++++ .../anser/expected/anser_runtime_filter.out | 399 +++++++++ gpcontrib/anser/expected/anser_test.out | 286 +++++++ gpcontrib/anser/include/anser.h | 84 ++ gpcontrib/anser/include/anserbloom.h | 79 ++ gpcontrib/anser/include/anserfilter.h | 127 +++ gpcontrib/anser/include/anserpayload.h | 150 ++++ gpcontrib/anser/include/anserplan.h | 101 +++ gpcontrib/anser/include/ansersideband.h | 249 ++++++ gpcontrib/anser/sql/anser_multijoin.sql | 228 +++++ gpcontrib/anser/sql/anser_runtime_filter.sql | 305 +++++++ gpcontrib/anser/sql/anser_test.sql | 184 ++++ gpcontrib/anser/src/anser_test.c | 765 +++++++++++++++++ gpcontrib/anser/src/anserbloomconsume.c | 202 +++++ gpcontrib/anser/src/anserbloomproduce.c | 219 +++++ gpcontrib/anser/src/anserdispatch.c | 713 ++++++++++++++++ gpcontrib/anser/src/anserfilter.c | 376 ++++++++ gpcontrib/anser/src/anserinit.c | 235 +++++ gpcontrib/anser/src/anserpayload.c | 108 +++ gpcontrib/anser/src/anserplan.c | 637 ++++++++++++++ gpcontrib/anser/src/anserplanexec.c | 803 ++++++++++++++++++ gpcontrib/anser/src/ansersideband.c | 641 ++++++++++++++ pom.xml | 4 + src/Makefile.global.in | 1 + 33 files changed, 7894 insertions(+) create mode 100644 gpcontrib/anser/Makefile create mode 100644 gpcontrib/anser/README.md create mode 100644 gpcontrib/anser/anser_test--1.0.sql create mode 100644 gpcontrib/anser/anser_test.control create mode 100644 gpcontrib/anser/expected/anser_multijoin.out create mode 100644 gpcontrib/anser/expected/anser_runtime_filter.out create mode 100644 gpcontrib/anser/expected/anser_test.out create mode 100644 gpcontrib/anser/include/anser.h create mode 100644 gpcontrib/anser/include/anserbloom.h create mode 100644 gpcontrib/anser/include/anserfilter.h create mode 100644 gpcontrib/anser/include/anserpayload.h create mode 100644 gpcontrib/anser/include/anserplan.h create mode 100644 gpcontrib/anser/include/ansersideband.h create mode 100644 gpcontrib/anser/sql/anser_multijoin.sql create mode 100644 gpcontrib/anser/sql/anser_runtime_filter.sql create mode 100644 gpcontrib/anser/sql/anser_test.sql create mode 100644 gpcontrib/anser/src/anser_test.c create mode 100644 gpcontrib/anser/src/anserbloomconsume.c create mode 100644 gpcontrib/anser/src/anserbloomproduce.c create mode 100644 gpcontrib/anser/src/anserdispatch.c create mode 100644 gpcontrib/anser/src/anserfilter.c create mode 100644 gpcontrib/anser/src/anserinit.c create mode 100644 gpcontrib/anser/src/anserpayload.c create mode 100644 gpcontrib/anser/src/anserplan.c create mode 100644 gpcontrib/anser/src/anserplanexec.c create mode 100644 gpcontrib/anser/src/ansersideband.c 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..63274a27faa 100755 --- a/configure +++ b/configure @@ -820,6 +820,7 @@ enable_debug enable_orafce enable_faultinjector enable_debug_extensions +with_anser enable_pxf enable_gpfdist enable_strong_random @@ -891,6 +892,7 @@ enable_atomics enable_strong_random enable_gpfdist enable_pxf +with_anser enable_debug_extensions enable_faultinjector enable_orafce @@ -1656,6 +1658,7 @@ Optional Features: Optional Packages: --with-PACKAGE[=ARG] use PACKAGE [ARG=yes] --without-PACKAGE do not use PACKAGE (same as --with-PACKAGE=no) + --without-anser do not build the Anser runtime-filter extension --with-extra-version=STRING append STRING to version --with-template=NAME override operating system template @@ -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..7d6e9305581 --- /dev/null +++ b/gpcontrib/anser/README.md @@ -0,0 +1,454 @@ +# 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. + +### 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..3c2f8d14cfd --- /dev/null +++ b/gpcontrib/anser/anser_test--1.0.sql @@ -0,0 +1,99 @@ +/* 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; 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..cbc5fbceee8 --- /dev/null +++ b/gpcontrib/anser/expected/anser_runtime_filter.out @@ -0,0 +1,399 @@ +-- 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; +-- 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) + +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; +-- --------------------------------------------------------------------------- +-- 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 join key is the distribution key on both sides, so no Motion is needed +-- to co-locate the join. That is the only shape either planner parallelises: +-- a redistributed or broadcast build side is fine for GPORCA but the Postgres +-- planner marks Motion paths parallel-unsafe. +-- --------------------------------------------------------------------------- +CREATE TABLE anser_rf_par_build (id int, v text) DISTRIBUTED BY (id); +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..9e663672a02 --- /dev/null +++ b/gpcontrib/anser/expected/anser_test.out @@ -0,0 +1,286 @@ +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) + +DROP EXTENSION anser_test; diff --git a/gpcontrib/anser/include/anser.h b/gpcontrib/anser/include/anser.h new file mode 100644 index 00000000000..5bfa5d772e8 --- /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 bool gp_anser_enable; +extern bool gp_anser_runtime_filter; +extern bool gp_anser_debug; +extern int gp_anser_max_info_size; +extern 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..24c7eaba7f6 --- /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 AnserBloomFilterProduceState *ExecInitAnserBloomFilterProduce( + const AnserChannelKey *channel_key, + int64 total_elems, + Size max_payload_bytes, + uint32 part_index, + uint32 total_parts); +extern void ExecAnserBloomFilterProduceAddDatum(AnserBloomFilterProduceState *state, + Datum value, bool isnull); +extern bool ExecAnserBloomFilterProduceHasFilter(AnserBloomFilterProduceState *state); +extern bool ExecAnserBloomFilterProducePublish(AnserBloomFilterProduceState *state); +extern bool ExecAnserBloomFilterProduceCancel(AnserBloomFilterProduceState *state); +extern void ExecEndAnserBloomFilterProduce(AnserBloomFilterProduceState *state); + +/* Consumer side: gather all parts of a channel into one Bloom filter. */ +extern AnserBloomFilterConsumeState *ExecInitAnserBloomFilterConsume( + const AnserChannelKey *channel_key, + int64 total_elems, + Size max_payload_bytes, + uint32 expected_parts); +extern bool ExecAnserBloomFilterConsume(AnserBloomFilterConsumeState *state, + long registration_timeout_ms); +extern bloom_filter *ExecAnserBloomFilterConsumerGetFilter( + AnserBloomFilterConsumeState *state); +extern uint32 ExecAnserBloomFilterConsumerReceivedParts( + AnserBloomFilterConsumeState *state); +extern bool ExecAnserBloomFilterConsumerWasCancelled( + AnserBloomFilterConsumeState *state); +extern 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..7e50e25c042 --- /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 bool AnserBloomPartWorthSending(const void *payload, Size payload_len); + +extern 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 bloom_filter *AnserBloomCreate(int64 total_elems, + Size max_payload_bytes, + uint64 seed); +extern Size AnserBloomSerializedSize(const bloom_filter *filter); +extern bool AnserBloomSerializePart(const bloom_filter *filter, + uint32 part_index, + uint32 total_parts, + void *buffer, + Size buffer_size, + Size *payload_len); +extern 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 bool AnserBloomLooksLikePart(const void *payload, Size payload_len); +extern 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..cb5f30dffcf --- /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 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 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..4e59453210d --- /dev/null +++ b/gpcontrib/anser/include/anserplan.h @@ -0,0 +1,101 @@ +/*------------------------------------------------------------------------- + * + * 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/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 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 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 bool AnserIsRuntimeFilterScan(const Plan *plan); + +extern 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 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 bool AnserRuntimeFilterSize(double est_rows, int64 *total_elems, + int64 *max_payload, int64 *planned_bytes); + +#endif /* ANSERPLAN_H */ diff --git a/gpcontrib/anser/include/ansersideband.h b/gpcontrib/anser/include/ansersideband.h new file mode 100644 index 00000000000..2cbea9336cd --- /dev/null +++ b/gpcontrib/anser/include/ansersideband.h @@ -0,0 +1,249 @@ +/*------------------------------------------------------------------------- + * + * 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 */ + +/* 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 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 bool AnserWireParse(const char *msg, AnserWireMsg *out); +extern 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 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 bool AnserSidebandPublish(const AnserChannelKey *channel_key, + char payload_type, + uint32 part_index, uint32 total_parts, + const void *payload, Size payload_len, + bool cancelled); +extern 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 bool AnserDispatchNotifyHandler(struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify); +extern bool AnserDispatchLocalPublish(const AnserChannelKey *channel_key, + char payload_type, + uint32 part_index, uint32 total_parts, + const void *payload, Size payload_len, + bool cancelled); +extern 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 void AnserDispatchReset(void); +extern void AnserSidebandResetInbox(void); +extern 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..06e12ac2f2f --- /dev/null +++ b/gpcontrib/anser/sql/anser_runtime_filter.sql @@ -0,0 +1,305 @@ +-- 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; + + +-- 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; +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; + +-- --------------------------------------------------------------------------- +-- 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 join key is the distribution key on both sides, so no Motion is needed +-- to co-locate the join. That is the only shape either planner parallelises: +-- a redistributed or broadcast build side is fine for GPORCA but the Postgres +-- planner marks Motion paths parallel-unsafe. +-- --------------------------------------------------------------------------- +CREATE TABLE anser_rf_par_build (id int, v text) DISTRIBUTED BY (id); +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..0c6e3585c89 --- /dev/null +++ b/gpcontrib/anser/sql/anser_test.sql @@ -0,0 +1,184 @@ +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; + +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..456d1a4a004 --- /dev/null +++ b/gpcontrib/anser/src/anser_test.c @@ -0,0 +1,765 @@ +/*------------------------------------------------------------------------- + * + * 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 "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 "miscadmin.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))); +} 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..a48e3a0d1f7 --- /dev/null +++ b/gpcontrib/anser/src/anserdispatch.c @@ -0,0 +1,713 @@ +/*------------------------------------------------------------------------- + * + * 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 "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; +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; +} + +/* + * 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_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; + + 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) || + pqPutMsgEnd(conn) < 0 || + pqFlush(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) + 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..10b0a28498f --- /dev/null +++ b/gpcontrib/anser/src/anserinit.c @@ -0,0 +1,235 @@ +/*------------------------------------------------------------------------- + * + * 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 + * 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 ExecutorEnd_hook_type prev_ExecutorEnd_hook = NULL; + +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_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. + */ + cdbdisp_notify_hook = AnserDispatchNotifyHandler; + + /* + * 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; +} + +/* + * Drop the transport's per-query state. + * + * Nested executor runs (a function body, say) must not clear state the outer + * query is still using, so only the outermost end resets. + */ +static void +anser_executor_end(QueryDesc *queryDesc) +{ + static int nesting_level = 0; + + nesting_level++; + PG_TRY(); + { + if (prev_ExecutorEnd_hook) + prev_ExecutorEnd_hook(queryDesc); + else + standard_ExecutorEnd(queryDesc); + } + PG_FINALLY(); + { + nesting_level--; + } + PG_END_TRY(); + + if (nesting_level == 0) + AnserSidebandResetAll(); +} + +static void +anser_xact_callback(XactEvent event, void *arg) +{ + 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..7fa5aae5189 --- /dev/null +++ b/gpcontrib/anser/src/anserplan.c @@ -0,0 +1,637 @@ +/*------------------------------------------------------------------------- + * + * 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 anser_slice_producers */ + int slice_index; /* slice the subtree being walked runs in */ + 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 int anser_slice_producers(AnserInjectCtx *ctx, int slice_index); +static bool anser_hashjoin_keys(HashJoin *hj, AttrNumber *inner_attno, + AttrNumber *outer_attno); +static bool anser_resolve_key_scan(Plan *top, AttrNumber key_attno, + int slice_index, Plan **parent_out, + Plan **scan_out, AttrNumber *attno_out, + int *slice_out); +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; + + { + AnserInjectCtx ctx; + 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; + + anser_inject_walk(stmt->planTree, &ctx); + } +} + +/* + * Largest plan_node_id in a plan subtree. Recurses the spine plus CustomScan + * children; sufficient for the supported (simple) plan shape. + */ +static int +anser_max_plan_node_id(Plan *plan) +{ + int m; + + 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))); + if (IsA(plan, CustomScan)) + { + ListCell *lc; + + foreach(lc, ((CustomScan *) plan)->custom_plans) + m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + } + + 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. + */ +static bool +anser_resolve_key_scan(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; + + if (!anser_hashjoin_keys(hj, &inner_attno, &outer_attno)) + return; + if (!anser_resolve_key_scan(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; + } + else if (!anser_resolve_key_scan(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; + + /* + * 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 = anser_slice_producers(ctx, 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); + 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): a slice runs + * numsegments * parallel_workers processes, with parallel_workers == 0 meaning + * one. 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. + */ +static int +anser_slice_producers(AnserInjectCtx *ctx, int slice_index) +{ + PlanSlice *slice; + int factor; + + if (ctx->stmt->slices == NULL || + slice_index < 0 || slice_index >= ctx->stmt->numSlices) + return 0; /* unknown: caller declines to inject */ + + slice = &ctx->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; + 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..3680b9215c8 --- /dev/null +++ b/gpcontrib/anser/src/anserplanexec.c @@ -0,0 +1,803 @@ +/*------------------------------------------------------------------------- + * + * 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 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, and only sound on the + * coordinator -- a QE resolves these pointers by name when it deserializes the + * plan, which is long after the only pass that asks. + */ +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; + + 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; + + /* + * A producer that ran but never published was abandoned mid-scan (squelch, + * or an error upstream), and consumers must be told so rather than left + * waiting for a part that will never come. A producer that never ran at + * all belongs to another slice: cancelling here would destroy a channel + * this process has no part in -- which is exactly what the coordinator + * used to do to every runtime filter. + */ + if (st->started && !st->published && st->produce != NULL) + { + (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)); +} + +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)); +} + +/* + * Hand the received filter to the child SeqScan 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. + */ +static void +anser_consume_pushdown(CustomScanState *node) +{ + AnserBloomConsumeScanState *st = (AnserBloomConsumeScanState *) node; + PlanState *child = (PlanState *) linitial(node->custom_ps); + SeqScanState *sss; + ScanKey sk; + MemoryContext oldcxt; + + if (!gp_enable_runtime_filter_pushdown || !IsA(child, SeqScanState)) + return; + sss = (SeqScanState *) child; + if (!sss->filter_in_seqscan) + return; + + oldcxt = MemoryContextSwitchTo(node->ss.ps.state->es_query_cxt); + sk = (ScanKey) palloc0(sizeof(ScanKeyData)); + sk->sk_attno = st->key_attno; + sk->sk_flags = SK_BLOOM_FILTER; + sk->sk_argument = PointerGetDatum(st->filter); + sss->filters = lappend(sss->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); + + 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; +} + +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..babc7bbaba8 --- /dev/null +++ b/gpcontrib/anser/src/ansersideband.c @@ -0,0 +1,641 @@ +/*------------------------------------------------------------------------- + * + * 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; + + /* + * 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(); + + 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)) + { + /* + * 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) + { + /* + * Nothing else should reach us here, and if it does there is no way + * back: pq_getbyte_if_available() has already taken the byte and the + * backend has no means of pushing one in front of the stream, so this + * message can neither be handed to the command loop nor skipped + * safely. 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. + * + * cdb_sequence_nextval_qe() reaches the same conclusion at + * sequence.c:438, one level milder, because it runs where the gang is + * about to be torn down anyway. + */ + 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@ From 9af287039e6f3b04b59e2fe919cbd58feb01c6ec Mon Sep 17 00:00:00 2001 From: Leonid Borchuk Date: Thu, 15 Oct 2026 13:59:40 +0300 Subject: [PATCH 3/5] Export Anser's C API and rework the filter pushdown Anser builds as a MODULE_big with no SHLIB_EXPORTS, so the module is compiled -fvisibility=hidden and every extern in its headers lands in .symtab but not .dynsym. A second extension cannot reach any of it: RTLD_GLOBAL does not help, because a hidden symbol never reaches the dynamic symbol table, and load_external_function() fails for the same reason. Mark the public declarations PGDLLEXPORT, which is the convention the tree already uses for a cross-module C API -- see contrib/hstore and src/pl/plpython. Two plan-resolution helpers are promoted into that API rather than left for a caller to reimplement. Both encode which plan shapes are safe to inject into, so a second copy would be a second place to get that wrong. The pushdown itself is split so the decision can be tested on its own. A scan key is numbered by the table's attributes while the consumer is numbered by the scan's output targetlist, and the two coincide only when the scan emits every column in order -- so the filter was handed a position where an attribute number was wanted, and a projecting scan tested the wrong column and discarded every row. The decision now maps one to the other, and refuses the shapes that have no mapping: a computed column, a whole-row or system Var, a Var of another relation. A second function walks from the consumer to the deepest node that accepts the key, so stacked consumers over one relation all push into the same scan instead of probing tuple by tuple. Co-Authored-By: Claude Opus 5 --- gpcontrib/anser/anser_test--1.0.sql | 32 +++ .../anser/expected/anser_runtime_filter.out | 86 +++++++ gpcontrib/anser/expected/anser_test.out | 66 ++++++ gpcontrib/anser/include/anser.h | 10 +- gpcontrib/anser/include/anserbloom.h | 24 +- gpcontrib/anser/include/anserfilter.h | 16 +- gpcontrib/anser/include/anserpayload.h | 4 +- gpcontrib/anser/include/anserplan.h | 106 +++++++-- gpcontrib/anser/include/ansersideband.h | 24 +- gpcontrib/anser/sql/anser_runtime_filter.sql | 59 +++++ gpcontrib/anser/sql/anser_test.sql | 43 ++++ gpcontrib/anser/src/anser_test.c | 200 +++++++++++++++++ gpcontrib/anser/src/anserinit.c | 29 ++- gpcontrib/anser/src/anserplan.c | 89 +++++--- gpcontrib/anser/src/anserplanexec.c | 212 ++++++++++++++++-- 15 files changed, 895 insertions(+), 105 deletions(-) diff --git a/gpcontrib/anser/anser_test--1.0.sql b/gpcontrib/anser/anser_test--1.0.sql index 3c2f8d14cfd..0f1c02560ca 100644 --- a/gpcontrib/anser/anser_test--1.0.sql +++ b/gpcontrib/anser/anser_test--1.0.sql @@ -97,3 +97,35 @@ CREATE FUNCTION anser_test_push_crc( 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; diff --git a/gpcontrib/anser/expected/anser_runtime_filter.out b/gpcontrib/anser/expected/anser_runtime_filter.out index cbc5fbceee8..9293817d4c9 100644 --- a/gpcontrib/anser/expected/anser_runtime_filter.out +++ b/gpcontrib/anser/expected/anser_runtime_filter.out @@ -144,6 +144,92 @@ SELECT count(*) AS only_off_push (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) DISTRIBUTED BY (id); +CREATE TABLE anser_rf_proj_probe (payload int, id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_proj_build SELECT 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. diff --git a/gpcontrib/anser/expected/anser_test.out b/gpcontrib/anser/expected/anser_test.out index 9e663672a02..13e1413eecc 100644 --- a/gpcontrib/anser/expected/anser_test.out +++ b/gpcontrib/anser/expected/anser_test.out @@ -283,4 +283,70 @@ SELECT 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) + DROP EXTENSION anser_test; diff --git a/gpcontrib/anser/include/anser.h b/gpcontrib/anser/include/anser.h index 5bfa5d772e8..d7967e59070 100644 --- a/gpcontrib/anser/include/anser.h +++ b/gpcontrib/anser/include/anser.h @@ -60,11 +60,11 @@ typedef struct AnserChannelKey } AnserChannelKey; /* GUCs (defined in anserinit.c). */ -extern bool gp_anser_enable; -extern bool gp_anser_runtime_filter; -extern bool gp_anser_debug; -extern int gp_anser_max_info_size; -extern int gp_anser_timeout_ms; +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. diff --git a/gpcontrib/anser/include/anserbloom.h b/gpcontrib/anser/include/anserbloom.h index 24c7eaba7f6..946b35db5f8 100644 --- a/gpcontrib/anser/include/anserbloom.h +++ b/gpcontrib/anser/include/anserbloom.h @@ -47,33 +47,33 @@ typedef struct AnserBloomFilterConsumeState AnserBloomFilterConsumeState; * libpq connection (parallel-retrieve-cursor model); NULL means connect * without it and rely on pg_hba. Ignored on the coordinator-local path. */ -extern AnserBloomFilterProduceState *ExecInitAnserBloomFilterProduce( +extern PGDLLEXPORT AnserBloomFilterProduceState *ExecInitAnserBloomFilterProduce( const AnserChannelKey *channel_key, int64 total_elems, Size max_payload_bytes, uint32 part_index, uint32 total_parts); -extern void ExecAnserBloomFilterProduceAddDatum(AnserBloomFilterProduceState *state, +extern PGDLLEXPORT void ExecAnserBloomFilterProduceAddDatum(AnserBloomFilterProduceState *state, Datum value, bool isnull); -extern bool ExecAnserBloomFilterProduceHasFilter(AnserBloomFilterProduceState *state); -extern bool ExecAnserBloomFilterProducePublish(AnserBloomFilterProduceState *state); -extern bool ExecAnserBloomFilterProduceCancel(AnserBloomFilterProduceState *state); -extern void ExecEndAnserBloomFilterProduce(AnserBloomFilterProduceState *state); +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 AnserBloomFilterConsumeState *ExecInitAnserBloomFilterConsume( +extern PGDLLEXPORT AnserBloomFilterConsumeState *ExecInitAnserBloomFilterConsume( const AnserChannelKey *channel_key, int64 total_elems, Size max_payload_bytes, uint32 expected_parts); -extern bool ExecAnserBloomFilterConsume(AnserBloomFilterConsumeState *state, +extern PGDLLEXPORT bool ExecAnserBloomFilterConsume(AnserBloomFilterConsumeState *state, long registration_timeout_ms); -extern bloom_filter *ExecAnserBloomFilterConsumerGetFilter( +extern PGDLLEXPORT bloom_filter *ExecAnserBloomFilterConsumerGetFilter( AnserBloomFilterConsumeState *state); -extern uint32 ExecAnserBloomFilterConsumerReceivedParts( +extern PGDLLEXPORT uint32 ExecAnserBloomFilterConsumerReceivedParts( AnserBloomFilterConsumeState *state); -extern bool ExecAnserBloomFilterConsumerWasCancelled( +extern PGDLLEXPORT bool ExecAnserBloomFilterConsumerWasCancelled( AnserBloomFilterConsumeState *state); -extern void ExecEndAnserBloomFilterConsume(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 index 7e50e25c042..dd802a4b6f6 100644 --- a/gpcontrib/anser/include/anserfilter.h +++ b/gpcontrib/anser/include/anserfilter.h @@ -94,34 +94,34 @@ typedef struct AnserBloomPartHeader * anything useful. Works on the wire form, so the coordinator can ask this of * a merged payload without rebuilding a filter. */ -extern bool AnserBloomPartWorthSending(const void *payload, Size payload_len); +extern PGDLLEXPORT bool AnserBloomPartWorthSending(const void *payload, Size payload_len); -extern uint64 AnserBloomSeed(const char *condition_key); +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 bloom_filter *AnserBloomCreate(int64 total_elems, +extern PGDLLEXPORT bloom_filter *AnserBloomCreate(int64 total_elems, Size max_payload_bytes, uint64 seed); -extern Size AnserBloomSerializedSize(const bloom_filter *filter); -extern bool AnserBloomSerializePart(const bloom_filter *filter, +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 bloom_filter *AnserBloomDeserializePart(const void *payload, +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 bool AnserBloomLooksLikePart(const void *payload, Size payload_len); -extern bool AnserBloomFoldPartInPlace(void *acc, Size acc_len, +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 index cb5f30dffcf..18ab6bab108 100644 --- a/gpcontrib/anser/include/anserpayload.h +++ b/gpcontrib/anser/include/anserpayload.h @@ -137,7 +137,7 @@ typedef struct AnserPayloadOps * 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 const AnserPayloadOps *AnserPayloadLookup(char code); +extern PGDLLEXPORT const AnserPayloadOps *AnserPayloadLookup(char code); /* * Whether a type's body is covered by the message checksum. Takes a code @@ -145,6 +145,6 @@ extern const AnserPayloadOps *AnserPayloadLookup(char code); * before it has resolved anything; an unregistered code answers false, which * is why this one needs no error handling. */ -extern bool AnserPayloadChecksumsBody(char code); +extern PGDLLEXPORT bool AnserPayloadChecksumsBody(char code); #endif /* ANSERPAYLOAD_H */ diff --git a/gpcontrib/anser/include/anserplan.h b/gpcontrib/anser/include/anserplan.h index 4e59453210d..26c8169424d 100644 --- a/gpcontrib/anser/include/anserplan.h +++ b/gpcontrib/anser/include/anserplan.h @@ -29,6 +29,7 @@ #ifndef ANSERPLAN_H #define ANSERPLAN_H +#include "nodes/execnodes.h" /* PlanState, for the pushdown helpers */ #include "nodes/plannodes.h" /* @@ -38,14 +39,14 @@ * both the Postgres planner and ORCA. A no-op unless the Anser runtime-filter * GUCs are on and this is a coordinator SELECT. */ -extern void AnserApplyRuntimeFilters(PlannedStmt *stmt); +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 void AnserRegisterRuntimeFilterMethods(void); +extern PGDLLEXPORT void AnserRegisterRuntimeFilterMethods(void); /* * Node builders (implemented in anserplanexec.c, where the CustomScan method @@ -61,15 +62,17 @@ extern void AnserRegisterRuntimeFilterMethods(void); * 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 bool AnserIsRuntimeFilterScan(const Plan *plan); +extern PGDLLEXPORT bool AnserIsRuntimeFilterScan(const Plan *plan); -extern 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); +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 @@ -78,14 +81,16 @@ extern CustomScan *AnserBuildBloomProducerScan(Plan *child, AttrNumber key_attno * cannot be ruled out; the cost is that such a consumer cannot push its filter * into the scan and probes it itself instead. */ -extern 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); +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 @@ -95,7 +100,68 @@ extern CustomScan *AnserBuildBloomConsumerScan(Plan *child, AttrNumber key_attno * 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 bool AnserRuntimeFilterSize(double est_rows, int64 *total_elems, - int64 *max_payload, int64 *planned_bytes); +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 index 2cbea9336cd..995c16670ad 100644 --- a/gpcontrib/anser/include/ansersideband.h +++ b/gpcontrib/anser/include/ansersideband.h @@ -165,12 +165,12 @@ typedef struct AnserWireMsg uint32 crc; /* of the header, key and decoded body */ } AnserWireMsg; -extern char *AnserWireFormat(const AnserChannelKey *channel_key, char kind, +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 bool AnserWireParse(const char *msg, AnserWireMsg *out); -extern bool AnserWireCheckCrc(const AnserWireMsg *msg, const void *body, +extern PGDLLEXPORT bool AnserWireParse(const char *msg, AnserWireMsg *out); +extern PGDLLEXPORT bool AnserWireCheckCrc(const AnserWireMsg *msg, const void *body, Size body_len); /* @@ -185,7 +185,7 @@ extern bool AnserWireCheckCrc(const AnserWireMsg *msg, const void *body, * this one function so the two cannot drift apart. It is implemented in * ansersideband.c, beside the reader that verifies it. */ -extern pg_crc32c AnserWirePushCrc(char payload_type, uint32 condition_id, +extern PGDLLEXPORT pg_crc32c AnserWirePushCrc(char payload_type, uint32 condition_id, uint32 flags, const char *key, int keylen, const void *body, int bodylen); @@ -204,12 +204,12 @@ extern pg_crc32c AnserWirePushCrc(char payload_type, uint32 condition_id, * is not of 'payload_type', which would mean two different kinds of information * had collided on one channel. */ -extern bool AnserSidebandPublish(const AnserChannelKey *channel_key, +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 bool AnserSidebandConsumeWait(const AnserChannelKey *channel_key, +extern PGDLLEXPORT bool AnserSidebandConsumeWait(const AnserChannelKey *channel_key, char payload_type, void **payload, Size *payload_len, bool *cancelled, long timeout_ms); @@ -225,14 +225,14 @@ extern bool AnserSidebandConsumeWait(const AnserChannelKey *channel_key, * same reasons, so that a coordinator-local producer and a segment producer are * interchangeable on one channel. */ -extern bool AnserDispatchNotifyHandler(struct CdbDispatchResult *dispatchResult, +extern PGDLLEXPORT bool AnserDispatchNotifyHandler(struct CdbDispatchResult *dispatchResult, struct pgNotify *notify); -extern bool AnserDispatchLocalPublish(const AnserChannelKey *channel_key, +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 bool AnserDispatchLocalConsume(const AnserChannelKey *channel_key, +extern PGDLLEXPORT bool AnserDispatchLocalConsume(const AnserChannelKey *channel_key, char payload_type, void **payload, Size *payload_len, bool *cancelled); @@ -242,8 +242,8 @@ extern bool AnserDispatchLocalConsume(const AnserChannelKey *channel_key, * the executor/transaction-end callbacks call; the halves are exposed because * each lives with the state it owns. */ -extern void AnserDispatchReset(void); -extern void AnserSidebandResetInbox(void); -extern void AnserSidebandResetAll(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_runtime_filter.sql b/gpcontrib/anser/sql/anser_runtime_filter.sql index 06e12ac2f2f..ec46b6e0596 100644 --- a/gpcontrib/anser/sql/anser_runtime_filter.sql +++ b/gpcontrib/anser/sql/anser_runtime_filter.sql @@ -85,6 +85,65 @@ 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) DISTRIBUTED BY (id); +CREATE TABLE anser_rf_proj_probe (payload int, id int) DISTRIBUTED BY (id); +INSERT INTO anser_rf_proj_build SELECT 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 diff --git a/gpcontrib/anser/sql/anser_test.sql b/gpcontrib/anser/sql/anser_test.sql index 0c6e3585c89..dfdd08fb8c8 100644 --- a/gpcontrib/anser/sql/anser_test.sql +++ b/gpcontrib/anser/sql/anser_test.sql @@ -181,4 +181,47 @@ SELECT 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; + DROP EXTENSION anser_test; diff --git a/gpcontrib/anser/src/anser_test.c b/gpcontrib/anser/src/anser_test.c index 456d1a4a004..4ef17765d89 100644 --- a/gpcontrib/anser/src/anser_test.c +++ b/gpcontrib/anser/src/anser_test.c @@ -28,6 +28,7 @@ #include "postgres.h" #include "access/htup_details.h" +#include "catalog/pg_type.h" #include "anser.h" #include "anserbloom.h" #include "anserfilter.h" @@ -39,7 +40,9 @@ #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" @@ -763,3 +766,200 @@ anser_test_push_crc(PG_FUNCTION_ARGS) 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))); +} diff --git a/gpcontrib/anser/src/anserinit.c b/gpcontrib/anser/src/anserinit.c index 10b0a28498f..1ab12e3db09 100644 --- a/gpcontrib/anser/src/anserinit.c +++ b/gpcontrib/anser/src/anserinit.c @@ -66,7 +66,10 @@ static PlannedStmt *anser_planner(Query *parse, const char *query_string, static planner_hook_type prev_planner_hook = NULL; static ExecutorEnd_hook_type prev_ExecutorEnd_hook = NULL; +static cdbdisp_notify_hook_type prev_cdbdisp_notify_hook = NULL; +static bool anser_notify_hook(struct CdbDispatchResult *dispatchResult, + struct pgNotify *notify); static void anser_executor_end(QueryDesc *queryDesc); static void anser_xact_callback(XactEvent event, void *arg); @@ -100,7 +103,8 @@ _PG_init(void) * Installed unconditionally: it is inert until a segment sends one, and a * QE that never dispatches never calls it. */ - cdbdisp_notify_hook = AnserDispatchNotifyHandler; + prev_cdbdisp_notify_hook = cdbdisp_notify_hook; + cdbdisp_notify_hook = anser_notify_hook; /* * The producer and consumer nodes travel to the segments inside dispatched @@ -198,6 +202,29 @@ anser_planner(Query *parse, const char *query_string, int cursorOptions, 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; +} + /* * Drop the transport's per-query state. * diff --git a/gpcontrib/anser/src/anserplan.c b/gpcontrib/anser/src/anserplan.c index 7fa5aae5189..75c08a48fcd 100644 --- a/gpcontrib/anser/src/anserplan.c +++ b/gpcontrib/anser/src/anserplan.c @@ -55,7 +55,7 @@ /* Per-statement state for the injection pass. */ typedef struct AnserInjectCtx { - PlannedStmt *stmt; /* for slices[]: see anser_slice_producers */ + PlannedStmt *stmt; /* for slices[]: see AnserSliceProducers */ int slice_index; /* slice the subtree being walked runs in */ uint32 next_condition_id; int next_plan_node_id; @@ -65,13 +65,8 @@ typedef struct AnserInjectCtx } AnserInjectCtx; static int anser_max_plan_node_id(Plan *plan); -static int anser_slice_producers(AnserInjectCtx *ctx, int slice_index); static bool anser_hashjoin_keys(HashJoin *hj, AttrNumber *inner_attno, AttrNumber *outer_attno); -static bool anser_resolve_key_scan(Plan *top, AttrNumber key_attno, - int slice_index, Plan **parent_out, - Plan **scan_out, AttrNumber *attno_out, - int *slice_out); static void anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx); static void anser_inject_walk(Plan *plan, AnserInjectCtx *ctx); @@ -127,13 +122,23 @@ AnserApplyRuntimeFilters(PlannedStmt *stmt) } /* - * Largest plan_node_id in a plan subtree. Recurses the spine plus CustomScan - * children; sufficient for the supported (simple) plan shape. + * 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; @@ -141,12 +146,38 @@ anser_max_plan_node_id(Plan *plan) 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))); - if (IsA(plan, CustomScan)) - { - ListCell *lc; - foreach(lc, ((CustomScan *) plan)->custom_plans) - m = Max(m, anser_max_plan_node_id((Plan *) lfirst(lc))); + 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; @@ -317,10 +348,10 @@ anser_hashjoin_keys(HashJoin *hj, AttrNumber *inner_attno, AttrNumber *outer_att * 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. */ -static bool -anser_resolve_key_scan(Plan *top, AttrNumber key_attno, int slice_index, - Plan **parent_out, Plan **scan_out, - AttrNumber *attno_out, int *slice_out) +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; @@ -453,9 +484,9 @@ anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) if (!anser_hashjoin_keys(hj, &inner_attno, &outer_attno)) return; - if (!anser_resolve_key_scan(hash, inner_attno, ctx->slice_index, - &build_parent, &build_scan, &build_attno, - &build_slice)) + if (!AnserResolveKeyScan(hash, inner_attno, ctx->slice_index, + &build_parent, &build_scan, &build_attno, + &build_slice)) return; /* @@ -477,9 +508,9 @@ anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) probe_scan = probe; probe_attno = outer_attno; } - else if (!anser_resolve_key_scan(probe, outer_attno, ctx->slice_index, - &probe_parent, &probe_scan, &probe_attno, - &probe_slice)) + else if (!AnserResolveKeyScan(probe, outer_attno, ctx->slice_index, + &probe_parent, &probe_scan, &probe_attno, + &probe_slice)) return; /* @@ -495,7 +526,7 @@ anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) * is too low makes the coordinator complete the channel early and ship a * filter missing keys, which drops joinable rows. */ - n_producers = anser_slice_producers(ctx, build_slice); + n_producers = AnserSliceProducers(ctx->stmt, build_slice); if (n_producers <= 0) return; /* @@ -612,17 +643,17 @@ anser_inject_walk(Plan *plan, AnserInjectCtx *ctx) * Motion 16:16 over a Parallel Seq Scan, so 16). Reading it from the slice * means neither case needs special handling. */ -static int -anser_slice_producers(AnserInjectCtx *ctx, int slice_index) +int +AnserSliceProducers(PlannedStmt *stmt, int slice_index) { PlanSlice *slice; int factor; - if (ctx->stmt->slices == NULL || - slice_index < 0 || slice_index >= ctx->stmt->numSlices) + if (stmt == NULL || stmt->slices == NULL || + slice_index < 0 || slice_index >= stmt->numSlices) return 0; /* unknown: caller declines to inject */ - slice = &ctx->stmt->slices[slice_index]; + slice = &stmt->slices[slice_index]; /* The coordinator's own slice is one process and has no gang. */ if (slice->gangType == GANGTYPE_UNALLOCATED || diff --git a/gpcontrib/anser/src/anserplanexec.c b/gpcontrib/anser/src/anserplanexec.c index 3680b9215c8..a6c256c6960 100644 --- a/gpcontrib/anser/src/anserplanexec.c +++ b/gpcontrib/anser/src/anserplanexec.c @@ -258,9 +258,13 @@ anser_build_rf_scan(const CustomScanMethods *methods, Plan *child, * 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, and only sound on the - * coordinator -- a QE resolves these pointers by name when it deserializes the - * plan, which is long after the only pass that asks. + * + * 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) @@ -639,35 +643,205 @@ anser_consume_receive(AnserBloomConsumeScanState *st) } /* - * Hand the received filter to the child SeqScan 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. + * 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); - SeqScanState *sss; + PlanState *target; + AttrNumber sk_attno; ScanKey sk; MemoryContext oldcxt; - if (!gp_enable_runtime_filter_pushdown || !IsA(child, SeqScanState)) + if (!gp_enable_runtime_filter_pushdown) return; - sss = (SeqScanState *) child; - if (!sss->filter_in_seqscan) + + 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 = st->key_attno; + sk->sk_attno = sk_attno; sk->sk_flags = SK_BLOOM_FILTER; sk->sk_argument = PointerGetDatum(st->filter); - sss->filters = lappend(sss->filters, sk); + ((SeqScanState *) target)->filters = + lappend(((SeqScanState *) target)->filters, sk); MemoryContextSwitchTo(oldcxt); st->pushed_down = true; @@ -704,6 +878,12 @@ anser_consume_next(CustomScanState *node) 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. */ From 3e572416f2a47e4bf49e369298915d116f8e267a Mon Sep 17 00:00:00 2001 From: Leonid Borchuk Date: Thu, 15 Oct 2026 14:00:05 +0300 Subject: [PATCH 4/5] Fix four ways Anser could drop rows or hang a session A filter that is a subset of the build side's keys rejects rows that join, so every one of these is a wrong answer rather than a slow one. A build side under a nested loop yields different keys per outer tuple, but neither node rebuilds on rescan: the producer stays published and the consumer keeps the filter it has, so the second iteration is probed against the first iteration's keys. Refuse such a join at injection time, by intersecting the loop's nestParams with the build subtree's extParam -- the same test UpdateChangedParamSet makes at runtime. Cleanup was scoped by counting ExecutorEnd, which only ever measures recursion into ExecutorEnd itself. A statement run through SPI from a function body goes through its whole Start/Run/End inside the outer query's Run, so its End looked outermost and took the running query's channels with it. Count over execution instead, as auto_explain does. Two readers share the QD->QE connection, and the nextval one died on a sideband message: it took the type byte, then errored without the body, leaving the stream desynchronised and the session unusable. It now skips one whole and waits on, and Anser no longer subscribes when it has no deadline to wait out. The coordinator could also leave a QE's notification unread for the whole lifetime of the slice that sent it, because PQisBusy() reports the command rather than the queued message, and the bytes may already have been absorbed by a flush. Drain before deciding to wait. Co-Authored-By: Claude Opus 5 --- .../anser/expected/anser_runtime_filter.out | 157 +++++++++++++++++ gpcontrib/anser/sql/anser_runtime_filter.sql | 121 +++++++++++++ gpcontrib/anser/src/anserinit.c | 94 ++++++++-- gpcontrib/anser/src/anserplan.c | 67 +++++++- gpcontrib/anser/src/anserplanexec.c | 16 ++ gpcontrib/anser/src/ansersideband.c | 43 ++++- src/backend/cdb/dispatcher/cdbdisp_async.c | 68 ++++++++ src/backend/commands/sequence.c | 47 ++++- src/test/modules/Makefile | 1 + .../modules/libpq_isbusy_notify/.gitignore | 5 + src/test/modules/libpq_isbusy_notify/Makefile | 27 +++ .../libpq_isbusy_notify/libpq_isbusy_notify.c | 162 ++++++++++++++++++ .../modules/libpq_isbusy_notify/meson.build | 47 +++++ .../t/001_isbusy_notify.pl | 33 ++++ src/test/modules/meson.build | 1 + 15 files changed, 856 insertions(+), 33 deletions(-) create mode 100644 src/test/modules/libpq_isbusy_notify/.gitignore create mode 100644 src/test/modules/libpq_isbusy_notify/Makefile create mode 100644 src/test/modules/libpq_isbusy_notify/libpq_isbusy_notify.c create mode 100644 src/test/modules/libpq_isbusy_notify/meson.build create mode 100644 src/test/modules/libpq_isbusy_notify/t/001_isbusy_notify.pl diff --git a/gpcontrib/anser/expected/anser_runtime_filter.out b/gpcontrib/anser/expected/anser_runtime_filter.out index 9293817d4c9..11603d91ddf 100644 --- a/gpcontrib/anser/expected/anser_runtime_filter.out +++ b/gpcontrib/anser/expected/anser_runtime_filter.out @@ -259,6 +259,31 @@ SELECT 'the filter pruned nothing: it never arrived, or it arrived empty' AS pro --------- (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; @@ -339,6 +364,138 @@ SELECT count(*) AS only_off (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 (id); +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; -- --------------------------------------------------------------------------- -- Parallel execution. -- diff --git a/gpcontrib/anser/sql/anser_runtime_filter.sql b/gpcontrib/anser/sql/anser_runtime_filter.sql index ec46b6e0596..736823fbd57 100644 --- a/gpcontrib/anser/sql/anser_runtime_filter.sql +++ b/gpcontrib/anser/sql/anser_runtime_filter.sql @@ -170,6 +170,27 @@ 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; @@ -230,6 +251,106 @@ SELECT count(*) AS only_off 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 (id); +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; + -- --------------------------------------------------------------------------- -- Parallel execution. -- diff --git a/gpcontrib/anser/src/anserinit.c b/gpcontrib/anser/src/anserinit.c index 1ab12e3db09..da6cb30e96d 100644 --- a/gpcontrib/anser/src/anserinit.c +++ b/gpcontrib/anser/src/anserinit.c @@ -26,6 +26,7 @@ * 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 @@ -65,11 +66,33 @@ static PlannedStmt *anser_planner(Query *parse, const char *query_string, 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); @@ -94,6 +117,10 @@ _PG_init(void) * 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); @@ -225,38 +252,77 @@ anser_notify_hook(struct CdbDispatchResult *dispatchResult, return false; } -/* - * Drop the transport's per-query state. - * - * Nested executor runs (a function body, say) must not clear state the outer - * query is still using, so only the outermost end resets. - */ +/* Count a query as executing for as long as it is running. */ static void -anser_executor_end(QueryDesc *queryDesc) +anser_executor_run(QueryDesc *queryDesc, ScanDirection direction, + uint64 count, bool execute_once) { - static int nesting_level = 0; + 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(); +} - nesting_level++; +/* The same, for the phase that runs AFTER triggers. */ +static void +anser_executor_finish(QueryDesc *queryDesc) +{ + anser_exec_nesting++; PG_TRY(); { - if (prev_ExecutorEnd_hook) - prev_ExecutorEnd_hook(queryDesc); + if (prev_ExecutorFinish_hook) + prev_ExecutorFinish_hook(queryDesc); else - standard_ExecutorEnd(queryDesc); + standard_ExecutorFinish(queryDesc); } PG_FINALLY(); { - nesting_level--; + anser_exec_nesting--; } PG_END_TRY(); +} - if (nesting_level == 0) +/* + * 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/anserplan.c b/gpcontrib/anser/src/anserplan.c index 75c08a48fcd..3391db24461 100644 --- a/gpcontrib/anser/src/anserplan.c +++ b/gpcontrib/anser/src/anserplan.c @@ -57,6 +57,9 @@ 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; @@ -87,7 +90,14 @@ AnserApplyRuntimeFilters(PlannedStmt *stmt) return; { - AnserInjectCtx ctx; + /* + * 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; @@ -116,6 +126,7 @@ AnserApplyRuntimeFilters(PlannedStmt *stmt) 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); } @@ -482,6 +493,34 @@ anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) 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, @@ -619,7 +658,31 @@ anser_inject_walk(Plan *plan, AnserInjectCtx *ctx) anser_try_inject((HashJoin *) plan, ctx); anser_inject_walk(outerPlan(plan), ctx); - anser_inject_walk(innerPlan(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; diff --git a/gpcontrib/anser/src/anserplanexec.c b/gpcontrib/anser/src/anserplanexec.c index a6c256c6960..8b8f93a4a8e 100644 --- a/gpcontrib/anser/src/anserplanexec.c +++ b/gpcontrib/anser/src/anserplanexec.c @@ -511,6 +511,21 @@ anser_produce_end(CustomScanState *node) 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) { @@ -934,6 +949,7 @@ anser_consume_end(CustomScanState *node) st->filter = NULL; } +/* Keeps the received filter; see anser_produce_rescan for why that is sound. */ static void anser_consume_rescan(CustomScanState *node) { diff --git a/gpcontrib/anser/src/ansersideband.c b/gpcontrib/anser/src/ansersideband.c index babc7bbaba8..c0e01700c43 100644 --- a/gpcontrib/anser/src/ansersideband.c +++ b/gpcontrib/anser/src/ansersideband.c @@ -173,6 +173,26 @@ AnserSidebandConsumeWait(const AnserChannelKey *channel_key, char payload_type, 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. @@ -277,11 +297,18 @@ anser_sideband_read_one(long timeout_ms) if (qtype != GP_SIDEBAND_MESSAGE) { /* - * Nothing else should reach us here, and if it does there is no way - * back: pq_getbyte_if_available() has already taken the byte and the - * backend has no means of pushing one in front of the stream, so this - * message can neither be handed to the command loop nor skipped - * safely. The connection is one byte short from here on. + * 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 -- @@ -292,9 +319,9 @@ anser_sideband_read_one(long timeout_ms) * connection cannot be reused: an ERROR would return this QE to the * gang pool with a desynchronised stream, poisoning a later query. * - * cdb_sequence_nextval_qe() reaches the same conclusion at - * sequence.c:438, one level milder, because it runs where the gang is - * about to be torn down anyway. + * 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, diff --git a/src/backend/cdb/dispatcher/cdbdisp_async.c b/src/backend/cdb/dispatcher/cdbdisp_async.c index 536ef3c15ae..62417d2e643 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 @@ -564,6 +565,34 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) 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. */ @@ -1039,6 +1068,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) { @@ -1117,6 +1171,20 @@ processNotifies(CdbDispatchResult *dispatchResult) 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 */); } 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/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') From 6d24b29203abaaf0545aa22c623bb3a2cedc2ae2 Mon Sep 17 00:00:00 2001 From: Leonid Borchuk Date: Fri, 16 Oct 2026 17:21:04 +0300 Subject: [PATCH 5/5] Fix stalls and inject only across a Motion A blocking push stranded the dispatcher uncancellably when a consumer had timed out: deliver without blocking, and let a consumer withdraw. AnserSliceProducers counted a singleton reader and direct dispatch as the whole cluster, so the channel never completed. A producer whose slice ran but whose node never did stayed silent, leaving consumers to wait out the deadline. A consumer ignored QueryFinishPending, so a query the coordinator had already finished -- a LIMIT satisfied early -- still sat out the full deadline. CHECK_FOR_INTERRUPTS does not cover that flag. Injection is now refused when the build and probe sides share a slice. Slices are divided by Motions only, so a co-located join offers no synchronisation point to hang the exchange on and no network cost to save against the filter's own. probe_slice already carried the answer but was never read, and was left uninitialized on one path. Tests move to redistributed joins to match. The parallel section had also been running on the 1 ms deadline left over from the preceding block, which let it pass without a filter ever arriving; it now restores the deadline, and keeps its coverage under GPORCA only, since the Postgres planner marks Motion paths parallel-unsafe. configure is regenerated for --with-anser. Co-Authored-By: Claude Opus 5 --- configure | 8 +- gpcontrib/anser/README.md | 21 +++ gpcontrib/anser/anser_test--1.0.sql | 22 ++++ .../anser/expected/anser_runtime_filter.out | 122 ++++++++++++++++-- gpcontrib/anser/expected/anser_test.out | 62 +++++++++ gpcontrib/anser/include/ansersideband.h | 2 + gpcontrib/anser/sql/anser_runtime_filter.sql | 89 +++++++++++-- gpcontrib/anser/sql/anser_test.sql | 39 ++++++ gpcontrib/anser/src/anser_test.c | 93 +++++++++++++ gpcontrib/anser/src/anserdispatch.c | 81 +++++++++++- gpcontrib/anser/src/anserplan.c | 63 ++++++++- gpcontrib/anser/src/anserplanexec.c | 36 +++++- gpcontrib/anser/src/ansersideband.c | 34 +++++ src/backend/cdb/dispatcher/cdbdisp_async.c | 23 +++- 14 files changed, 660 insertions(+), 35 deletions(-) diff --git a/configure b/configure index 63274a27faa..1d5664668e5 100755 --- a/configure +++ b/configure @@ -819,8 +819,8 @@ GCOV enable_debug enable_orafce enable_faultinjector -enable_debug_extensions with_anser +enable_debug_extensions enable_pxf enable_gpfdist enable_strong_random @@ -892,8 +892,8 @@ enable_atomics enable_strong_random enable_gpfdist enable_pxf -with_anser enable_debug_extensions +with_anser enable_faultinjector enable_orafce enable_debug @@ -1658,7 +1658,6 @@ Optional Features: Optional Packages: --with-PACKAGE[=ARG] use PACKAGE [ARG=yes] --without-PACKAGE do not use PACKAGE (same as --with-PACKAGE=no) - --without-anser do not build the Anser runtime-filter extension --with-extra-version=STRING append STRING to version --with-template=NAME override operating system template @@ -1666,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] @@ -3634,12 +3634,12 @@ fi - # # Anser adaptive information sharing (runtime filters) # + # Check whether --with-anser was given. if test "${with_anser+set}" = set; then : withval=$with_anser; diff --git a/gpcontrib/anser/README.md b/gpcontrib/anser/README.md index 7d6e9305581..66cb5762508 100644 --- a/gpcontrib/anser/README.md +++ b/gpcontrib/anser/README.md @@ -94,6 +94,27 @@ 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 diff --git a/gpcontrib/anser/anser_test--1.0.sql b/gpcontrib/anser/anser_test--1.0.sql index 0f1c02560ca..c15793866e4 100644 --- a/gpcontrib/anser/anser_test--1.0.sql +++ b/gpcontrib/anser/anser_test--1.0.sql @@ -129,3 +129,25 @@ CREATE FUNCTION anser_test_pushdown_target( 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/expected/anser_runtime_filter.out b/gpcontrib/anser/expected/anser_runtime_filter.out index 11603d91ddf..bcdd6aeea97 100644 --- a/gpcontrib/anser/expected/anser_runtime_filter.out +++ b/gpcontrib/anser/expected/anser_runtime_filter.out @@ -153,9 +153,9 @@ RESET gp_enable_runtime_filter_pushdown; -- 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) DISTRIBUTED BY (id); +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 FROM generate_series(1, 200) g; +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; @@ -376,7 +376,7 @@ DROP TABLE anser_rf_z_on, anser_rf_z_off, anser_rf_build_z, anser_rf_probe_z; -- 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 (id); +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; @@ -496,6 +496,109 @@ SELECT gp_inject_fault('nextval_sideband_before_response', 'reset', dbid) 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. -- @@ -509,12 +612,15 @@ DROP TABLE anser_rf_seq_t; -- which disagree here: for one query the Postgres planner may give a serial -- build slice where GPORCA gives a parallel one. -- --- The join key is the distribution key on both sides, so no Motion is needed --- to co-locate the join. That is the only shape either planner parallelises: --- a redistributed or broadcast build side is fine for GPORCA but the Postgres --- planner marks Motion paths parallel-unsafe. +-- 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 (id); +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; diff --git a/gpcontrib/anser/expected/anser_test.out b/gpcontrib/anser/expected/anser_test.out index 13e1413eecc..54785b1e518 100644 --- a/gpcontrib/anser/expected/anser_test.out +++ b/gpcontrib/anser/expected/anser_test.out @@ -349,4 +349,66 @@ FROM (VALUES 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/ansersideband.h b/gpcontrib/anser/include/ansersideband.h index 995c16670ad..7c2f0775eb2 100644 --- a/gpcontrib/anser/include/ansersideband.h +++ b/gpcontrib/anser/include/ansersideband.h @@ -76,6 +76,7 @@ struct pgNotify; /* #include "libpq-fe.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 @@ -242,6 +243,7 @@ extern PGDLLEXPORT bool AnserDispatchLocalConsume(const AnserChannelKey *channel * 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); diff --git a/gpcontrib/anser/sql/anser_runtime_filter.sql b/gpcontrib/anser/sql/anser_runtime_filter.sql index 736823fbd57..9fd2f5d2bc9 100644 --- a/gpcontrib/anser/sql/anser_runtime_filter.sql +++ b/gpcontrib/anser/sql/anser_runtime_filter.sql @@ -94,9 +94,9 @@ RESET gp_enable_runtime_filter_pushdown; -- 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) DISTRIBUTED BY (id); +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 FROM generate_series(1, 200) g; +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; @@ -263,7 +263,7 @@ DROP TABLE anser_rf_z_on, anser_rf_z_off, anser_rf_build_z, anser_rf_probe_z; -- 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 (id); +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; @@ -351,6 +351,76 @@ SELECT gp_inject_fault('nextval_sideband_before_response', 'reset', dbid) 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. -- @@ -364,12 +434,15 @@ DROP TABLE anser_rf_seq_t; -- which disagree here: for one query the Postgres planner may give a serial -- build slice where GPORCA gives a parallel one. -- --- The join key is the distribution key on both sides, so no Motion is needed --- to co-locate the join. That is the only shape either planner parallelises: --- a redistributed or broadcast build side is fine for GPORCA but the Postgres --- planner marks Motion paths parallel-unsafe. +-- 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 (id); +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; diff --git a/gpcontrib/anser/sql/anser_test.sql b/gpcontrib/anser/sql/anser_test.sql index dfdd08fb8c8..be937ae01cc 100644 --- a/gpcontrib/anser/sql/anser_test.sql +++ b/gpcontrib/anser/sql/anser_test.sql @@ -224,4 +224,43 @@ FROM (VALUES 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 index 4ef17765d89..afd4886cc6f 100644 --- a/gpcontrib/anser/src/anser_test.c +++ b/gpcontrib/anser/src/anser_test.c @@ -963,3 +963,96 @@ anser_test_pushdown_target(PG_FUNCTION_ARGS) 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/anserdispatch.c b/gpcontrib/anser/src/anserdispatch.c index a48e3a0d1f7..06330332b82 100644 --- a/gpcontrib/anser/src/anserdispatch.c +++ b/gpcontrib/anser/src/anserdispatch.c @@ -66,6 +66,7 @@ #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" @@ -90,6 +91,15 @@ typedef struct AnserDispChannel } 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); @@ -120,6 +130,15 @@ anser_conn_error(const PGconn *conn) 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 @@ -274,6 +293,27 @@ AnserDispatchNotifyHandler(struct CdbDispatchResult *dispatchResult, 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; @@ -445,6 +485,15 @@ 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) @@ -497,9 +546,31 @@ anser_disp_push(PGconn *conn, AnserDispChannel *chan) 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) || - pqPutMsgEnd(conn) < 0 || - pqFlush(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)); @@ -555,7 +626,9 @@ AnserWireParse(const char *msg, AnserWireMsg *out) &keylen, &bodylen, &crc) != 11) return false; - if (kind != ANSER_WIRE_KIND_PART && kind != ANSER_WIRE_KIND_SUBSCRIBE) + 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; diff --git a/gpcontrib/anser/src/anserplan.c b/gpcontrib/anser/src/anserplan.c index 3391db24461..8a00fe2b3d1 100644 --- a/gpcontrib/anser/src/anserplan.c +++ b/gpcontrib/anser/src/anserplan.c @@ -546,6 +546,7 @@ anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) 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, @@ -559,6 +560,30 @@ anser_try_inject(HashJoin *hj, AnserInjectCtx *ctx) 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 @@ -698,9 +723,17 @@ anser_inject_walk(Plan *plan, AnserInjectCtx *ctx) * 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): a slice runs - * numsegments * parallel_workers processes, with parallel_workers == 0 meaning - * one. Both optimizers are covered, and they do differ: for the same query the + * 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 @@ -724,6 +757,30 @@ AnserSliceProducers(PlannedStmt *stmt, int slice_index) 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; diff --git a/gpcontrib/anser/src/anserplanexec.c b/gpcontrib/anser/src/anserplanexec.c index 8b8f93a4a8e..1baec034829 100644 --- a/gpcontrib/anser/src/anserplanexec.c +++ b/gpcontrib/anser/src/anserplanexec.c @@ -87,6 +87,8 @@ typedef struct AnserBloomProduceScanState 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; @@ -402,6 +404,16 @@ anser_produce_begin(CustomScanState *node, EState *estate, int eflags) 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); @@ -460,6 +472,7 @@ anser_produce_next(CustomScanState *node) { ANSER_DEBUG("anser: producer child exhausted, publishing (state=%s)", st->produce != NULL ? "ok" : "NULL"); + (void) ExecAnserBloomFilterProducePublish(st->produce); st->published = true; } @@ -491,14 +504,23 @@ anser_produce_end(CustomScanState *node) AnserBloomProduceScanState *st = (AnserBloomProduceScanState *) node; /* - * A producer that ran but never published was abandoned mid-scan (squelch, - * or an error upstream), and consumers must be told so rather than left - * waiting for a part that will never come. A producer that never ran at - * all belongs to another slice: cancelling here would destroy a channel - * this process has no part in -- which is exactly what the coordinator - * used to do to every runtime filter. + * 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->started && !st->published && st->produce != NULL) + if (!st->published && st->produce != NULL && (st->started || st->my_slice)) { (void) ExecAnserBloomFilterProduceCancel(st->produce); st->published = true; diff --git a/gpcontrib/anser/src/ansersideband.c b/gpcontrib/anser/src/ansersideband.c index c0e01700c43..d3b077661fb 100644 --- a/gpcontrib/anser/src/ansersideband.c +++ b/gpcontrib/anser/src/ansersideband.c @@ -217,6 +217,21 @@ AnserSidebandConsumeWait(const AnserChannelKey *channel_key, char payload_type, { 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; @@ -224,6 +239,25 @@ AnserSidebandConsumeWait(const AnserChannelKey *channel_key, char payload_type, 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. diff --git a/src/backend/cdb/dispatcher/cdbdisp_async.c b/src/backend/cdb/dispatcher/cdbdisp_async.c index 62417d2e643..19924b92714 100644 --- a/src/backend/cdb/dispatcher/cdbdisp_async.c +++ b/src/backend/cdb/dispatcher/cdbdisp_async.c @@ -557,10 +557,20 @@ 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)); } @@ -601,6 +611,17 @@ checkDispatchResult(CdbDispatcherState *ds, int timeout_sec) 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