Artifact 9d3d0f52c4bd2014b484a3ea9654c7dd25010fee1452b29395f794e8a0c70bf0:
- File
internal/index/landing.go
— part of check-in
[22346a81a7]
at
2026-08-31 13:14:29
on branch trunk
— prefix lookups seek instead of scanning; sqlite tuned for this workload
col LIKE ? || '%' cannot use an index: the pattern is not a literal when the statement is prepared, so finding ONE check-in by its full hash read all 36,853 rows of sqlite's tree. That was 10s on a first timeline render and 40ms after, which reads as cold I/O and is not. Half-open hex ranges seek. Also sets cache_size, mmap_size and temp_store, none of which were configured. (user: quarry, size: 42626) [annotate] [blame] [check-ins using]
package index import ( "database/sql" "strings" ) // A landing (zit#127) is a branch proposed to land on a target, carried // entirely by artifacts fossil already understands: the record is a forum // thread whose root body opens with a marker line, every state change is a // reply in that thread, and the outcome is a raw tag on the check-in that // landed. zit mints no card type for it, so there is nothing here to fold // from a new artifact kind — a landing is a READING of forum_post rows. // // That is why this table is derived and recomputed rather than written once // on arrival. Folding is ORDER-INDEPENDENT (0001 §5): a reply can be indexed // before the root it belongs to, a root can arrive after its own acceptance, // and either order must converge on the same row. So any forum post in a // thread recomputes that thread's landing from the forum_post rows present, // instead of trying to patch a row incrementally from the artifact in hand. // // The fold rule matches zit's exactly — latest transition wins, ordered by // (date, hash) — because two readers of the same store disagreeing about a // landing's state is the failure this whole feature exists to avoid. // landingMarker opens the body of a thread root that is a landing record. // Versioned at the source; a body announcing a version this code does not // know is NOT a landing here, which is a refusal to guess rather than an // oversight. Reading it as a landing anyway would mean rendering fields we // only think we recognise. const landingMarker = "zit-landing: 1" // IsLandingBody reports whether a forum post opens a landing. Exported // so the ingest fold can name the event correctly: a landing IS a forum // thread, and calling its opening post forum.posted would be true but // useless — nothing wanting landings could select them. func IsLandingBody(body string) bool { return strings.Contains(body, landingMarker) } // IsLandingStateBody reports whether a post carries a landing verdict. func IsLandingStateBody(body string) bool { return strings.Contains(body, landingStateMarker) } // landingStateMarker opens the body of a reply carrying a state transition. const landingStateMarker = "zit-landing-state:" // landingTargetMarker opens the body of a reply RETARGETING the record // (zit#144). A landing's target is not fixed at open: when the branch // below a stack lands, zit retargets whatever was stacked on it onto // the branch that just landed, and records that as a reply — the same // latest-wins shape a state transition uses. Reading the target from // the opening post alone leaves the record pointing at a branch that // has already landed, and every question asked of it after that — most // of all "did this merge?" — is asked about the wrong branch. const landingTargetMarker = "zit-landing-target:" // parseLandingTarget reads a retarget reply. Unlike a state, a branch // name is not a closed set, so anything non-empty on the marker line is // the new target; an empty one is a comment with an odd first line. func parseLandingTarget(body string) (string, bool) { if !strings.HasPrefix(body, landingTargetMarker) { return "", false } rest := body[len(landingTargetMarker):] if i := strings.IndexByte(rest, '\n'); i >= 0 { rest = rest[:i] } if t := strings.TrimSpace(rest); t != "" { return t, true } return "", false } // landingStates is the closed set. A reply naming anything else is a comment // with an odd first line, not a transition — an unknown state must never // settle a record. var landingStates = map[string]bool{"open": true, "accepted": true, "rejected": true} // parseLandingHeader reads branch and target out of a thread root's body. // ok is false for every ordinary forum post, which is the common case. func parseLandingHeader(body string) (branch, target string, ok bool) { if !strings.HasPrefix(body, landingMarker) { return "", "", false } lines := strings.Split(body, "\n") for _, line := range lines[1:] { if line == "" || line == "\r" { break // the blank line ends the header } switch { case strings.HasPrefix(line, "branch: "): branch = strings.TrimSpace(line[len("branch: "):]) case strings.HasPrefix(line, "target: "): target = strings.TrimSpace(line[len("target: "):]) default: // An unrecognised header line under a version we DO claim to // know is a dialect mismatch. Refuse the record rather than // index a partial reading of it. return "", "", false } } if branch == "" || target == "" { return "", "", false } return branch, target, true } // parseLandingState reads the state a reply carries, or ok=false when the // reply is an ordinary comment. func parseLandingState(body string) (string, bool) { if !strings.HasPrefix(body, landingStateMarker) { return "", false } rest := body[len(landingStateMarker):] if i := strings.IndexByte(rest, '\n'); i >= 0 { rest = rest[:i] } s := strings.TrimSpace(rest) if !landingStates[s] { return "", false } return s, true } // LandingProse is a landing root's body with its header block removed — the // message the author actually wrote. // // The header is ENCODING, and zit's landing module is explicit that the // interface hides it ("if a thread hash leaks into the interface ... this has // become a pass-through wrapper"). A page rendering the raw root body shows // `zit-landing: 1` to a human, which is the encoding leaking through the // interface built to hide it. Stripping it belongs here, beside the parser // that defines where the header ends, rather than in each reader. func LandingProse(body string) string { if !strings.HasPrefix(body, landingMarker) { return body } // The blank line ends the header, exactly as parseLandingHeader reads it. if i := strings.Index(body, "\n\n"); i >= 0 { return strings.TrimLeft(body[i+2:], "\n") } return "" } // notALanding is the clause that keeps landing records off the forum. // // A landing IS a forum thread — that is the compatibility story, and it is // why a stock fossil clone can read one. But it is not forum CONTENT: it is a // decision record with its own surface, its own controls and its own listing, // and a reader scanning discussions has no use for a thread whose first line // is a marker. // // The clause lives here, once, because a listing and the count above it // disagreeing about what a forum holds is the confirmation-leak shape in // miniature: the number would promise a thread the list refuses to show. func notALanding(col string) string { return col + ` NOT IN (SELECT id FROM landing)` } // LandingByPrefix resolves a landing from an id prefix, the way every other // hash in this forge resolves: a prefix matching two records matches neither, // because answering arbitrarily is worse than refusing. func (ix *Index) LandingByPrefix(prefix string) (LandingSummary, bool, error) { rows, err := ix.db.Query(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by FROM landing WHERE id >= ? AND id < ? LIMIT 2`, prefix, prefix+"g") if err != nil { return LandingSummary{}, false, err } defer rows.Close() var out LandingSummary n := 0 for rows.Next() { n++ if n > 1 { return LandingSummary{}, false, rows.Err() } if err := rows.Scan(&out.ID, &out.Branch, &out.Target, &out.Title, &out.Author, &out.Opened, &out.Updated, &out.State, &out.SettledBy, &out.SupersededBy, &out.ClaimedBy); err != nil { return LandingSummary{}, false, err } } return out, n == 1, rows.Err() } // LandingTransition reports whether a post body is a state transition rather // than a remark, and which state it carries. // // Exported because the API has to tell the two apart to render them // differently: a transition is an EVENT in the record ("dan accepted this"), // the way every forge renders a state change, while a remark is a comment. // Reading the marker in a second place would be a second dialect, so the // classification stays with the fold that defines it. func LandingTransition(body string) (state string, ok bool) { return parseLandingState(body) } // LandingRetarget reports the new target a reply announces, so a reader // sees the retarget as the EVENT it is rather than as a remark whose // first line happens to be machine text. func LandingRetarget(body string) (target string, ok bool) { return parseLandingTarget(body) } // landingUpdatedMarker opens the body of a reply recording that the // target was brought INTO the branch on the store (zit#152, `landing // update`): the merge check-in's hash follows it. An event, not a // remark — rendering it as prose puts the marker in the discussion. const landingUpdatedMarker = "zit-landing-updated:" // LandingUpdate reads an update reply: the hash of the merge it wrote. func LandingUpdate(body string) (merge string, ok bool) { if !strings.HasPrefix(body, landingUpdatedMarker) { return "", false } rest := body[len(landingUpdatedMarker):] if i := strings.IndexByte(rest, '\n'); i >= 0 { rest = rest[:i] } if h := strings.TrimSpace(rest); h != "" { return h, true } return "", false } // foldLanding recomputes one thread's landing row from the forum_post rows // currently present, and is called for every forum post folded in that // thread. Cheap: one thread is a handful of rows, and the alternative — // patching incrementally — cannot be made order-independent. // // A thread that is not (or is no longer) a landing has its row DELETED, so a // record can never outlive the post it was read from. func foldLanding(tx *sql.Tx, thread string) error { var body, title, user, opened string err := tx.QueryRow(`SELECT body, title, user, date FROM forum_post WHERE hash = ?`, thread).Scan(&body, &title, &user, &opened) if err == sql.ErrNoRows { // The root has not been folded yet. Not an error and not a gap: // whichever post arrives last recomputes the whole thread, so the // row appears as soon as the root does. return nil } if err != nil { return err } branch, target, ok := parseLandingHeader(body) if !ok { if _, err := tx.Exec(`DELETE FROM landing WHERE id = ?`, thread); err != nil { return err } // Whatever this thread is, it is forum content again. Only // reachable by a refold that saw the root differently, but the // re-filing below is not reversible any other way and a one-way // classification is the kind that leaves a store wrong for ever. _, err := tx.Exec(`UPDATE event SET kind = 'f', ref = ?, comment = (SELECT 'Forum: ' || CASE WHEN p.title != '' THEN p.title ELSE substr(p.body, 1, 60) END FROM forum_post p WHERE p.hash = event.hash) WHERE hash IN (SELECT hash FROM forum_post WHERE thread = ?)`, thread, thread) return err } // Ascending, so the LAST transition seen wins — zit's own fold rule, // deliberately mirrored rather than re-derived. rows, err := tx.Query(`SELECT hash, body, date FROM forum_post WHERE thread = ? AND hash <> ? ORDER BY date, hash`, thread, thread) if err != nil { return err } defer rows.Close() // verdicts holds each reply that ANNOUNCES a state, by its own // state rather than the thread's final one: a timeline row says // what happened at that moment, not how the story ended. verdicts := map[string]string{} // replyState is what the THREAD says, read the way zit's own fold // reads it: the last transition wins. replyState, replyBy := "open", "" updated := opened for rows.Next() { var hash, replyBody, date string if err := rows.Scan(&hash, &replyBody, &date); err != nil { return err } if date > updated { updated = date } if s, ok := parseLandingState(replyBody); ok { replyState, replyBy = s, hash verdicts[hash] = s } // Latest wins, exactly as the state above does: the rows are // already ordered ascending for that reason. if t, ok := parseLandingTarget(replyBody); ok { target = t } if _, ok := LandingUpdate(replyBody); ok { verdicts[hash] = "updated" } } if err := rows.Err(); err != nil { return err } // RFC 0007: the merge is the fact and the reply is the claim. A // landing is accepted when the DAG holds the check-in that merged // it — whoever posted what. A reply asserting acceptance with no // merge behind it leaves the record open and is recorded as a // claim, so the page can say one was made. Unconditional on // purpose: a fact about the DAG is the same fact under any policy, // which is what lets Disposability hold by construction. // // Rejection has no DAG fact to read, so there the reply stays the // record — as it was. merge, err := mergeOf(tx, branch, target, opened) if err != nil { return err } state, settledBy, claimedBy := "open", "", "" // A reply says accepted and no merge check-in exists. Before calling // that a claim, one more DAG fact: the branch may already be // CONTAINED in the target — zit settles such a landing as accepted // with nothing to merge ("merges up-to-date"), and its work is on // the target. Tip ancestry is the wrong general test (a branch // continued past its merge un-settles) but the right validation of // a claim, and it runs only then. contained := false if merge == "" && replyState == "accepted" { var err error if contained, err = containedIn(tx, branch, target); err != nil { return err } } switch { case merge != "" || contained: state = "accepted" if replyState == "accepted" { settledBy = replyBy } case replyState == "rejected": state, settledBy = "rejected", replyBy case replyState == "accepted": claimedBy = replyBy verdicts[replyBy] = "claimed" } if _, err := tx.Exec(`INSERT OR REPLACE INTO landing (id, branch, target, title, author, opened, updated, state, settled_by, claimed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, thread, branch, target, title, user, opened, updated, state, settledBy, claimedBy); err != nil { return err } if err := refileLandingEvents(tx, thread, title, verdicts); err != nil { return err } return supersedeLandings(tx, branch) } // mergeOf finds the check-in that landed a proposal: the earliest on // the target, opened or later, whose non-primary parent is on the // proposed branch. Structural — the P card zit wrote when it merged — // not tip ancestry, which un-settles a branch continued past its // merge. The same shape MergesForLandings reads, inside the fold's // transaction. // // Empty when the branch column is not yet computed for the tip, which // is the case for check-ins folded in the current batch; the refresh // after recomputeBranches closes that gap. func mergeOf(tx *sql.Tx, branch, target, opened string) (string, error) { var hash string err := tx.QueryRow(`SELECT c.hash FROM checkin c JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0 JOIN checkin tip ON tip.hash = p.parent WHERE tip.branch = ? AND c.branch = ? AND c.date >= ? ORDER BY c.date, c.hash LIMIT 1`, branch, target, opened).Scan(&hash) if err == sql.ErrNoRows { return "", nil } return hash, err } // containedIn reports whether branch's tip is an ancestor of target's // tip — the DAG fact behind "already on the target". Bounded, because // a walk from trunk's tip on a large store visits every check-in ever // made; the bound is far above any real branch depth and turns a // pathological store into a "no" rather than a hang. func containedIn(tx *sql.Tx, branch, target string) (bool, error) { tip := func(b string) (string, error) { var h string err := tx.QueryRow(`SELECT c.hash FROM checkin c WHERE c.branch = ? AND NOT EXISTS ( SELECT 1 FROM checkin_parent p JOIN checkin k ON k.hash = p.child WHERE p.parent = c.hash AND k.branch = ?) ORDER BY c.date DESC, c.hash DESC LIMIT 1`, b, b).Scan(&h) if err == sql.ErrNoRows { return "", nil } return h, err } bt, err := tip(branch) if err != nil || bt == "" { return false, err } tt, err := tip(target) if err != nil || tt == "" { return false, err } var one int err = tx.QueryRow(`WITH RECURSIVE anc(hash, d) AS ( SELECT ?, 0 UNION SELECT p.parent, anc.d + 1 FROM checkin_parent p JOIN anc ON p.child = anc.hash WHERE anc.d < 20000) SELECT 1 FROM anc WHERE hash = ? LIMIT 1`, tt, bt).Scan(&one) if err == sql.ErrNoRows { return false, nil } return err == nil, err } // refreshLandingStates re-derives every unsettled landing after the // branch columns are recomputed. A merge check-in folded in the same // batch as the reply that announced it — or pushed from a clone with // no reply at all — is only findable once its tip's branch is known, // and this is the first moment it is. Rejected records are left alone: // they have no DAG fact to converge on. // // Every fold of a landing thread recomputes it from scratch, so this is // the same function the posts run, not a second rule beside it. func refreshLandingStates(tx *sql.Tx) error { rows, err := tx.Query(`SELECT id FROM landing WHERE state <> 'rejected'`) if err != nil { return err } var ids []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() return err } ids = append(ids, id) } rows.Close() if err := rows.Err(); err != nil { return err } for _, id := range ids { if err := foldLanding(tx, id); err != nil { return err } } return nil } // supersedeLandings decides which of several open landings on one // branch is the current one. // // zit#127 left this as "probably yes, refused by name on the second" // and neither side enforces it. Refusing at open is the courtesy and it // belongs in the writer; it cannot be the whole answer, because two // people can open a landing for one branch in separate clones and both // are ordinary forum threads that are valid artifacts when they meet. // Sync makes prevention unenforceable, so the readers have to agree. // // The newest root wins, by (opened, id). Newest rather than oldest // because a second landing is the author saying something about the // branch NOW, and because it is the rule the state fold already uses — // two folds in one file disagreeing about which artifact supersedes // which is a worse thing to own than either rule. // // SETTLED landings never take part. An accepted landing is history and // its check-in is on the target; a later proposal for the same branch // is a new proposal, not a correction of a finished one. // // The losers keep their rows and their discussion — nothing is deleted, // because a record somebody argued in is not noise. They are marked, so // the queue can show one open landing per branch and the page can say // where the conversation moved. func supersedeLandings(tx *sql.Tx, branch string) error { if branch == "" { return nil } rows, err := tx.Query(`SELECT id FROM landing WHERE branch = ? AND state = 'open' ORDER BY opened DESC, id DESC`, branch) if err != nil { return err } var open []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() return err } open = append(open, id) } rows.Close() if err := rows.Err(); err != nil { return err } if len(open) == 0 { return nil } // Recomputed from scratch every fold, never accumulated: a landing // that was superseded and then settled elsewhere must be able to // become current again when the winner is settled or withdrawn. winner := open[0] for _, id := range open { to := winner if id == winner { to = "" } if _, err := tx.Exec(`UPDATE landing SET superseded_by = ? WHERE id = ?`, to, id); err != nil { return err } } // A settled landing is never superseded by an open one. _, err = tx.Exec(`UPDATE landing SET superseded_by = '' WHERE branch = ? AND state <> 'open' AND superseded_by <> ''`, branch) return err } // refileLandingEvents moves a landing's posts off the forum kind. // // A landing IS a forum thread, so the forum trigger files every post in // one as kind 'f' with a "Forum: " prefix — which produced timeline // rows reading "Forum: zit-landing-state: accepted Accepted. The forge // now tells y", a prefix over a marker over a truncated body. quarry // had already decided these are not forum content: notALanding keeps // them out of the forum listing and its count. The timeline was the // surface that missed the memo, and surfaces disagreeing about what a // forum holds is the same shape as a count promising a thread the list // refuses to show. // // This runs HERE, not in the trigger, because the trigger fires while // the forum post is being inserted and the landing row does not exist // yet — the root post is what the landing row is derived FROM. Any post // in a thread recomputes the record, so classification converges no // matter which order a clone delivers them in. // // Verdicts are 'l' and plain comments are 'lc'. A verdict changes the // target branch and belongs in a mixed timeline; a comment is // conversation, and three landings produced six rows of it. func refileLandingEvents(tx *sql.Tx, thread, title string, verdicts map[string]string) error { hashes, err := tx.Query(`SELECT hash FROM forum_post WHERE thread = ?`, thread) if err != nil { return err } defer hashes.Close() var posts []string for hashes.Next() { var h string if err := hashes.Scan(&h); err != nil { return err } posts = append(posts, h) } if err := hashes.Err(); err != nil { return err } for _, h := range posts { kind, label := "lc", "Comment on landing: "+title switch { case h == thread: kind, label = "l", "Landing: "+title case verdicts[h] == "claimed": // Asserted acceptance with nothing merged. Conversation, // not a verdict: it changed no branch. kind, label = "lc", "Acceptance claimed on landing: "+title case verdicts[h] == "updated": // The target brought into the branch. Housekeeping on the // proposal, not a decision about it. kind, label = "lc", "Landing updated from its target: "+title case verdicts[h] != "": kind, label = "l", "Landing "+verdicts[h]+": "+title } if _, err := tx.Exec(`UPDATE event SET kind = ?, ref = ?, comment = ? WHERE hash = ?`, kind, thread, label, h); err != nil { return err } } return nil } // backfillLandings populates the table for indexes written before it // existed, for the reason backfillTickets gives: the fold only writes it on // newly arriving posts, so an upgraded index would show an EMPTY landing list // until someone reindexed a store whose forum_post rows are already complete. // // Once, at open, and only when there is nothing to lose: a non-empty table is // left alone. func (ix *Index) backfillLandings() error { var landings, posts int if err := ix.db.QueryRow(`SELECT count(*) FROM landing`).Scan(&landings); err != nil { return err } if err := ix.db.QueryRow(`SELECT count(*) FROM forum_post`).Scan(&posts); err != nil { return err } if landings > 0 || posts == 0 { return nil } rows, err := ix.db.Query(`SELECT hash FROM forum_post WHERE hash = thread AND body LIKE ?`, landingMarker+"%") if err != nil { return err } var roots []string for rows.Next() { var h string if err := rows.Scan(&h); err != nil { rows.Close() return err } roots = append(roots, h) } rows.Close() if err := rows.Err(); err != nil { return err } if len(roots) == 0 { return nil } // The LIKE above is a cheap prefilter; parseLandingHeader inside // foldLanding is the real gate, so a body that merely starts with the // marker but carries an unknown header is still refused. tx, err := ix.db.Begin() if err != nil { return err } defer tx.Rollback() for _, root := range roots { if err := foldLanding(tx, root); err != nil { return err } } return tx.Commit() } // VerdictsForLandings reports each landing's CI verdict — "ci-pass", // "ci-fail", or absent when no run has recorded one. // // The ticket asked which shape a landing verdict should take, forum // reply or tag, before wiring the display. Neither, because the shape // already exists: a verdict is a tag artifact on a CHECK-IN, quarry // already folds and reads them, and a landing proposes a branch whose // tip is a check-in. So a landing's verdict is its branch tip's // verdict, and there is nothing new to mint. // // That is also the better answer on its own terms. The verdict stays // attached to the thing that was actually tested rather than to a // discussion record; it keeps the zit#127 invariant with no new // vocabulary, because a stock fossil clone already understands the tag; // and a landing retargeted or reopened inherits the right answer // without anyone copying it across. func (ix *Index) VerdictsForLandings(branches []string) (map[string]string, error) { out := map[string]string{} for _, b := range branches { if b == "" || out[b] != "" { continue } tip, err := ix.branchTip(b) if err != nil { return nil, err } if tip == "" { continue } st, err := ix.tagState([]string{tip}) if err != nil { return nil, err } if v := st[tip].ci; v != "" { out[b] = v } } return out, nil } // OpenLandingOn reports the current open landing for a branch, if // there is one. The courtesy check a writer makes before opening a // second: see supersedeLandings for why it cannot be the whole answer. // Behind counts the target's check-ins the branch has not seen — how // far the target has moved since the branch last carried it. Zero // means an update has nothing to bring in; the decision card offers // the verb only when this says it would do something (RFC 0009). func (ix *Index) Behind(branch, target string) (int, error) { if branch == "" || target == "" || branch == target { return 0, nil } branchTip, err := ix.branchTip(branch) if err != nil || branchTip == "" { return 0, err } targetTip, err := ix.branchTip(target) if err != nil || targetTip == "" { return 0, err } // Ancestors of the branch tip (bounded: a branch that diverged more // than 500 deep still reads as behind, just not exactly how far). rows, err := ix.db.Query(` WITH RECURSIVE seen(hash, depth) AS ( SELECT ?, 0 UNION SELECT p.parent, s.depth + 1 FROM checkin_parent p JOIN seen s ON p.child = s.hash WHERE s.depth < 500 ) SELECT hash FROM seen`, branchTip) if err != nil { return 0, err } ancestors := map[string]bool{} for rows.Next() { var h string if err := rows.Scan(&h); err != nil { rows.Close() return 0, err } ancestors[h] = true } rows.Close() if err := rows.Err(); err != nil { return 0, err } // Walk the target back from its tip, counting until the branch has // seen it. The walk stays on reachable parents, not branch labels, // so merges from elsewhere count as the movement they are. behind := 0 frontier := []string{targetTip} visited := map[string]bool{} for len(frontier) > 0 && behind <= 500 { next := []string{} for _, h := range frontier { if visited[h] || ancestors[h] { continue } visited[h] = true behind++ ps, err := ix.ParentsOf(h) if err != nil { return behind, err } next = append(next, ps...) } frontier = next } return behind, nil } // VerdictOf reads one check-in's recorded CI verdict — "ci-pass", // "ci-fail", or "" — the per-check-in reading VerdictsForLandings // gives per branch (RFC 0009 §verdict-tags, carry-forward's guard). func (ix *Index) VerdictOf(checkin string) (string, error) { if checkin == "" { return "", nil } st, err := ix.tagState([]string{checkin}) if err != nil { return "", err } return st[checkin].ci, nil } // ParentsOf lists a check-in's parents — for a merge, the two tips it // joined, which is how carry-forward reconstructs the tested pair. func (ix *Index) ParentsOf(checkin string) ([]string, error) { rows, err := ix.db.Query(`SELECT parent FROM checkin_parent WHERE child = ?`, checkin) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var p string if err := rows.Scan(&p); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (ix *Index) OpenLandingOn(branch string) (LandingSummary, bool, error) { var l LandingSummary err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by FROM landing WHERE branch = ? AND state = 'open' AND superseded_by = '' ORDER BY opened DESC, id DESC LIMIT 1`, branch).Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy) if err == sql.ErrNoRows { return LandingSummary{}, false, nil } if err != nil { return LandingSummary{}, false, err } return l, true, nil } // BranchCommit is one check-in a landing proposes. type BranchCommit struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // BranchCommits returns a branch's own check-ins in ANCESTRY order, // parents before children. // // The landing page used to list them through the timeline with a branch // filter, which orders by (date DESC, hash DESC). Two check-ins made in // the same second tie on date and then order by hash, which has nothing // to do with which is the parent — so a branch's own commits rendered // in an order that is arbitrary with respect to ancestry. Commits land // back-to-back routinely here, so this is not a rare shape, and it is // exactly the quiet kind of wrong a reviewer would trust. // // zit hit the same bug in `landing show` and its fix is the one worth // inheriting: stop hand-rolling the order. The verb owns no ancestry // ordering of its own; the DAG does. // // So this walks parents from the branch tip and orders by depth. A // check-in's depth is its LONGEST path back from the tip, which is what // makes a parent always deeper than its child even where the branch // forked and rejoined. Dates break ties between unrelated commits at // the same depth, where any order is as good as another, and never // between a parent and its child. // // The walk stops where the branch does: a parent on another branch is // the point the proposal diverged, and everything beyond it is shared // history the target already has. // BranchTip names a branch's newest check-in — the subject CI runs // and external checks attach to (RFC 0009). Exported because taking // the last element of a LIMITed BranchCommits silently answers with // the wrong commit once a branch outgrows the limit. func (ix *Index) BranchTip(branch string) (string, error) { return ix.branchTip(branch) } func (ix *Index) BranchCommits(branch string, limit int) ([]BranchCommit, error) { if branch == "" { return nil, nil } if limit <= 0 { limit = 50 } tip, err := ix.branchTip(branch) if err != nil || tip == "" { return nil, err } rows, err := ix.db.Query(` WITH RECURSIVE walk(hash, depth) AS ( SELECT ?, 0 UNION SELECT p.parent, w.depth + 1 FROM checkin_parent p JOIN walk w ON p.child = w.hash JOIN checkin c ON c.hash = p.parent WHERE c.branch = ? ) SELECT c.hash, c.date, c.user, c.comment, max(w.depth) AS d FROM walk w JOIN checkin c ON c.hash = w.hash WHERE c.branch = ? GROUP BY c.hash ORDER BY d DESC, c.date, c.hash LIMIT ?`, tip, branch, branch, limit) if err != nil { return nil, err } defer rows.Close() var out []BranchCommit for rows.Next() { var c BranchCommit var depth int if err := rows.Scan(&c.Hash, &c.Date, &c.User, &c.Comment, &depth); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // branchTip is the check-in on a branch that nothing else on that // branch calls a parent. // // Not "the newest by date", which is the same mistake one level up: // two tips tying on a second would pick by hash. A tip is a structural // property — a leaf within the branch — and only a branch that genuinely // forked has more than one, where the newest is the honest guess. func (ix *Index) branchTip(branch string) (string, error) { var tip string err := ix.db.QueryRow(`SELECT c.hash FROM checkin c WHERE c.branch = ? AND NOT EXISTS ( SELECT 1 FROM checkin_parent p JOIN checkin k ON k.hash = p.child WHERE p.parent = c.hash AND k.branch = ?) ORDER BY c.date DESC, c.hash DESC LIMIT 1`, branch, branch).Scan(&tip) if err == sql.ErrNoRows { return "", nil } return tip, err } // LandingMerge is the check-in a landing produced. type LandingMerge struct { Checkin string `json:"checkin"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // MergesForLandings resolves each landing to the check-in that merged // it, for the landings named. Landings that never landed are absent. // // landing.settled_by holds the reply that DECIDED the state — a forum // post hash, not a commit — so it was read as meaning nothing connects // a landing to the check-in it produced, and the durable fix was // assumed to be upstream. It is not: the answer is already in the DAG. // A merge check-in's P card lists its primary parent first and the // branches it merged after, so ord > 0 IS the branch tip that landed, // and the landing knows which branch it proposed. // // That is a structural fact zit wrote when it merged, not a guess. // Matching on comment text would have been a guess — those matched only // because the accept message and the merge message were written the // same way by habit — and branch-plus-time is a heuristic that is // eventually wrong. // // The search is bounded by when the landing was OPENED, not when it was // accepted. The merge lands before the state artifact recording it is // written — pager-shape merged at 12:46:55 and its verdict was filed at // 12:49:51 — so a verdict bound silently loses exactly the check-ins // this exists to find. A proposal cannot be landed before it is made, // which is the bound that actually holds. // // The remaining ambiguity is a branch landed more than once; the // earliest qualifying merge wins, which is the one that first landing // produced. func (ix *Index) MergesForLandings(ids []string) (map[string]LandingMerge, error) { out := map[string]LandingMerge{} if len(ids) == 0 { return out, nil } stmt, err := ix.db.Prepare(`SELECT c.hash, c.date, c.user, c.comment FROM checkin c JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0 JOIN checkin tip ON tip.hash = p.parent WHERE tip.branch = ? AND c.branch = ? AND c.date >= ? ORDER BY c.date, c.hash LIMIT 1`) if err != nil { return nil, err } defer stmt.Close() for _, id := range ids { var branch, target, opened string err := ix.db.QueryRow(`SELECT l.branch, l.target, l.opened FROM landing l WHERE l.id = ? AND l.state = 'accepted'`, id).Scan(&branch, &target, &opened) if err == sql.ErrNoRows { continue // open, rejected, or unknown: nothing landed } if err != nil { return nil, err } var m LandingMerge err = stmt.QueryRow(branch, target, opened).Scan(&m.Checkin, &m.Date, &m.User, &m.Comment) if err == sql.ErrNoRows { continue } if err != nil { return nil, err } out[id] = m } return out, nil } // attachLandings decorates the MERGE check-ins in a timeline page with // the landing each one settled, and who approved it. // // Evaluated for the returned rows only, and only for the merges among // them — the same rule the page's other per-row extras follow. A // timeline of check-ins on a forge with no landings pays one nothing. func (ix *Index) attachLandings(out []TimelineEntry) error { var merges []int for i := range out { if out[i].Kind == "ci" && out[i].IsMerge { merges = append(merges, i) } } if len(merges) == 0 { return nil } // The approver is whoever posted the accept; failing that — a // merge pushed from a clone with no reply — whoever wrote the merge. stmt, err := ix.db.Prepare(`SELECT l.id, l.title, COALESCE(NULLIF((SELECT f.user FROM forum_post f WHERE f.hash = l.settled_by), ''), c.user) FROM checkin c JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0 JOIN checkin tip ON tip.hash = p.parent JOIN landing l ON l.branch = tip.branch AND l.target = c.branch AND l.state = 'accepted' AND c.date >= l.opened WHERE c.hash = ? ORDER BY l.updated DESC LIMIT 1`) if err != nil { return err } defer stmt.Close() for _, i := range merges { var id, title, approver string err := stmt.QueryRow(out[i].Hash).Scan(&id, &title, &approver) if err == sql.ErrNoRows { continue // a merge somebody made by hand } if err != nil { return err } out[i].Landing, out[i].LandingTitle, out[i].LandingApprover = id, title, approver } return nil } // backfillLandingEvents re-files landing posts that an older index filed // as forum events. // // The classification moved from the forum trigger into the fold, so a // store folded before that change carries "Forum: " rows for every // landing comment and verdict it already holds. Without this, the // timeline stays wrong until somebody happens to reindex — and derived // data that is only correct after a manual step is not derived data, // it is a chore. // // Idempotent and cheap to skip: the count is an indexed lookup, and a // store with nothing to fix pays only for that. func (ix *Index) backfillLandingEvents() error { var stale int if err := ix.db.QueryRow(`SELECT count(*) FROM event e JOIN forum_post p ON p.hash = e.hash WHERE e.kind = 'f' AND p.thread IN (SELECT id FROM landing)`).Scan(&stale); err != nil { return err } if stale == 0 { return nil } rows, err := ix.db.Query(`SELECT id FROM landing`) if err != nil { return err } var roots []string for rows.Next() { var h string if err := rows.Scan(&h); err != nil { rows.Close() return err } roots = append(roots, h) } rows.Close() if err := rows.Err(); err != nil { return err } tx, err := ix.db.Begin() if err != nil { return err } defer tx.Rollback() // foldLanding rather than refileLandingEvents directly: it is the // one place that decides which reply is a verdict, and a backfill // that re-derived that rule beside it would be a second answer to // the same question. for _, root := range roots { if err := foldLanding(tx, root); err != nil { return err } } return tx.Commit() } // LandingSummary is the list-surface row: everything the queue and the // detail header need, without a second query or a DAG walk. type LandingSummary struct { ID string `json:"id"` Branch string `json:"branch"` Target string `json:"target"` Title string `json:"title"` Author string `json:"author"` Opened string `json:"opened"` Updated string `json:"updated"` State string `json:"state"` // SettledBy is the reply that decided State; empty while open. SettledBy string `json:"settled_by,omitempty"` // Replies counts the thread's remarks: every post but the opener, // minus the state-transition posts, which are events rather than // things anyone said. Replies int `json:"replies"` // SupersededBy names the landing this one defers to when two are // open on the same branch. Empty is the ordinary case. SupersededBy string `json:"superseded_by,omitempty"` // ClaimedBy is a reply that asserted acceptance while nothing was // merged. The record is still open; the page says a claim was made. ClaimedBy string `json:"claimed_by,omitempty"` // CI is the branch tip's recorded verdict — "ci-pass", "ci-fail", // or empty when no run has written one. Not stored on the landing: // filled by the reader from the tag, so it cannot go stale against // the branch it describes. CI string `json:"ci,omitempty"` // Stack, Position and Depth are RFC 0006's reading of an open // landing: the base's id, this landing's rung counted from 1 at the // base, and the stack's height. Derived at read time by // AttachStrata, never stored. Broken names a chain that cannot be // walked: a cycle, or a rejected base. Stack string `json:"stack,omitempty"` Position int `json:"position,omitempty"` Depth int `json:"depth,omitempty"` Broken string `json:"broken,omitempty"` } // Landings lists landings, newest activity first. state filters to one of // open/accepted/rejected; empty returns every landing. // // limit of 0 means no limit — the caller is a page that already knows how // many rows it wants, and a silent default would truncate a listing that // looked complete. func (ix *Index) Landings(state string, limit int) ([]LandingSummary, error) { q := `SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by, (SELECT count(*) FROM forum_post p WHERE p.thread = landing.id AND p.hash <> landing.id AND p.body NOT LIKE ?) FROM landing` // The marker holds no LIKE wildcards, so a bare prefix pattern is exact. args := []any{landingStateMarker + "%"} if state != "" { q += ` WHERE state = ?` args = append(args, state) // The open queue is a work list, and a superseded proposal is // not work: its branch already has a current landing. It stays // reachable, and the unfiltered listing still shows it. if state == "open" { q += ` AND superseded_by = ''` } } q += ` ORDER BY updated DESC, id DESC` if limit > 0 { q += ` LIMIT ?` args = append(args, limit) } rows, err := ix.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var out []LandingSummary for rows.Next() { var l LandingSummary if err := rows.Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy, &l.Replies); err != nil { return nil, err } out = append(out, l) } return out, rows.Err() } // Landing returns one landing by id, or ok=false when no such record is // indexed. Prefix matching belongs to the caller's hash resolution, not // here: this table's key is the full thread-root hash. func (ix *Index) Landing(id string) (LandingSummary, bool, error) { var l LandingSummary err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by FROM landing WHERE id = ?`, id).Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy) if err == sql.ErrNoRows { return LandingSummary{}, false, nil } if err != nil { return LandingSummary{}, false, err } return l, true, nil }