19. Motion & the Interconnect
19.1 Distributed Execution Principles
Every plan in the last six chapters had one of these in it — Gather Motion 3:1, Redistribute Motion 3:3, Broadcast Motion 1:3, Explicit Redistribute Motion — and every time we read it as an annotation: a note in the margin saying and then the data moves. It is not an annotation. A Motion is a plan node. It has an ExecInitMotion, an ExecMotion and an ExecEndMotion, it sits in the tree exactly where a Sort or a Hash Join would sit, and it is the only reason any of the rest of this works.
That design choice is the whole architecture. §18.1 described the executor as a demand-pull volcano: a parent calls ExecProcNode on its child and gets one tuple, or NULL for end-of-stream. Cloudberry does not extend that protocol with a send this somewhere else primitive. It inserts a node whose child happens to live in a different process. A Motion's parent calls ExecProcNode and gets a tuple, and cannot tell that the tuple arrived over a socket. A Motion's child is called by a parent that will ship the result sideways instead of returning it upward, and cannot tell either. Every other node in the tree stays completely oblivious, which is why Chapters 13–18 could be written at all.
Three consequences follow immediately, and they are the vocabulary for the rest of the chapter. First, the plan is cut at Motion boundaries into slices — §16.3 built the cut at planning time, §19.3 has the data structures. Second, each slice is run by a gang: one process per segment, all of them running the same slice of the same plan on different data (§19.2). Third — and this is the single most important idea here — once the QD has dispatched, nothing schedules anything. Each process runs its own volcano loop, blocking when its input is empty and blocking when its output buffer is full. There is no global coordinator during execution.
The chapter's running example: one aggregate, two motions, three slices
CREATE TABLE m191_sales (id int, region int, amt numeric) DISTRIBUTED BY (id);
INSERT INTO m191_sales SELECT g, g % 97, (g % 1000)::numeric FROM generate_series(1,4000000) g;
ANALYZE m191_sales;
EXPLAIN (costs off) SELECT region, count(*) FROM m191_sales GROUP BY region;
CREATE TABLE
INSERT 0 4000000
ANALYZE
QUERY PLAN
------------------------------------------------------------
Gather Motion 3:1 (slice1; segments: 3)
-> Finalize HashAggregate
Group Key: region
-> Redistribute Motion 3:3 (slice2; segments: 3)
Hash Key: region
-> Streaming Partial HashAggregate
Group Key: region
-> Seq Scan on m191_sales
Optimizer: GPORCA
(9 rows)
region is not the distribution key, so the groups are scattered across all three segments and a two-stage aggregate is the only way to finish them (§16.4 planned this shape, §18.3 executed it). Two motions cut the plan into three slices: the scan plus the partial aggregate; the finalize aggregate; and the root slice that the QD runs. The slice number printed on a Motion node is always the number of the sender slice below it, so the root is the unnumbered slice0 — §19.3 owns that numbering. Here is the same plan drawn against the processes that actually run it.
One query, its three slices, and the seven processes that run them. Dashed lines are slice boundaries; solid arrows are tuples crossing a Motion. Process IDs are from the live capture below.
Nine arrows between slice2 and slice1 is not artistic licence: a Redistribute Motion 3:3 really is all-to-all, every sender potentially talking to every receiver. That is the fact §19.5 has to build a transport for. To prove the process picture, run the same query long enough to photograph it — we inflate each row 120× with a generate_series join so the statement lives for about forty seconds, which changes nothing about the slice structure — and take a census from a second session.
Seven processes, one distributed statement. The QD row comes from the coordinator's own pg_stat_activity, the six segment rows from gp_dist_random.
-- session A, held open ~40 s (same three slices, 120x more input rows):
-- SELECT region, count(*) FROM m191_sales, generate_series(1,120) g GROUP BY region;
-- session B, while A runs:
SELECT 'seg'||gp_execution_segment() AS host, sess_id, pid, state, left(query,26) AS query
FROM gp_dist_random('pg_stat_activity') WHERE sess_id = 188
UNION ALL
SELECT 'QD ', sess_id, pid, state, left(query,26) FROM pg_stat_activity WHERE sess_id = 188
ORDER BY 1, 3;
host | sess_id | pid | state | query
------+---------+------+--------+----------------------------
QD | 188 | 9968 | active | select region, count(*) fr
seg0 | 188 | 9971 | active | select region, count(*) fr
seg0 | 188 | 9977 | active | select region, count(*) fr
seg1 | 188 | 9972 | active | select region, count(*) fr
seg1 | 188 | 9978 | active | select region, count(*) fr
seg2 | 188 | 9973 | active | select region, count(*) fr
seg2 | 188 | 9979 | active | select region, count(*) fr
(7 rows)
Seven processes, one sess_id, one statement: the QD plus one gang of three per non-root slice. The operating system agrees, and it labels them for us — a Cloudberry QE writes its slice number into its process title, so ps reads out the slice tree directly. Watch the C column (the kernel's CPU-usage estimate, fourth field of ps -ef).
ps sees the slice tree. The three slice2 processes are pegged; the three slice1 processes are burning nothing at all.
$ ps -ef | grep -E "con188 (seg|cmd)" | grep -v grep | sed 's/ */ /g' | sed 's/^gpadmin //'
9968 19803 1 01:54 ? 00:00:00 postgres: 7100, gpadmin postgres [local] con188 cmd5 SELECT
9971 19768 0 01:54 ? 00:00:00 postgres: 7102, gpadmin postgres 10.100.0.3(57978) con188 seg0 cmd6 slice1 MPPEXEC SELECT
9972 19772 0 01:54 ? 00:00:00 postgres: 7103, gpadmin postgres 10.100.0.3(41800) con188 seg1 cmd6 slice1 MPPEXEC SELECT
9973 19773 0 01:54 ? 00:00:00 postgres: 7104, gpadmin postgres 10.100.0.3(38304) con188 seg2 cmd6 slice1 MPPEXEC SELECT
9977 19768 93 01:54 ? 00:00:07 postgres: 7102, gpadmin postgres 10.100.0.3(57984) con188 seg0 cmd6 slice2 MPPEXEC SELECT
9978 19772 92 01:54 ? 00:00:07 postgres: 7103, gpadmin postgres 10.100.0.3(41806) con188 seg1 cmd6 slice2 MPPEXEC SELECT
9979 19773 91 01:54 ? 00:00:07 postgres: 7104, gpadmin postgres 10.100.0.3(38310) con188 seg2 cmd6 slice2 MPPEXEC SELECT
This capture is the no-global-coordinator claim. Nobody put the three slice1 processes to sleep and nobody will wake them: each one is sitting inside its Redistribute Motion receiver, blocked because its input is empty, and it will resume the instant a tuple lands. Nobody is throttling the three slice2 processes either; they scan until an output buffer fills. The QD is not a scheduler in this picture — it is simply the process that happens to run the root slice, plus the dispatcher that got everyone started (§13.3, §19.2). Treat the numbers as one snapshot on one machine, not a measurement: this build is --enable-cassert.
19.1.1 One plan node, two roles
A distributed plan is serialized once on the QD and shipped verbatim to every QE (§13.3 for the dispatch, §19.2 for what physically goes on the wire). So every process holds the same tree, including the Motion nodes belonging to slices it will never touch. What differs is where each process enters the tree. A QE looks up the Motion node that is the top of its slice and makes that node its plan root.
if (estate->eliminateAliens)
{
Motion *m = findSenderMotion(plannedstmt, LocallyExecutingSliceIndex(estate));
if (NULL != m)
{
start_plan_node = (Plan *) m;
ExecSlice *sendSlice = &estate->es_sliceTable->slices[m->motionID];
estate->currentSliceId = sendSlice->parentIndex;
findSenderMotion searches the whole plan for the Motion whose motionID equals this process's slice index — Motion.motionID is the sender slice's index (nodeMotion.c — ExecInitMotion()) — and everything outside that subtree is an alien, never initialized (the execute_pruned_plan developer GUC, on by default here, gates this; execMain.c — standard_ExecutorStart()). A receiver Motion does not even initialize its child: nodeMotion.c (ExecInitMotion()) skips the recursive ExecInitNode when the node is not a sender. Then, inside the one Motion node they share, sender and receiver are told apart by a single comparison.
if (LocallyExecutingSliceIndex(estate) == recvSlice->sliceIndex)
{
/* this is recv */
motionstate->mstype = MOTIONSTATE_RECV;
}
else if (LocallyExecutingSliceIndex(estate) == sendSlice->sliceIndex)
{
/* this is send */
motionstate->mstype = MOTIONSTATE_SEND;
}
From that one field everything else follows. ExecMotion branches on it before it does anything at all (nodeMotion.c); the hash-key expressions and the CdbHash object are built only if mstype == MOTIONSTATE_SEND (nodeMotion.c — ExecInitMotion()), so a receiver never even owns a hash function; the merge comparator for a sorted gather is built only for MOTIONSTATE_RECV (nodeMotion.c — ExecInitMotion()). The complementary decision on the QD side is §18.1's getGpExecIdentity (execUtils.c), which returns GP_ROOT_SLICE when this process runs the top of the tree, GP_NON_ROOT_ON_QE when it runs a slice capped by a sender Motion — the call returns no tuples at all, they all leave over the interconnect — and GP_IGNORE when the process has nothing to run locally.
- slice — A maximal piece of the plan tree containing no Motion boundary. The unit of parallelism, and the unit of dispatch. Cut by the planner (§16.3, §17.5), numbered in EXPLAIN as slice0 (root) upward (§19.3).
- gang — The set of processes that runs one slice — normally one QE per segment, sometimes exactly one QE anywhere. Allocation, reuse and death are §19.2.
- sender / receiver slice — For each Motion: the slice below it sends, the slice containing it receives.
Motion.motionIDnames the sender slice;PlanSlice.parentIndexnames the receiver. - route — A sender's local index for one destination process, 0..N-1, plus the pseudo-route
BROADCAST_SEGIDX= -2 meaning everyone (src/include/cdb/tupchunk.h). Choosing the route is what distinguishes the motion types.
19.1.2 Three shapes of data
§16.1 gave the planner's side of this as a locus algebra. On the execution side there are only three physical shapes a rowset can have, and a Motion exists to convert between them.
- partitioned — N processes, N disjoint row sets whose union is the answer. A
DISTRIBUTED BYorDISTRIBUTED RANDOMLYtable, and the output of any Redistribute. Most rowsets in most plans. - replicated — N processes, N identical row sets. A
DISTRIBUTED REPLICATEDtable on disk — and, transiently, whatever a Broadcast Motion has just delivered. Cheap to join against, expensive to produce. - singleton — Exactly one process holds everything: the QD, a one-QE reader gang on a segment, or an entry-db reader on the coordinator. The client only ever sees a singleton — which is why nearly every plan ends in a Gather.
A replicated table needs no motion to join, and needs only one sender to gather
-- m191_dim: 97 rows, DISTRIBUTED BY (region)
CREATE TABLE m191_rep (region int, rname text) DISTRIBUTED REPLICATED;
INSERT INTO m191_rep SELECT * FROM m191_dim;
EXPLAIN (costs off) SELECT s.id, r.rname FROM m191_sales s JOIN m191_rep r
ON s.region = r.region WHERE s.id < 5;
EXPLAIN (costs off) SELECT count(*) FROM m191_rep;
CREATE TABLE
INSERT 0 97
QUERY PLAN
------------------------------------------
Gather Motion 3:1 (slice1; segments: 3)
-> Hash Join
Hash Cond: (s.region = r.region)
-> Seq Scan on m191_sales s
Filter: (id < 5)
-> Hash
-> Seq Scan on m191_rep r
Optimizer: GPORCA
(8 rows)
QUERY PLAN
------------------------------------------------
Finalize Aggregate
-> Gather Motion 1:1 (slice1; segments: 1)
-> Partial Aggregate
-> Seq Scan on m191_rep
Optimizer: GPORCA
(5 rows)
Both plans are the shape algebra made visible. The join needs no motion whatsoever on the inner side — every segment already has all 97 dimension rows, so the hash table is built locally three times over. The aggregate is the opposite direction: counting a replicated table from three segments would count everything three times, so the planner gives the slice a one-segment gang and gathers 1:1. A replicated rowset converts to a singleton by ignoring two thirds of the cluster.
19.1.3 What each motion type converts
There are seven MotionType values (src/include/nodes/plannodes.h), four of which you meet routinely. What separates them is a single question asked once per tuple in doSendTuple: which route does this row go out on, and who worked that out?
| EXPLAIN node | MotionType | input shape → output shape | who computes the destination |
|---|---|---|---|
| Gather Motion N:1 | MOTIONTYPE_GATHER | partitioned → singleton | Nobody. There is only one route, so the sender hard-wires targetRoute = 0 (nodeMotion.c — doSendTuple). |
| Explicit Gather Motion N:1 | MOTIONTYPE_GATHER_SINGLE | replicated → singleton | Nobody. The subplan runs on N segments but only the planner-chosen one sends. |
| Redistribute Motion N:M | MOTIONTYPE_HASH | partitioned on key A → partitioned on key B | The sender, per tuple: a cdbhash over the Hash Key expressions (evalHashKey, nodeMotion.c — doSendTuple). §19.4 has the hash itself. |
| Broadcast Motion N:M | MOTIONTYPE_BROADCAST | partitioned or singleton → replicated | Nobody. The sender writes the pseudo-route BROADCAST_SEGIDX (-2) and the motion layer fans the tuple out to every receiver. |
| Broadcast Workers Motion | MOTIONTYPE_BROADCAST_WORKERS | partitioned → replicated once per segment, into one worker of each | The sender, at random inside each segment's worker set (nodeMotion.c — doSendTuple) — an intra-segment-parallel variant, see §18.2. |
| Explicit Redistribute Motion N:M | MOTIONTYPE_EXPLICIT | partitioned → partitioned by a segment number carried in the row | Nobody. The destination is already a column of the tuple. |
| (never executed) | MOTIONTYPE_OUTER_QUERY | placeholder inside a correlated subplan | Resolved into a Gather or a Broadcast before the plan is finished. |
A Broadcast, proved: one row per segment goes in, two rows per segment come out
EXPLAIN (analyze, timing off, costs off, summary off)
SELECT count(*) FROM m191_sales s JOIN m191_dim d ON s.region = d.region
WHERE d.rname IN ('r5','r7');
QUERY PLAN
-------------------------------------------------------------------------------------------------------
Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice1; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Hash Join (actual rows=27610 loops=1)
Hash Cond: (s.region = d.region)
Extra Text: (seg1) Hash chain length 1.0 avg, 1 max, using 2 of 524288 buckets.
-> Seq Scan on m191_sales s (actual rows=1334931 loops=1)
-> Hash (actual rows=2 loops=1)
Buckets: 524288 Batches: 1 Memory Usage: 4097kB
-> Broadcast Motion 3:3 (slice2; segments: 3) (actual rows=2 loops=1)
-> Seq Scan on m191_sales d (actual rows=1 loops=1)
Filter: (rname = ANY ('{r5,r7}'::text[]))
Rows Removed by Filter: 63
Under §18.1's reporting conventions those actual rows are per-segment figures, and they say exactly what the shape table claims: below the motion the busiest segment produced 1 matching dimension row (two rows exist in the whole cluster), above it every receiver holds 2. Partitioned went in, replicated came out, and the total row count on the wire is senders × rows, which is precisely why §16.2 charges a broadcast so much more than a redistribute. A Redistribute, by contrast, preserves the row count and only changes whose rows they are — and needs one slice per input, so a two-sided redistribute join costs one more gang.
Four slices, four gangs: 1 + 3 × 3 = 10 processes for one SELECT
-- m191_ship: 2 000 000 rows, DISTRIBUTED BY (sid)
EXPLAIN (costs off) SELECT count(*) FROM m191_sales s JOIN m191_ship p
ON s.region = p.region AND s.amt = p.qty;
-- then, from a second session while that query actually runs (sess_id 47):
SELECT count(*) AS processes FROM (
SELECT 1 FROM gp_dist_random('pg_stat_activity') WHERE sess_id = 47
UNION ALL
SELECT 1 FROM pg_stat_activity WHERE sess_id = 47) z;
QUERY PLAN
---------------------------------------------------------------------------------------
Finalize Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Hash Join
Hash Cond: ((s.region = p.region) AND (s.amt = (p.qty)::numeric))
-> Redistribute Motion 3:3 (slice2; segments: 3)
Hash Key: s.region, s.amt
-> Seq Scan on m191_sales s
-> Hash
-> Redistribute Motion 3:3 (slice3; segments: 3)
Hash Key: p.region, (p.qty)::numeric
-> Seq Scan on m191_ship p
Optimizer: GPORCA
(13 rows)
processes
-----------
10
The last of the four is the strange one, and it is the reason DML on a distributed table works at all. When an UPDATE changes the distribution key, the row must move to a different segment — but the old version must be deleted where it already lives. §18.7 introduced the Split Update node that turns one update into a delete tuple and an insert tuple; the delete half already knows its home and must not be re-hashed. So the planner puts the segment number into the row itself.
Explicit Redistribute: gp_segment_id travels as a column of the tuple
SET optimizer = off; -- ORCA renders this shape as Split Update + Redistribute
EXPLAIN (costs off, verbose) UPDATE m191_sales SET id = id + 1 WHERE region = 5;
SET
QUERY PLAN
-------------------------------------------------------------------------------
Update on public.m191_sales
-> Explicit Redistribute Motion 3:3 (slice1; segments: 3)
Output: ((id + 1)), region, amt, ctid, gp_segment_id, (DMLAction)
-> Split Update
Output: ((id + 1)), region, amt, ctid, gp_segment_id, DMLAction
-> Seq Scan on public.m191_sales
Output: (id + 1), region, amt, ctid, gp_segment_id
Filter: (m191_sales.region = 5)
Optimizer: Postgres query optimizer
Settings: optimizer = 'off'
else if (motion->motionType == MOTIONTYPE_EXPLICIT)
{
Datum segidColIdxDatum;
Assert(motion->segidColIdx > 0 && motion->segidColIdx <= list_length((motion->plan).targetlist));
bool is_null = false;
segidColIdxDatum = slot_getattr(outerTupleSlot, motion->segidColIdx, &is_null);
targetRoute = Int32GetDatum(segidColIdxDatum);
Assert(!is_null);
}
No hash function, no distribution policy, no key — one slot_getattr at the attribute number the planner recorded in Motion.segidColIdx (plannodes.h), and that integer is the route. This is the only motion type whose routing is data rather than computation, and the reason it can be: the row was read from a specific segment, so it can be told where it came from and sent back there. ctid beside it in the target list is the other half of the trick — §18.7 has the DML side, §19.4 the sending side.
19.1.4 Reading the N:M label
The two numbers are senders : receivers, and they are gang sizes, not row counts. EXPLAIN computes them from the slice table at the moment it prints the node: the sender count is the size of the slice the Motion caps, the receiver count the size of its parent slice.
motion_snd = list_length(es->currentSlice->segments);
motion_recv = parentSlice == NULL ? 1 : list_length(parentSlice->segments);
/* ... the motionType switch forces motion_recv = 1 for both
* gather flavours, and picks sname ... */
pname = psprintf("%s %d:%d", sname, motion_snd, motion_recv);
So Gather Motion 3:1 is three sending gang members and, by definition, one receiver; Redistribute Motion 3:3 is three of each and the all-to-all mesh of the opening figure; Gather Motion 1:1 was the replicated table's one-segment gang. The case that surprises people is 1:3, which needs a sender slice with exactly one process in it — for example a catalog scan, which can only run on the coordinator.
Broadcast Motion 1:3 — a single coordinator-side sender fanning out to the whole cluster
EXPLAIN (costs off) SELECT count(*) FROM m191_sales s, pg_class c WHERE s.region = c.relnatts;
QUERY PLAN
--------------------------------------------------------
Finalize Aggregate
-> Gather Motion 3:1 (slice1; segments: 3)
-> Partial Aggregate
-> Hash Join
Hash Cond: (c.relnatts = s.region)
-> Broadcast Motion 1:3 (slice2)
-> Seq Scan on pg_class c
-> Hash
-> Seq Scan on m191_sales s
Optimizer: Postgres query optimizer
(10 rows)
Note what is missing: every other motion in this section printed (sliceN; segments: N), and this one prints only (slice2). show_dispatch_info reports zero segments for a GANGTYPE_UNALLOCATED or GANGTYPE_ENTRYDB_READER slice (explain.c, printed at show_dispatch_info()), and an entry-db reader is exactly what a coordinator-side catalog scan gets: one QE, on the coordinator, alongside the QD. Its data is a singleton, and the Broadcast converts singleton → replicated so the segments can join against it locally. The other way the arity moves is intra-segment parallelism, which multiplies the process count without changing the segment count.
With intra-segment parallelism the sender count is segments × workers — including the asymmetric 6:3
SET enable_parallel = on;
EXPLAIN (costs off) SELECT count(*) FROM m191_sales;
EXPLAIN (costs off) SELECT region, count(*) FROM m191_sales GROUP BY region;
SET
QUERY PLAN
---------------------------------------------------
Finalize Aggregate
-> Gather Motion 6:1 (slice1; segments: 6)
-> Partial Aggregate
-> Parallel Seq Scan on m191_sales
Optimizer: GPORCA
(5 rows)
QUERY PLAN
------------------------------------------------------------
Gather Motion 3:1 (slice1; segments: 3)
-> Finalize HashAggregate
Group Key: region
-> Redistribute Motion 6:3 (slice2; segments: 6)
Hash Key: region
-> Streaming Partial HashAggregate
Group Key: region
-> Parallel Seq Scan on m191_sales
Optimizer: GPORCA
(9 rows)
Three segments, two workers each, so the sending gang has six members and the label says 6. Nothing about the label changed — it is still list_length(sendSlice->segments), and §19.3 explains why that list has six entries when there are three segments (numsegments × parallel_workers). The second plan is the one to remember: 6:3 is a perfectly legal motion in which six senders redistribute into three receivers, so the arity of a Motion is not a property of the cluster, it is a property of the two slices it joins. §18.2 owns why the planner chose six.
19.1.5 What the model costs
None of this is free, and it is worth being blunt about the bill. A motion is the only way to satisfy a join or a grouping whose keys do not line up with the distribution, which is why the planner has to reason about distribution at all (§16.1) and why it has a dedicated cost function for shipping tuples (§16.2's cdbpath_cost_motion). Every motion boundary also multiplies processes: the ten-process census above is one SELECT count(*), and each of those processes carries its own work memory, its own snapshot and its own share of the resource budget (Chapter 21).
The subtler cost is structural, and it is the thread that runs through the rest of this chapter. A sender and a receiver are separate operating-system processes with finite buffers. A sender blocks when its outbound queue is full; a receiver blocks when nothing has arrived. Arrange four such processes in a cycle — two motions feeding the two sides of one join — and the executor can deadlock itself with no lock, no lock table and nothing for the deadlock detector of §11.5 to find. This handbook has already shown both mechanisms that prevent it: §18.1's ExecSquelchNode (src/backend/executor/execAmi.c), which tells an abandoned sender to stop rather than fill its queue forever, and §18.4's prefetch_inner (plannodes.h — Join), which forces a join to drain its inner motion before it touches the outer one. What neither section could show is the machinery underneath: how a receiver's stop actually reaches a sender, and what happens on the wire while it is in flight.
That is the rest of the chapter. §19.2 takes the QD's side — how gangs are allocated, reused and recycled, and what dispatch physically sends. §19.3 opens the slice table that this section kept pointing, field by field. §19.4 opens nodeMotion.c: the sender loop, the receiver loop, chunking, and the stop protocol that answers the deadlock question. §19.5 goes below SendTupleChunkToAMS into the interconnect itself — which, it turns out, is not part of the backend at all. §19.6 closes Part III with the one feature that lets a client skip the Gather entirely and read from the segments directly.
(Demo objects m191_sales, m191_dim, m191_rep and m191_ship were dropped after the captures above.)
19.2 The Dispatcher: Gangs & Async Dispatch
§13.3 summarised dispatch in one sentence: the QD serialises the plan and ships it to gangs. That sentence hides three engineering problems. Where do the executor processes come from, given that forking a backend costs milliseconds and a statement may need nine of them? How does one QD talk to nine processes at once without its own setup latency becoming the sum of nine round trips? And when one of the nine dies, how does the failure become a single error message at the client instead of a hang?
The answers are a gang (a group of QEs, one per segment, allocated as a unit), a per-session cache of idle QEs that survives statement boundaries, and an asynchronous libpq dispatcher built on PQsendQuery plus poll(). All three live in src/backend/cdb/dispatcher/ — cdbgang.c (1246 lines), cdbgang_async.c (427), cdbdisp_query.c (1864), cdbdisp_async.c (1226), cdbconn.c (873) — with the idle-QE pool in cdbutil.c. There is even a design note in the tree: src/backend/cdb/dispatcher/README.md.
One client connection, eleven backends. Two statements were run first (a redistributed self-join, then a join against a coordinator-only catalog); gp_backend_info then reports every QE this session is holding.
select count(*) from m192_t a join m192_t b on a.b = b.b;
select count(*) from m192_t t, gp_segment_configuration c where t.a = c.dbid;
select * from gp_backend_info() order by id;
count
-----------
514285716
(1 row)
count
-------
4
(1 row)
id | type | content | host | port | pid
----+------+---------+------------+------+-------
-1 | Q | -1 | cbdb-node1 | 7100 | 22404
0 | w | 0 | cbdb-node1 | 7102 | 22408
1 | w | 1 | cbdb-node1 | 7103 | 22409
2 | w | 2 | cbdb-node1 | 7104 | 22410
3 | r | 0 | cbdb-node1 | 7102 | 22414
4 | r | 1 | cbdb-node1 | 7103 | 22415
5 | r | 2 | cbdb-node1 | 7104 | 22416
6 | r | 0 | cbdb-node1 | 7102 | 22420
7 | r | 1 | cbdb-node1 | 7103 | 22421
8 | r | 2 | cbdb-node1 | 7104 | 22422
9 | R | -1 | cbdb-node1 | 7100 | 23012
(11 rows)
One session (gp_session_id 69 in the capture above): the QD, its writer gang, two reader gangs, and one entry-DB reader. Every pid is real. Solid amber = libpq, the QD's private control channel to each QE; dashed grey = the interconnect, which §19.5 owns.
Two setups, two networks. Gang setup and interconnect setup are different things and it is worth nailing the boundary now. Gang setup is QD → QE over libpq, to each segment's ordinary postmaster port (7102/7103/7104 here); it is what this section is about. Interconnect setup is QE ↔ QE over separate, ephemeral listeners; §19.5 owns it. The two meet at exactly one point: when the QD finishes a libpq connection it immediately asks the new QE for its motion listener port (cdbconn.c — cdbconn_doConnectComplete()) and refuses the gang if there isn't one. That port is then shipped to every peer inside the slice table.
19.2.1 One writer, many readers
A gang is deliberately thin — a type, a size, and an array of connection descriptors (cdbgang.h — Gang). All the interesting policy is in the type, five values that have not changed in fifteen years:
typedef enum GangType
{
GANGTYPE_UNALLOCATED, /* a root slice executed by the qDisp */
GANGTYPE_ENTRYDB_READER, /* a 1-gang with read access to the entry db */
GANGTYPE_SINGLETON_READER, /* a 1-gang to read the segment dbs */
GANGTYPE_PRIMARY_READER, /* a 1-gang or N-gang to read the segment dbs */
GANGTYPE_PRIMARY_WRITER /* the N-gang that can update the segment dbs */
} GangType;
- GANGTYPE_UNALLOCATED — Not a gang at all: the slice runs in the QD itself. Slice 0 of every plan is this.
InventorySliceTreestill fills in a process list for it, fromgetCdbProcessesForQD(cdbgang.c—setupCdbProcessList()), because the QD is a motion endpoint even though nobody dispatched to it. - GANGTYPE_ENTRYDB_READER — A one-process gang on the coordinator, used when a plan must scan a coordinator-only relation. That is row
id=9, type=R, content=-1, port=7100in the capture above: the coordinator connected to its own postmaster and forked a backend for itself. - GANGTYPE_SINGLETON_READER — A one-process gang on one segment — what you get for a
numsegments=1slice, e.g. after direct dispatch (§16.4) or aGatherinto a single segment. - GANGTYPE_PRIMARY_READER — The workhorse: N processes, one per segment in the slice's segment list. Read-only with respect to the distributed transaction.
- GANGTYPE_PRIMARY_WRITER — Also N processes, but this gang owns the transaction on each segment. A session has at most one, and reader gangs cannot exist without it.
That last asymmetry is the load-bearing one, and it exists because a transaction has to live somewhere. Each segment is a full PostgreSQL instance with its own XID space, its own lock table and its own snapshot machinery; nine peer processes on three segments cannot each independently open a transaction and still be one atomic unit. So on every segment exactly one QE — the writer — begins the local transaction, holds the locks on behalf of the whole session (see the lockHolderProcPtr discussion at execUtils.c — AssignWriterGangFirst()), and publishes its local snapshot into shared memory. Readers attach to it. The branch is taken once per backend, during InitPostgres:
else if (Gp_role == GP_ROLE_EXECUTE)
{
if (Gp_is_writer)
{
addSharedSnapshot("Writer qExec", gp_session_id);
}
else
{
/*
* NOTE: This assumes that the Slot has already been
* allocated by the writer. ...
*/
lookupSharedSnapshot("Reader qExec", "Writer qExec", gp_session_id);
}
}
Read that comment as a scheduling constraint on the dispatcher, because that is what it is. The writer must be created first, or every reader on that segment fails its SharedSnapshotLookup (sharedsnapshot.c). The QD honours it explicitly: AssignGangs calls AssignWriterGangFirst (execUtils.c) which descends the slice tree looking for the GANGTYPE_PRIMARY_WRITER slice before the ordinary top-down walk in InventorySliceTree (execUtils.c) allocates anything else. The comment at execUtils.c (AssignWriterGangFirst()) gives the case that forced this: a writable CTE, where the updating slice sits below a reading slice, so a naive top-down walk would hand the writer QEs to the wrong slice. The same constraint explains the rest of this section's behaviour — a session's writer gang cannot be recycled away mid-transaction (cdbgang.c — DisconnectAndDestroyAllGangs() spells it out), and killing a writer resets the whole session rather than just failing a statement.
Writer-ness is a property of the QE, not of the gang. A plain SELECT asks for SEGMENTTYPE_ANY and its slices are typed GANGTYPE_PRIMARY_READER — the debug log for the self-join above shows gangtype=3 for all three slices — yet gp_backend_info() still reports three w backends. The reason is cdbutil.c — cdbcomponent_allocateIdleQE(): isWriter = contentId == -1 ? false : (cdbinfo->numIdleQEs == 0 && cdbinfo->numActiveQEs == 0). The first QE ever created on a segment in this session becomes the writer, whatever asked for it. A DML statement is the case that forces the issue: an INSERT logs segment type = 1 (SEGMENTTYPE_EXPLICT_WRITER) and gangtype=4, and with direct dispatch gangsize=1.
19.2.2 Bringing a gang into being
The allocation path is four calls deep and each layer has exactly one job. AssignGangs (execUtils.c) walks the slice tree; AllocateGang (cdbgang.c) maps the slice's GangType to a SegmentType request and records the gang on the dispatcher state so it can be recycled later; buildGangDefinition (cdbgang.c — segment_failure_due_to_fault_injector()) loops over the segment list calling cdbcomponent_allocateIdleQE(contentId, segmentType); and that function either pops a descriptor off the segment's freelist or manufactures a fresh one. Note the division of labour in buildGangDefinition: it only reserves descriptors. Nothing is connected yet. That is what makes the next step batchable.
A QE is not a special kind of process. It is an ordinary backend, forked by the segment's own postmaster in response to an ordinary libpq connection — cdbconn.c (cdbconn_doConnectStart()) calls PQconnectStartParams. What makes it a QE is two extra connection parameters that CBDB added to libpq itself (fe-connect.c — PQconninfoOptions and): gpqeid and options/diff_options. gpqeid is a semicolon-delimited string built without any palloc or elog, because historically it ran on a thread:
len = snprintf(buf, bufsz, "%d;" TIMESTAMP_FORMAT ";%s;%d;%d;%d;%d",
gp_session_id, PgStartTime,
(is_writer ? "true" : "false"), identifier, hostSegs, icHtabSize,
qeidx);
postmaster.c (ProcessStartupPacket()) picks gpqeid out of the startup packet and cdbgang_parse_gpqeid_params (cdbgang.c — gpqeid_next_param()) applies it with PGC_S_OVERRIDE before authentication — which is how gp_is_writer is already set by the time postinit.c — InitPostgres() needs it. The options string is a different animal: makeOptions (cdbgang.c) emits -c name=value for every GUC flagged GUC_GPDB_NEED_SYNC, at its reset value, plus the QD's own hostname and port; any GUC the session has since changed goes into a second diff_options list which the QE applies at PGC_S_SESSION (postinit.c — process_startup_options()) so the reset values stay intact. On this cluster that string is 193 -c pairs, 6380 bytes, sent once per connection. Setting gp_log_gang = debug prints the whole thing, along with the interconnect port the QD just learned:
gp_log_gang=debug on a fresh connection. The 6380-byte options string is elided; every fragment shown is verbatim and in its real (alphabetical) order.
PGOPTIONS='-c gp_log_gang=debug -c client_min_messages=log' psql -p 7100 -d postgres
select count(*) from m192_s;
LOG: AllocateGang begin.
LOG: createGang size = 3, segment type = 3
LOG: buildGangDefinition:Starting 3 qExec processes for gang
LOG: buildGangDefinition done
LOG: Connected to seg1 10.100.0.3:7103 motionListenerPorts=38140/0 with options
-c gp_qd_hostname=10.100.0.3 -c gp_qd_port=7100
-c allow_dml_directory_table=false -c array_nulls=true
[... 187 more -c name=value pairs ...]
-c gp_interconnect_type=udpifc -c gp_max_packet_size=8192
-c statement_mem=128000 -c work_mem=32768
LOG: createGang: 3 processes requested; 3 successful connections 0 in recovery
LOG: AllocateGang end.
LOG: getCdbProcessList slice1 gangtype=3 gangsize=3
LOG: Gang assignment: slice1 seg0 10.100.0.3:58041 pid=14392
LOG: Gang assignment: slice1 seg1 10.100.0.3:38140 pid=14393
LOG: Gang assignment: slice1 seg2 10.100.0.3:50190 pid=14394
Two details in that log repay attention. motionListenerPorts=38140/0 packs two ports into one word (cdbconn.c — cdbconn_doConnectComplete() masks the low and high halves) — the UDP listener and the TCP one, only one of which is populated depending on gp_interconnect_type. And the Gang assignment lines print 10.100.0.3:58041, not — by that point the QD has stopped caring about postmaster ports and is recording the interconnect endpoints it will hand to the peers. Connections are opened in a batch, exactly as buildGangDefinition set up: cdbgang_async.c fires cdbconn_doConnectStart for all N segments (cdbgang_createGang_async()), then drives them to completion in a single poll() loop (cdbgang_createGang_async()) with a deadline computed from gp_segment_connect_timeout (180 s here; getPollTimeout). The per-QE elapsed time is recorded, and gp_print_create_gang_time prints it:
gp_print_create_gang_time=on: the same three-slice self-join, run twice. Timings are ratios on one --enable-cassert build, not benchmarks.
set gp_print_create_gang_time = on;
select count(*) from m192_s a join m192_s b on a.b=b.b;
select count(*) from m192_s a join m192_s b on a.b=b.b;
INFO: The shortest establish conn time: 6.04 ms, segindex: 0,
The longest establish conn time: 6.57 ms, segindex: 2
INFO: (Slice2) The shortest establish conn time: 5.21 ms, segindex: 0,
The longest establish conn time: 5.81 ms, segindex: 1
INFO: (Slice3) The shortest establish conn time: 5.01 ms, segindex: 0,
The longest establish conn time: 5.77 ms, segindex: 2
INFO: (Slice1) is reused
count
-------
10002
(1 row)
INFO: (Slice2) is reused
INFO: (Slice3) is reused
INFO: (Slice1) is reused
count
-------
10002
(1 row)
Three segments, and the slowest connection in a gang finishes 0.5–0.8 ms after the fastest — the fork-and-authenticate work overlapped. Serially it would have cost the sum. The gang-creation loop also has a retry path most people never see: a PGRES_POLLING_FAILED whose error text matches POSTMASTER_IN_STARTUP_MSG or POSTMASTER_IN_RECOVERY_MSG (segment_failure_due_to_recovery, cdbgang.c — AllocateGang() — it really does grep the message string, and the comment apologises for it) is treated as retryable, and after gp_gang_creation_retry_timer the whole create_gang_retry label runs again. Everything else, including the deadline, becomes ERROR: failed to acquire resources on one or more segments with the reason in errdetail — timeout expired (cdbgang_async.c — cdbgang_createGang_async()), a libpq message, or FTS detected one or more segments are down. A segment-down condition was not induced on this cluster.
19.2.3 Gangs outlive statements
This is the part that is most often got wrong. A gang is not created per statement. cdbdisp_destroyDispatcherState recycles a session's gangs rather than dropping them, and cdbcomponent_recycleIdleQE pushes each descriptor — live libpq connection and all — back onto a per-segment freelist. The next statement pops them straight off. The proof is that the pids do not move:
Nine QEs, two statements, same pids. Then gp_vmem_idle_resource_timeout is lowered to 100 ms and the client goes idle for two seconds. The 600000 ms the comment names is this assert-enabled build's default; a release build starts at 18000.
select count(*) from m192_s a join m192_s b on a.b=b.b;
select count(*) as qes, string_agg(pid::text,' ' order by id) as pids
from gp_backend_info() where content>=0;
select count(*) from m192_s a join m192_s b on a.b=b.b; -- same statement again
select count(*) as qes, string_agg(pid::text,' ' order by id) as pids
from gp_backend_info() where content>=0;
set gp_vmem_idle_resource_timeout = 100; -- ms; default is 600000
\! sleep 2
qes | pids
-----+-------------------------------------------------------
9 | 22031 22032 22033 22037 22038 22039 22043 22044 22045
(1 row)
qes | pids
-----+-------------------------------------------------------
9 | 22031 22032 22033 22037 22038 22039 22043 22044 22045
(1 row)
SET
qes | pids
-----+-------------------
3 | 22031 22032 22033
(1 row)
Nine, then the same nine, then three — and the three survivors are precisely the writers, pids 22031 22032 22033. That is DisconnectAndDestroyUnusedQEs calling cdbcomponent_cleanupIdleQEs(false), whose false means do not touch the writers; §19.2.1 explains why it can't. Whether a recycled QE goes back on the freelist or dies is one decision function, cleanupQE plus the length check that follows it:
if (forceDestroy || !cleanupQE(segdbDesc))
goto destroy_segdb;
/* If freelist length exceed gp_cached_gang_threshold, destroy it */
maxLen = segdbDesc->segindex == -1 ?
MAX_CACHED_1_GANGS : gp_cached_gang_threshold;
if (!isWriter && list_length(cdbinfo->freelist) >= maxLen)
goto destroy_segdb;
| What ends a QE's life | Where | Note |
|---|---|---|
Explicit forceDestroy from the caller | RecycleGang(gp, true), cdbgang.c (gangTypeToString()) | Set for extended-query/named-portal gangs, and for a partly built gang whose PG_CATCH fired |
| Connection already bad, or FTS says the segment is down | cleanupQE, cdbutil.c | A dead connection is never cached |
Reader's peak memory above gp_vmem_protect_segworker_cache_limit (500 MB) | cdbutil.c (cleanupQE()) | Uses libpq's mop_high_watermark; a memory-hungry reader is dropped, not reused |
| QE still busy after a 20-attempt result discard | cdbconn_discardResults, cdbutil.c (cleanupQE()) | Logs cleaning up segN while it is still busy |
Freelist already at gp_cached_segworkers_threshold (5) | cdbutil.c (cdbcomponent_recycleIdleQE()) | Only readers; writers are exempt and always cached |
Session idle longer than gp_vmem_idle_resource_timeout (18000 ms; 600000 on the assert-enabled build these sessions were captured on) | DisconnectAndDestroyUnusedQEs, src/backend/cdb/dispatcher/cdbgang.c | Readers only |
| Writer gang lost | markCurrentGxactWriterGangLost, cdbutil.c (cdbcomponent_recycleIdleQE()) | Forces resetSessionForPrimaryGangLoss and a new gp_session_id |
The freelist cap is easy to see at work. Drop it to 1 and the reader gangs can no longer all be cached, so their pids churn from statement to statement while the writers stay put:
gp_cached_segworkers_threshold=1: only one idle reader per segment may be cached, so the second statement has to fork fresh readers.
set gp_cached_segworkers_threshold = 1; -- real GUC name; default 5
select count(*) from m192_s a join m192_s b on a.b=b.b;
select type, string_agg(pid::text,' ' order by id) as pids
from gp_backend_info() where content>=0 group by type order by type;
select count(*) from m192_s a join m192_s b on a.b=b.b;
select type, string_agg(pid::text,' ' order by id) as pids
from gp_backend_info() where content>=0 group by type order by type;
type | pids
------+-------------------
r | 23454 23455 23456
w | 23440 23441 23442
(2 rows)
type | pids
------+-------------------
r | 23464 23465 23466
w | 23440 23441 23442
(2 rows)
A cached QE is a process with stale GUC state, so reuse imposes an obligation: every SET must be replayed to gangs that already exist. CdbDispatchSetCommand (cdbdisp_query.c) does exactly that, dispatching the text of the SET to all live gangs except extended-query ones — and marking those non-recyclable afterwards (cdbdisp_markNamedPortalGangsDestroyed), because they missed the update:
The same three QEs before and after a SET; the value changed underneath them.
select seg, setting from gp_dist_random('m192_wm') order by seg; -- view over pg_settings
select string_agg(pid::text,' ' order by id) as qe_pids
from gp_backend_info() where content>=0;
set work_mem = '64MB';
select seg, setting from gp_dist_random('m192_wm') order by seg;
select string_agg(pid::text,' ' order by id) as qe_pids
from gp_backend_info() where content>=0;
seg | setting
-----+---------
0 | 32768
1 | 32768
2 | 32768
(3 rows)
qe_pids
-------------------
23475 23476 23477
(1 row)
SET
seg | setting
-----+---------
0 | 65536
1 | 65536
2 | 65536
(3 rows)
qe_pids
-------------------
23475 23476 23477
(1 row)
Put a number on what reuse buys. Establishing one QE cost 5–7 ms above, and it carried 6380 bytes of GUC options; the three-slice self-join needs nine of them. Without the cache, every statement in a session would pay that — and on a real cluster with 48 segments, a nine-slice plan means 432 forks and authentications per statement. This is the deeper half of §13.2's point that a prepared statement reuses more than a plan: on CBDB, a warm session is warm in processes, and the first statement after connecting is measurably the expensive one.
19.2.4 Async dispatch: one QD, N sockets
With gangs in hand, the QD has nine open libpq connections and one serialised plan. It does not walk them in order waiting for each to finish. cdbdisp_dispatchToGang_async (cdbdisp_async.c) loops over the gang and calls dispatchCommand on each member, which is a thin wrapper over PQsendGpQuery_shared(conn, query_text, query_text_len, true) (dispatchCommand()) — the true being non-blocking, so a congested socket does not stall the loop. Only after every slice has been kicked off does the QD start reading. Two loops make that work: cdbdisp_waitDispatchFinish_async polls for POLLOUT and flushes the leftovers, then checkDispatchResult polls for POLLIN:
/*
* Add socket to fd_set if still connected.
*/
sock = PQsocket(conn);
Assert(sock >= 0);
fds[nfds].fd = sock;
fds[nfds].events = POLLIN;
nfds++;
}
/* Break out when no QEs still running or required QEs acked. */
if (nfds <= 0 || ...)
break;
n = poll(fds, nfds, timeout);
Whichever descriptors come back readable are handed to handlePollSuccess → processResults → PQconsumeInput / PQisBusy. Nothing enforces an order. Turning on gp_log_gang = debug shows the whole shape of a dispatch in one trace:
One statement, three gangs, nine QEs. The interleaved 'PQsocket says' and '-> ok SELECT 0' lines have been dropped to keep the trace readable; the ordering of the lines shown is exactly as captured.
PGOPTIONS='-c gp_log_gang=debug -c client_min_messages=log' psql -p 7100 -d postgres
select count(*) from m192_s a join m192_s b on a.b=b.b;
LOG: createGang: 3 processes requested; 3 successful connections 0 in recovery
LOG: getCdbProcessList slice1 gangtype=3 gangsize=3
LOG: createGang: 3 processes requested; 3 successful connections 0 in recovery
LOG: getCdbProcessList slice2 gangtype=3 gangsize=3
LOG: createGang: 3 processes requested; 3 successful connections 0 in recovery
LOG: getCdbProcessList slice3 gangtype=3 gangsize=3
LOG: Query plan size to dispatch: 2KB
LOG: Command dispatched to QE (seg0 slice2 10.100.0.3:7102 pid=23814)
LOG: Command dispatched to QE (seg1 slice2 10.100.0.3:7103 pid=23815)
LOG: Command dispatched to QE (seg2 slice2 10.100.0.3:7104 pid=23816)
LOG: Command dispatched to QE (seg0 slice3 10.100.0.3:7102 pid=23822)
LOG: Command dispatched to QE (seg1 slice3 10.100.0.3:7103 pid=23823)
LOG: Command dispatched to QE (seg2 slice3 10.100.0.3:7104 pid=23824)
LOG: Command dispatched to QE (seg0 slice1 10.100.0.3:7102 pid=23808)
LOG: Command dispatched to QE (seg1 slice1 10.100.0.3:7103 pid=23809)
LOG: Command dispatched to QE (seg2 slice1 10.100.0.3:7104 pid=23810)
LOG: processResults says we are finished with 4 of 9 (seg0 slice3 ... pid=23822)
LOG: processResults says we are finished with 5 of 9 (seg1 slice3 ... pid=23823)
LOG: processResults says we are finished with 6 of 9 (seg2 slice3 ... pid=23824)
LOG: processResults says we are finished with 2 of 9 (seg1 slice2 ... pid=23815)
LOG: processResults says we are finished with 3 of 9 (seg2 slice2 ... pid=23816)
LOG: processResults says we are finished with 1 of 9 (seg0 slice2 ... pid=23814)
LOG: processResults says we are finished with 9 of 9 (seg2 slice1 ... pid=23810)
LOG: processResults says we are finished with 7 of 9 (seg0 slice1 ... pid=23808)
LOG: processResults says we are finished with 8 of 9 (seg1 slice1 ... pid=23809)
Four facts fall out of that trace. All three gangs are allocated before anything is dispatched — AssignGangs runs at cdbdisp_query.c (cdbdisp_dispatchX()), ahead of serialisation. The plan is serialised exactly once: one Query plan size to dispatch line for nine sends, and PQsendGpQuery_shared does not even copy the buffer — it points each PGconn->outBuffer at the shared text (cdbpq.c) and restores the original afterwards. Slices are dispatched leaves-first: slice2, slice3, then the root slice1, because fillSliceVector sorts by number of dependent children (compare_slice_order, cdbdisp_query.c) so producers are running before their consumer starts pulling. And completions arrive out of order — 4,5,6,2,3,1,9,7,8 — which is what asynchrony looks like from the inside. A synchronous dispatcher would have serialised nine PQexec round trips, adding nine setup latencies to a statement whose actual work is embarrassingly parallel; the legacy thread-pool knob (gp_connections_per_thread) that once controlled this no longer exists in the tree. One last detail worth holding on to: every QE's libpq result here is ok SELECT 0, including the root slice's senders. Tuples never travel on these sockets. libpq carries control and completion; the data goes over the interconnect (§19.5).
19.2.5 What actually crosses the wire
“Dispatching the plan” is a single message, type 'M', hand-marshalled by buildGpQueryString (cdbdisp_query.c). Its size computation is the most compact inventory of a dispatch payload in the tree:
total_query_len = 1 /* 'M' */ +
sizeof(len) /* message length */ +
sizeof(gp_command_count) +
sizeof(sessionUserId) + sizeof(outerUserId) + sizeof(currentUserId) +
sizeof(n32) * 2 /* currentStatementStartTimestamp */ +
sizeof(is_hs_dispatch) +
sizeof(command_len) + sizeof(plantree_len) +
sizeof(sddesc_len) + sizeof(dtxContextInfo_len) +
dtxContextInfo_len + command_len + plantree_len + sddesc_len +
sizeof(numsegments) + sizeof(resgroupInfo.len) + resgroupInfo.len +
sizeof(tempNamespaceId) + sizeof(tempToastNamespaceId);
Three variable-length blobs carry the weight. plantree is the serialised PlannedStmt, produced by serializeNode (cdbdisp_query.c — cdbdisp_buildPlanQueryParms()) and compressed — the function returns both the wire length and the uncompressed length, and it is the uncompressed one that gp_max_plan_size polices. sddesc is the serialised QueryDispatchDesc (execdesc.h), the per-dispatch sidecar the QD cannot put in the plan: the SliceTable itself (§19.3), paramInfo with both PARAM_EXTERN and already-evaluated PARAM_EXEC values, oidAssignments so a CREATE picks the same OIDs on every segment, cursorPositions, parallelCursorName for §19.6, secContext, and the matview fields. dtxContextInfo is the distributed transaction context and snapshot from qdSerializeDtxContextInfo (§12.2, §10.5) — the reason all nine QEs see one consistent database state. The rest is small but load-bearing: three user OIDs so SECURITY DEFINER and SET ROLE behave, the statement start timestamp so now() agrees everywhere, the temp-namespace OIDs, and a resource-group blob (Chapter 21). The command field is the SQL text, deliberately truncated to QUERY_STRING_TRUNCATE_SIZE when a plan is present — it is only there for pg_stat_activity on the segment.
| Query | Dispatched slices | Uncompressed plan size |
|---|---|---|
select count(*) from m192_s | 1 | 0 KB |
| 2-table self-join on a non-distribution key | 3 | 2 KB |
| 3-table self-join | 4 | 3 KB |
| 4-table self-join | 5 | 4 KB |
| 6-table self-join | 7 | 6 KB |
Roughly a kilobyte per slice on this schema, and it is the same bytes to every QE — the payload scales with the plan, not with the segment count. Each QE then peels the message apart in PostgresMain at postgres.c (case 'M': /* MPP dispatched stmt from QD */), sets its snapshot from the DTX context first, and calls exec_mpp_query (postgres.c — PostgresMain(), defined), which installs the slice table, works out which slice it is (§18.1's getGpExecIdentity), and runs the ordinary executor lifecycle. §13.3 owns that protocol; the contribution here is the fan-out view. One buildGpQueryString, one 2 KB buffer, nine PQsendGpQuery_shared calls, and ten independent ExecutorStart/ExecutorRun traversals of the same plan tree — each one entering it at a different height.
19.2.6 When a QE dies
Every segment-side error in this handbook has carried a parenthesised suffix — (seg0 slice1 10.100.0.3:7102 pid=9934). It is manufactured on the QD, not the segment, by cdbconn_setQEIdentifier, and it is rebuilt whenever a QE is assigned to a different slice:
if (segdbDesc->segindex >= 0)
{
appendStringInfo(&string, "seg%d", segdbDesc->segindex);
if (sliceIndex > 0)
appendStringInfo(&string, " slice%d", sliceIndex);
}
else
appendStringInfo(&string, SEGMENT_IS_ACTIVE_PRIMARY(cdbinfo) ?
"entry db" : "mirror entry db");
appendStringInfo(&string, " %s:%d", cdbinfo->config->hostip, cdbinfo->config->port);
if (segdbDesc->backendPid != 0)
appendStringInfo(&string, " pid=%d", segdbDesc->backendPid);
The result is stashed as segdbDesc->whoami and stapled onto every incoming PGresult as a private diagnostics field, PG_DIAG_GP_PROCESS_TAG (cdbdispatchresult.c — cdbdisp_appendResult()). When cdbdisp_get_PQerror rebuilds a QE's error as a local ErrorData, it pulls the tag back out and composes errmsg("%s (%s)", fld, whoami) (cdbdisp_get_PQerror()) while preserving the QE's SQLSTATE, file, line and function. That is why a segment error looks like a local error with a return address. To see it, a reader QE on seg0 was terminated from a utility-mode connection while a long join ran, with statement_timeout set as a guard:
Killing a reader QE mid-statement. Session A ran the join twice with statement_timeout=90s; between them a second connection did PGOPTIONS='-c gp_role=utility' psql -p 7102 -c 'select pg_terminate_backend(10791)'.
-- session A
SET statement_timeout = '90s';
select min(pid) from gp_backend_info() where type='r' and content=0; --> 10791
select count(*) from m192_t a join m192_t b on a.b = b.b;
-- (meanwhile, on seg 7102 in utility mode: pg_terminate_backend(10791))
select 'SESSION A STILL ALIVE' as post;
select count(*) from m192_s;
count
-----------
514285716
(1 row)
ERROR: terminating connection due to administrator command (seg0 slice2 10.100.0.3:7102 pid=10791)
post
-----------------------
SESSION A STILL ALIVE
(1 row)
count
-------
300
(1 row)
The QE's FATAL arrived at the QD as an ERROR — deliberately, per the comment at cdbdispatchresult.c (cdbdisp_get_PQerror()): a QE dying must not kill the QD. The statement, however, is unconditionally dead. There is no way to re-run one slice: that QE held part of a hash table and half a redistribution stream, and its peers' Motion receivers were already waiting on a route that will never produce another chunk. So the dispatcher escalates — checkDispatchResult promotes waitMode to DISPATCH_WAIT_CANCEL as soon as any QE reports an error and cancelOnError is set (cdbdisp_async.c — checkDispatchResult()), signalQEs sends libpq cancels to the survivors, and cdbdisp_getDispatchResults rethrows the first collected error. Cleanup then runs through RecycleGang, and here the writer/reader asymmetry pays off one final time: because the victim was a reader, only its descriptor was destroyed, the session's writer gang and transaction survived, and the next statement ran normally on rebuilt readers. Had the writer died, markCurrentGxactWriterGangLost (cdbutil.c — cdbcomponent_recycleIdleQE()) would have forced resetSessionForPrimaryGangLoss — a new gp_session_id, dropped temp tables, and the log line “The previous session was reset because its gang was disconnected” (cdbgang.c — GpDropTempTables()). What none of this covers is admission: how many gangs a cluster will let you hold at once, and what happens when the answer is “not yet”. That is Chapter 21's subject. Between here and there, §19.3 takes the payload apart — the slice table that tells each of these nine processes which slice it is.
19.3 Slice Tables & PlanSlice ↔ Gang Mapping
A dispatched plan is a broadcast: byte for byte, every executor receives the same PlannedStmt. Nothing in it says "you are the segment that scans, you are the one that receives". Yet twelve processes must divide that one tree between them without further negotiation. The object that makes this possible is the slice table, and it exists in two forms with two different lifetimes: PlanSlice, which the planner writes and the dispatcher ships, and ExecSlice, which each executor builds locally and which alone knows about processes.
This section is about that pair of structures and the binding between them. §19.1 gave you the vocabulary (a slice is the unit of parallelism, a Motion is its boundary) and §19.2 owns the processes — how gangs are created, reused and recycled. Here we stay on the mapping: which slice is which, which process belongs to it, how a QE discovers its own identity, and — the practical pay-off — how to read a slice tree out of EXPLAIN and count the processes a plan will need before you run it.
One slice table, two representations. Left: what the planner decided, shipped inside the plan. Middle: what the executor built from it. Right: what the dispatcher bound to it. sliceIndex is the join key across all three.
19.3.1 Two structs, two lifetimes
The planner's slice table is an array hanging off the PlannedStmt — numSlices entries at slices (src/include/nodes/plannodes.h), plus a parallel array subplan_sliceIds (PlannedStmt) mapping each SubPlan to the slice that owns it. One entry looks like this.
typedef struct PlanSlice
{
int sliceIndex;
int parentIndex;
GangType gangType;
/* # of segments in the gang, for PRIMARY_READER/WRITER slices */
int numsegments;
int parallel_workers;
/* segment to execute on, for SINGLETON_READER slices */
int segindex;
/* direct dispatch information, for PRIMARY_READER/WRITER slices */
DirectDispatchInfo directDispatch;
} PlanSlice;
That is the whole planner-side description of a slice: an index, a parent, a gang kind from the GangType enum (plannodes.h), a segment count, and an optional narrowing to specific segments. No process, no address, no tree links. You can read the array straight out of the plan with debug_print_plan, which serializes it verbatim (src/backend/nodes/outfuncs.c — _outPlannedStmt()).
The planner's slice table for a two-table join, read out of the dispatched PlannedStmt.
SET optimizer = off;
SET debug_print_plan = on;
SET client_min_messages = log;
SELECT count(*) FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust;
LOG: plan:
DETAIL: {PLANNEDSTMT
...
:subplan_sliceIds <>
:numSlices 3
:slices[i].sliceIndex 0
:slices[i].parentIndex -1
:slices[i].gangType 0
:slices[i].numsegments 1
:slices[i].parallel_workers 0
:slices[i].segindex 0
:slices[i].directDispatch.isDirectDispatch false
:slices[i].directDispatch.contentIds <>
:slices[i].sliceIndex 1
:slices[i].parentIndex 0
:slices[i].gangType 3
:slices[i].numsegments 3
:slices[i].parallel_workers 0
:slices[i].segindex 0
:slices[i].directDispatch.isDirectDispatch false
:slices[i].directDispatch.contentIds <>
:slices[i].sliceIndex 2
:slices[i].parentIndex 1
:slices[i].gangType 3
:slices[i].numsegments 3
:slices[i].parallel_workers 0
...
gangType 0 is GANGTYPE_UNALLOCATED and 3 is GANGTYPE_PRIMARY_READER. Note that slice 0 — the one the coordinator runs itself — is in the array, with numsegments 1 and no gang type; §13.3 already noted that this slice is never dispatched. The executor-side struct is much fatter.
typedef struct ExecSlice
{
int sliceIndex;
int rootIndex;
int parentIndex;
int planNumSegments; /* nominal, for hash calculations */
List *children; /* child slices = receiving Motions */
GangType gangType;
List *segments; /* segment ids that run this slice */
struct Gang *primaryGang;
List *primaryProcesses; /* one CdbProcess per worker */
Bitmapset *processesMap; /* which QE runs this slice */
} ExecSlice;
| Question | PlanSlice (planner) | ExecSlice (executor) |
|---|---|---|
| Where it lives | PlannedStmt.slices[] | EState.es_sliceTable->slices[] |
| Who writes it | cdbllize_build_slice_table (§16.3) or ORCA's DXL translator (§17.5) | InitSliceTable at ExecutorStart, then AssignGangs at dispatch |
| Lifetime | planning → serialization → every QE reads the same bytes | one instance per process, per statement |
| Tree links | parentIndex only | parentIndex + rootIndex + children list |
| Segment info | numsegments (a count) + directDispatch.contentIds | segments (an actual list of ids) + planNumSegments |
| Processes | none | primaryGang, primaryProcesses, processesMap |
| Parallel workers | parallel_workers = 0 when not parallel | parallel_workers = 1 when not parallel, and useMppParallelMode |
| Whose identity | nobody's — the array is symmetric | SliceTable.localSlice says which entry is me |
19.3.2 The hand-off: InitSliceTable, then AssignGangs
ExecutorStart on the coordinator calls InitSliceTable (execMain.c — standard_ExecutorStart() → execUtils.c). It copies the array, then does two things the planner did not: it walks parentIndex upwards to compute each slice's rootIndex and appends the child to its parent's children list (execUtils.c — InitSliceTable()), and it expands numsegments into a concrete segment list via FillSliceGangInfo.
int factor = ps->parallel_workers ? ps->parallel_workers : 1;
int numsegments = ps->numsegments;
...
case GANGTYPE_PRIMARY_WRITER:
case GANGTYPE_PRIMARY_READER:
slice->planNumSegments = numsegments * factor;
if (dd->isDirectDispatch)
{
/* ... one entry per dd->contentIds member, x factor ... */
}
else
{
for (i = 0; i < numsegments; i++)
for (j = 0; j < factor; j++)
slice->segments = lappend_int(slice->segments,
i % getgpsegmentCount());
planNumSegments = numsegments × parallel_workers is the arithmetic §18.2 introduced from the scan side; here you see it produce the gang size. Now dump the coordinator's copy with debug_print_slice_table (guc_gp.c — config_bool), which fires at DEBUG3 right after InitPlan (execMain.c — standard_ExecutorStart()).
The coordinator's slice table, immediately after InitSliceTable — structure complete, processes absent.
SET debug_print_slice_table = on;
SET client_min_messages = debug3;
SELECT count(*) FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust;
DEBUG: slice table:
DETAIL: {SLICETABLE
:localSlice 0
:numSlices 3
:slices[i].sliceIndex 0
:slices[i].rootIndex 0
:slices[i].parentIndex -1
:slices[i].planNumSegments 1
:slices[i].children (i 1)
:slices[i].gangType 0
:slices[i].segments <>
...
:slices[i].sliceIndex 1
:slices[i].rootIndex 0
:slices[i].parentIndex 0
:slices[i].planNumSegments 3
:slices[i].children (i 2)
:slices[i].gangType 3
:slices[i].segments (i 0 1 2)
:slices[i].useMppParallelMode false
:slices[i].parallel_workers 1
:slices[i].primaryGang <>
:slices[i].primaryProcesses <>
:slices[i].processesMap (b)
...
:hasMotions true
:ic_instance_id 0
}
DEBUG: Query plan size to dispatch: 2KB
children (i 1) and children (i 2) are the tree the planner only implied. primaryProcesses and processesMap are still empty because this dump happens before dispatch: AssignGangs (execUtils.c) runs later, allocates a gang per slice tree via InventorySliceTree and then calls setupCdbProcessList (cdbgang.c — makeCdbProcess()), which fills one CdbProcess per worker — host, interconnect listener port, pid, contentid, dbid (src/include/cdb/cdbgang.h) — and records each worker's identifier in the bitmap (cdbgang.c — setupCdbProcessList()). The QEs receive the result.
The same slice table as a QE received it. Now every worker has an address, and the bitmaps partition the processes between slices.
-- excerpt from the QE-side copy of the dump above (execMain.c:620)
DEBUG: slice table: (seg0 slice1 10.100.0.3:7102 pid=10561)
DETAIL: {SLICETABLE
:localSlice 1
:numSlices 3
...
:slices[i].sliceIndex 1
:slices[i].planNumSegments 3
:slices[i].segments (i 0 1 2)
:slices[i].primaryProcesses (
{CDBPROCESS
:listenerAddr \10.100.0.3
:listenerPort 50429
:pid 10561
:contentid 0
:dbid 2
}
... 2 more, pid 10562 seg1 / pid 10563 seg2 ...
)
:slices[i].processesMap (b 0 1 2)
:slices[i].sliceIndex 2
:slices[i].segments (i 0 1 2)
:slices[i].primaryProcesses (
... pid 10567 seg0 / pid 10568 seg1 / pid 10569 seg2 ...
)
:slices[i].processesMap (b 3 4 5)
:hasMotions true
:ic_instance_id 1
}
Those listenerAddr/listenerPort pairs are the whole reason the slice table travels with the plan: a sending Motion in slice 2 finds its receivers' UDP endpoints by looking up its parent slice's primaryProcesses (§19.5 uses them). And the two disjoint bitmaps (b 0 1 2) / (b 3 4 5) are the plan-to-gang mapping in its most compressed form. You can see the same mapping from the outside: gp_log_gang = debug makes EXPLAIN label each slice with its gang's first identifier (explain.c — show_dispatch_info()), and gp_backend_info() lists the session's backends by identifier.
A five-slice plan: four gangs, twelve QEs, and the identifier arithmetic that ties them together.
SET gp_log_gang = debug;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF)
SELECT count(*) FROM (SELECT c.region, p.cat, sum(f.amt)
FROM m193_fact f JOIN m193_cust c ON f.cust = c.cust
JOIN m193_prod p ON f.prod = p.prod
GROUP BY 1,2) x;
SELECT id, type, content, pid FROM gp_backend_info() ORDER BY type DESC, id;
Finalize Aggregate (actual rows=1 loops=1)
-> Gather Motion 3:1 (slice1; gang0; segments: 3) (actual rows=3 loops=1)
-> Partial Aggregate (actual rows=1 loops=1)
-> Finalize HashAggregate (actual rows=13 loops=1)
-> Redistribute Motion 3:3 (slice2; gang3; segments: 3) (actual rows=39 loops=1)
-> Streaming Partial HashAggregate (actual rows=35 loops=1)
-> Hash Join (actual rows=66711 loops=1)
-> Hash Join (actual rows=66711 loops=1)
-> Seq Scan on m193_fact f (actual rows=66711 loops=1)
-> Hash (actual rows=1000 loops=1)
-> Broadcast Motion 3:3 (slice3; gang6; segments: 3)
-> Seq Scan on m193_cust c (actual rows=340 loops=1)
-> Hash (actual rows=50 loops=1)
-> Broadcast Motion 3:3 (slice4; gang9; segments: 3)
-> Seq Scan on m193_prod p (actual rows=18 loops=1)
(slice0) Executor memory: 136K bytes.
* (slice1) Executor memory: 130K bytes avg x 3x(0) workers, 130K bytes max (seg0).
* (slice2) Executor memory: 2199K bytes avg x 3x(0) workers, 2199K bytes max (seg0).
(slice3) Executor memory: 113K bytes avg x 3x(0) workers, 113K bytes max (seg0).
(slice4) Executor memory: 113K bytes avg x 3x(0) workers, 113K bytes max (seg0).
id | type | content | pid
----+------+---------+-------
0 | w | 0 | 12551
1 | w | 1 | 12552
2 | w | 2 | 12553
3 | r | 0 | 12557
...
11 | r | 2 | 12571
-1 | Q | -1 | 12549
The gang label is not a gang id — GPDB 5 had those and dropped them. It is slice->primaryGang->db_descriptors[0]->identifier, i.e. the qe_identifier of the gang's first member (explain.c — show_dispatch_info()). So gang0 / gang3 / gang6 / gang9 on 3 segments means identifiers 0–2, 3–5, 6–8, 9–11 — exactly the processesMap bitmaps, and exactly the id column of gp_backend_info(). Twelve QEs for one SELECT.
19.3.3 How a QE learns which slice it is
A QE cannot deduce its role from the plan, and it cannot ask — dispatch is one-way and asynchronous (§19.2). Identity arrives through a side channel: when the coordinator opens the libpq connection it passes a gpqeid option whose fourth field is the worker's qe_identifier (cdbgang.c — cdbgang_parse_gpqeid_params(), parsed before authentication). At MPPEXEC time the QE deserializes the slice table and searches the bitmaps for its own number.
/* Identify slice to execute */
for (i = 0; i < sliceTable->numSlices; i++)
{
slice = &sliceTable->slices[i];
if (bms_is_member(qe_identifier, slice->processesMap))
break;
}
if (i == sliceTable->numSlices)
elog(ERROR, "could not find QE identifier in process map");
sliceTable->localSlice = slice->sliceIndex;
localSlice is therefore the only field of the shipped slice table that differs between processes, and it is written by the receiver, not the sender. Everything else — LocallyExecutingSliceIndex (execUtils.c), RootSliceIndex, and §18.1's getGpExecIdentity with its GP_ROOT_SLICE / GP_NON_ROOT_ON_QE / GP_IGNORE verdict — is derived from it. The dump from §19.3.2, grepped across processes, shows the whole population at once.
Six QEs and one QD, each holding the same slice table with a different localSlice.
$ grep -n 'slice table:' st.txt
$ grep -n ':localSlice' st.txt
20:DEBUG: slice table:
94:DEBUG: slice table: (seg0 slice1 10.100.0.3:7102 pid=10561)
192:DEBUG: slice table: (seg1 slice1 10.100.0.3:7103 pid=10562)
294:DEBUG: slice table: (seg0 slice2 10.100.0.3:7102 pid=10567)
391:DEBUG: slice table: (seg2 slice1 10.100.0.3:7104 pid=10563)
493:DEBUG: slice table: (seg1 slice2 10.100.0.3:7103 pid=10568)
602:DEBUG: slice table: (seg2 slice2 10.100.0.3:7104 pid=10569)
22: :localSlice 0
96: :localSlice 1
194: :localSlice 1
296: :localSlice 2
393: :localSlice 1
495: :localSlice 2
604: :localSlice 2
From that single integer, slice identity propagates into every Motion node. ExecInitMotion looks up the sending slice by the Motion's own motionID — the numbering identity we will lean on in