Explainer 11 of 11

Bounding walks by crossings, and flagging what was cut

Read first: Answering every walk once

To say where a dynamic XCTL goes, the analysis walks its target's bytes backwards, crossing into whichever program they came from. Where programs hand areas round in a ring, those walks pile up: on a large CICS corpus, one cyclic group grew past a million places, though earlier fixes had made each walk cheap.

PROGT's XCTL goes to PROGM, a literal in PROGT, or to PROGQ, which PROGC stamped into the commarea two programs back. Allow one crossing (--max-hops 1) and the walk keeps PROGM, stops short of PROGC, and marks the answer hop-limit; allow two and it finds both, unmarked. Stop after N crossings, keep what was found, mark what was cut.

A crossing is a step of the walk that leaves one program for another. The COBOL/CICS primer has the rest of the vocabulary.

The example

Three programs in a chain, and where each XCTL can go:

flowchart TD
    C["PROGC"] -->|"PROGRAM('PROGP')"| P["PROGP"]
    P -->|"MOVE 'PROGT'"| T["PROGT"]
    P -.->|"commarea [0, 8)"| Q["PROGQ · not analysed"]
    T -.->|"MOVE 'PROGM'"| M["PROGM · not analysed"]
    T -.->|"commarea [0, 8)"| Q

PROGC stamps 'PROGQ' into the first eight bytes of its commarea and XCTLs to PROGP. PROGP and PROGT each XCTL to a literal or to the first eight bytes of the commarea they received, handing that commarea on. Dashed arrows lead to PROGQ and PROGM, which are not analysed; each arrow is labelled with the line that names its target.

The question is the target of PROGT's XCTL. Exactly, it is PROGM or PROGQ: PROGQ travels from PROGC through PROGP's commarea untouched and into PROGT, which is why PROGT's dashed commarea arrow also ends at PROGQ. Here is what the combine answers at four limits, for PROGT and for PROGP; these are the rows the test asserts:

--max-hopstarget of PROGTtarget of PROGP
0PROGM · hop-limitPROGT · hop-limit
1PROGM · hop-limitPROGQ, PROGT
2PROGM, PROGQPROGQ, PROGT
no limitPROGM, PROGQPROGQ, PROGT

Read the first two rows for PROGT together: the answer at 0 and at 1 is the same, yet both still carry hop-limit, and at 2 it grows. That is why the reason matters more than the comparison. The two solid edges, PROGC → PROGP (a literal) and PROGP → PROGT (from PROGP's own MOVE 'PROGT'), are found at every limit.

The lines that matter. PROGT is PROGP with 'PROGM' in place of 'PROGT':

* PROGC: WS-FROM is bytes [0, 8) of WS-AREA
MOVE 'PROGQ' TO WS-FROM.
EXEC CICS XCTL PROGRAM('PROGP')
     COMMAREA(WS-AREA) LENGTH(20) END-EXEC.
* PROGP: LK-FROM is DFHCOMMAREA [0, 8)
IF EIBCALEN = 0
    MOVE 'PROGT' TO WS-NEXT
ELSE
    MOVE LK-FROM TO WS-NEXT
END-IF.
EXEC CICS XCTL PROGRAM(WS-NEXT)
     COMMAREA(DFHCOMMAREA) LENGTH(20) END-EXEC.
Full listings (PROGC, PROGP, PROGT)
       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGC.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-AREA.
           05  WS-FROM          PIC X(8).
           05  WS-REST          PIC X(12).
       PROCEDURE DIVISION.
           MOVE 'PROGQ' TO WS-FROM.
           EXEC CICS XCTL PROGRAM('PROGP')
                COMMAREA(WS-AREA) LENGTH(20) END-EXEC.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGP.
       DATA DIVISION.
       WORKING-STORAGE SECTION.
       01  WS-NEXT              PIC X(8).
       LINKAGE SECTION.
       01  DFHCOMMAREA.
           05  LK-FROM          PIC X(8).
           05  LK-REST          PIC X(12).
       PROCEDURE DIVISION.
           IF EIBCALEN = 0
               MOVE 'PROGT' TO WS-NEXT
           ELSE
               MOVE LK-FROM TO WS-NEXT
           END-IF.
           EXEC CICS XCTL PROGRAM(WS-NEXT)
                COMMAREA(DFHCOMMAREA) LENGTH(20) END-EXEC.

       IDENTIFICATION DIVISION.
       PROGRAM-ID. PROGT.
      *    Same as PROGP, with 'PROGM' in place of 'PROGT'.

Step by step

The walk from PROGT's XCTL meets three places, climbing the graph above from PROGT to PROGC. A place is a program, a statement in it, and a byte range. Each row is a place, what its own program gives, and where it would cross next. The first column is the place's hop count.

hopsplacefound locallywould cross to
0PROGT, XCTL, WS-NEXT bytes [0, 8){PROGM}RECEIVED: caller PROGP, at its XCTL, commarea bytes [0, 8)
1PROGP, XCTL, DFHCOMMAREA bytes [0, 8){}RECEIVED: caller PROGC, at its XCTL, WS-AREA bytes [0, 8)
2PROGC, XCTL, WS-AREA bytes [0, 8){PROGQ}nothing

The second place is PROGP's commarea at the moment PROGP hands it on. PROGP never writes LK-FROM, so the bytes there are what PROGP received, and the walk must cross again.

The target of PROGT at four limits (4 runs)
  1. --max-hops 0. The first place has hops 0, which is the limit, and it has a crossing. It keeps PROGM, drops the crossing to PROGP, and adds hop-limit. The walk ends there.

    placehopsoutcome
    PROGT, XCTL, 0–80cut: {PROGM} + hop-limit
    candidates: PROGMreasons: hop-limit
  2. --max-hops 1. The first place has hops 0, below the limit, so it crosses: PROGP's place gets hops 1. That place is at the limit and has a crossing, so it keeps what it found locally — nothing — and adds hop-limit. The extra hop bought nothing: PROGP only hands the bytes along.

    placehopsoutcome
    PROGT, XCTL, 0–80{PROGM}, crosses
    PROGP, XCTL, 0–81cut: {} + hop-limit
    candidates: PROGMreasons: hop-limit
  3. --max-hops 2. PROGP's place has hops 1, below the limit, so it crosses: PROGC's place gets hops 2. That place is at the limit but has no crossing — PROGQ is a literal in PROGC — so nothing is cut and no reason is added.

    placehopsoutcome
    PROGT, XCTL, 0–80{PROGM}, crosses
    PROGP, XCTL, 0–81{}, crosses
    PROGC, XCTL, 0–82{PROGQ}, nothing to cross
    candidates: PROGM, PROGQreasons: none
  4. No limit. Hops are not counted: every place carries 0. The same three places are expanded the same way, and the answer is the same as at 2. The row at 2 had no hop-limit, which is what promised this.

    placehopsoutcome
    PROGT, XCTL, 0–80{PROGM}, crosses
    PROGP, XCTL, 0–80{}, crosses
    PROGC, XCTL, 0–80{PROGQ}, nothing to cross
    candidates: PROGM, PROGQreasons: none

PROGP's own target needs one crossing fewer: its walk starts at its XCTL with {PROGT} found locally and reaches PROGC at hops 1. That is why, in the example's table, PROGP's column loses hop-limit one row earlier than PROGT's.

Counting crossings

Inside one program a walk is cheap: the per-program memo answers each position once for the whole run. The expensive part is leaving the program, which the first two places of the step-by-step table do in their last column. There are three ways to leave, and each is a crossing:

crossingthe bytes cameso go toon this page
RECEIVEDin through the linkage sectionevery caller that jumped here, at what it handed overevery crossing in the step-by-step table and the ring's table below
RETURNEDback from a program this one LINKed or CALLed (a read-back)that callee's exitsPROGL's fragment under what goes wrong
RETRIEVEDfrom EXEC CICS RETRIEVEevery program that STARTed this transactionnone

A second, separate switch, --skip-readbacks, drops the RETURNED row altogether: those stops are not followed, and the bytes report readback-skipped. PROGL's table under what goes wrong shows it.

Give each place a counter: how many crossings it took to get here. When a place at the limit wants to cross again, it does not. It keeps the values it found in its own program, drops the crossings, and adds the reason hop-limit. In step 2 of the walk that place is PROGP's, at hops 1: it keeps its empty set and adds the reason, which travels back to PROGT's jump the same way every other reason in a value set does (see the reasons taxonomy for the full list). So PROGT's chips in step 2 carry hop-limit, though the cut was one program away:

candidates: PROGMreasons: hop-limit

The answer is a subset of the exact answer: at 1 it is {PROGM}, where the exact answer is {PROGM, PROGQ}. A target whose own walk was cut says so; a target shortened only because a cut elsewhere hid a link does not (see what goes wrong).

Why the walk needs a bound

The example's walk met three places and ended at a literal. A statement reached under many different byte ranges is many places, and where programs hand areas to each other in a ring, every program in the ring can reach every other under many ranges, so one cyclic group holds about as many places as the programs in the ring times the byte ranges each is reached under. The smallest ring is three copies of PROGP, each choosing the next program in the ring from a literal or from the first eight bytes of the commarea it received:

flowchart LR
    X["PROGX"] -->|"MOVE 'PROGY'"| Y["PROGY"]
    Y -->|"MOVE 'PROGZ'"| Z["PROGZ"]
    Z -->|"MOVE 'PROGX'"| X

Each arrow is an XCTL that hands on the whole commarea. Walking back from PROGX's XCTL, with no limit, meets these places. Only one byte range, [0, 8), travels round this ring, so each program adds one place to the walk; a ring that passed several ranges would add one place per program per range.

#placefound locallywould cross to
1PROGX, XCTL, WS-NEXT bytes [0, 8){PROGY}RECEIVED: caller PROGZ
2PROGZ, XCTL, DFHCOMMAREA bytes [0, 8){}RECEIVED: caller PROGY
3PROGY, XCTL, DFHCOMMAREA bytes [0, 8){}RECEIVED: caller PROGX
4PROGX, XCTL, DFHCOMMAREA bytes [0, 8){}RECEIVED: caller PROGZ, which is place 2 again

Row 4 is not row 1: the same statement, but the commarea's bytes rather than WS-NEXT's, so it is a separate place. Its crossing lands on place 2, which the memo already holds, and the walk ends. On the large corpus the same shape, with many more programs and ranges, grew past a million places.

The memo already answers each place once per round: the four rows above are each answered once. The remaining cost is the number of rows, which is a property of the corpus. No exact fix could be guaranteed faster without building it and measuring it on the corpus. What was needed was a walk whose cost has a known bound, and whose answers say when they may be incomplete. Here is the ring's walk from PROGX with --max-hops 2; the amber place is the one at the limit:

flowchart TD
    p1["PROGX · WS-NEXT [0, 8) · hops 0 · {PROGY}"] -->|"RECEIVED"| p2["PROGZ · commarea [0, 8) · hops 1 · {}"]
    p2 -->|"RECEIVED"| p3["PROGY · commarea [0, 8) · hops 2 · {}"]
    p3 -.->|"not crossed: hop-limit"| p4["PROGX · commarea [0, 8)"]
    classDef changed fill:#f5b041,stroke:#f5b041,color:#000
    class p3 changed

PROGY's place, at hops 2, keeps its empty set and adds hop-limit, which travels back up the two solid arrows to PROGX's jump, so PROGX's target is:

candidates: PROGYreasons: hop-limit

The cost is now bounded by the programs within N crossings of the jump, not by the size of the corpus: the picture above has three places whatever lies beyond the dashed arrow.

Where this shows up

Cobble's last dataflow stage, cobble-dataflow-combine, answers one question for a CICS corpus: where can each dynamic jump go? On a large CICS corpus (about 3,800 programs and 19,000 jumps), a memo over strongly connected components (explainer 10), a per-program memo, an edge index, and places told apart by their bytes alone had made each walk cheap; even so, one cyclic group of walks, the ring's shape at scale, grew past a million places on the stack. The goal was narrow. Dynamic targets on that corpus come in three shapes, here as lines of PROGP with the crossings each needs:

shapeas a line of PROGPcrossings
VALUE01 WS-NEXT PIC X(8) VALUE 'PROGT'. (a variant: PROGP itself uses a MOVE)0
MOVE literalMOVE 'PROGT' TO WS-NEXT0
from commareaMOVE LK-FROM TO WS-NEXT, where LK-FROM is what PROGC stamped1

The last shape, about 5% of them, is PROGP's own target: one crossing, to PROGC, finds 'PROGQ', so from --max-hops 1 up its row carries no hop-limit. An answer that walks a few crossings and says honestly where it stopped covers all three.

The rule

place = (program, statement, bytes, hops)
expand(place) = local values, plus the places its stops cross to
if it would cross and hops ≥ N: keep local values, cross nowhere, add hop-limit
otherwise: each place crossed to gets hops + 1 (hops stays 0 when there is no limit)
--skip-readbacks: a RETURNED stop crosses nowhere and adds readback-skipped
answer(jump) = join of every place reached from (program, jump, target bytes, 0)

Each line of the box was applied in the walk above. The cut line is step 1 (PROGT's place at hops 0) and step 2 (PROGP's place at hops 1). The hops + 1 line is step 3, where PROGC's place is reached with hops 2. The stays 0 clause is step 4, where the hop column reads 0 on every row (amber where it changed). The --skip-readbacks line is PROGL's second row under what goes wrong.

Why hops are counted only when a limit is set: the hop count is part of the place. With no limit, a cycle across programs would come back to the same program, statement and bytes with a larger count — a new place on every lap, and the walk would never end. With the count held at 0, the lap returns to a place already seen and the memo closes the cycle: that is row 4 of the ring's table landing on place 2. With a limit N, a place can appear under at most N + 1 counts, so laps run out. The ring's walk from PROGX shows both, one row per limit:

--max-hopsplaces on PROGX's walk (program · hops)target of PROGX
no limitX·0, Z·0, Y·0, X·0 (commarea), then Z·0 again: closedPROGY
0X·0 cutPROGY · hop-limit
1X·0, Z·1 cutPROGY · hop-limit
2X·0, Z·1, Y·2 cutPROGY · hop-limit
3X·0, Z·1, Y·2, X·3 (commarea) cutPROGY · hop-limit
4X·0, Z·1, Y·2, X·3, Z·4 cutPROGY · hop-limit

Under limit N the walk holds N + 1 places, the last one cut. At 4, PROGZ's commarea is held twice, as Z·1 and Z·4. With no limit it holds four, and the lap that would have been Z·4 is the Z·0 the memo already has.

Why a target without hop-limit is exact: expansion depends on the hop count only through the cut. If no place reached from a jump was cut, each was expanded exactly as it would be with no limit, so the walk reaches the same programs, statements and bytes, and joins the same values and reasons. Steps 3 and 4 of the walk are this argument on PROGT: the same three places, expanded the same way, differing only in the hop column, with the same chips. (This holds within a round, given the same edges; see below.)

What goes wrong

In cobble

The combine (cobble-dataflow-combine) applies the limit when --max-hops is set; with no limit, a walk follows links as far as they go. A walk cut at the limit carries the reason hop-limit. --skip-readbacks leaves RETURNED stops unfollowed, and the bytes report readback-skipped. Each place is answered once per round, as memoised reachability explains. Both flags are in the field dataflow reference. This is PROGT's row in targets.jsonl from running the combine on the example with --max-hops 1; it is the 1 row of the example's table, PROGT's column:

{"caller": "PROGT", "node": "%MAIN/1", "transfer": "CICS_XCTL",
 "expression": "WS-NEXT", "candidates": ["PROGM"],
 "reasons": ["hop-limit"], "dropped": [], "outside": []}