Artifact 0c09b2aa9bc1117421de70f7b1e912a795044c7adb4c45a9f3b48a1c92d3dbf7:
- File
internal/zitcli/zitcli.go
— part of check-in
[c754084dc7]
at
2026-08-17 15:52:12
on branch trunk
— web: the files page was unusably slow; fix the query, memoise, cap the list
Three separate faults, worst first.
Branches() used a correlated subquery — one full scan of checkin per row. On tcl (42,074 check-ins, 1,855 branches) that took 434 SECONDS, which is what made the page look hung rather than slow. One grouped scan with SQLite's bare-column-with-max rule, plus an index on (branch, date): 0.042s.
'ls --long' costs ~4ms per file because it reads each blob for size and the binary flag, so tcl's 2,198-path tip took 8.5s. Fossil never pays this — browse.c builds a directory listing from its own indexed tables and shows no size at all — but quarry reads through the CLI by design, so the answer is to compute once: a check-in's file set is IMMUTABLE, so listings are memoised per version, the tip is warmed after each ingest, and the tree op resolves 'trunk' to its hash so the page asks for the key that was warmed.
Only a hash may be cached. Caching 'trunk' would serve a stale tree forever after the next commit — a correctness bug dressed as a performance fix — so the memo refuses anything that can move, with a test pinning it.
The branch selector rendered every branch: 1,855 <option> elements, 90% of a 239KB page, and unusable as a control. Now the 25 most recently active, plus the branch being viewed even when it is older.
tcl's repo home: 8.5s -> 0.38s, 239KB -> 29KB. sqlite: 5.0s -> 0.32s. (user: dmestas, size: 20085) [annotate] [blame] [check-ins using]
// Package zitcli is the subprocess boundary to zit (0001 §2): the zit // binary is the only store interface — never linked code, never direct // file reads. This package is a data plane; the arch gate restricts who // may import it. package zitcli import ( "bufio" "bytes" "errors" "fmt" "io" "os/exec" "sort" "strconv" "strings" "sync" ) // ErrCursorInvalid reports that the store was rewritten (pack, shun // reclaim) under a consumer's cursor. The remedy is a re-read from zero; // derived indexes rebuild, so the loss is detectable, never silent. var ErrCursorInvalid = errors.New("zitcli: cursor invalid: store was rewritten; re-read from zero") // Cursor is an opaque resume position from a prior Frames call. The zero // Cursor reads from the beginning (bootstrap). Only round-tripping the // values is supported — their content belongs to zit. type Cursor struct { Since string Verify string } // Artifact is one whole artifact as streamed by zit — delta storage is // resolved before emission, so Bytes hash to Hash. type Artifact struct { Hash string Bytes []byte } // Batch is one Frames result: the artifacts appended since the request // cursor, and the cursor to pass next time. type Batch struct { Artifacts []Artifact Cursor Cursor } // Client runs zit against one repository path (a checkout directory or // the .zit file itself, per zit -R). type Client struct { repo string // lsCache memoises file listings. A check-in's file set is IMMUTABLE // — the same version always yields the same listing — so a cached // entry can never go stale, which is what makes caching safe here // rather than merely convenient. // // It exists because `ls --long` costs roughly 4ms per file: it reads // each blob for size and the binary flag, so tcl's 2,198-path tip // takes 8.5 seconds. Fossil never pays that — its directory listing // comes from its own indexed tables and shows no size at all — but // quarry reads through the CLI by design (0001 §2), so the choice is // to compute once rather than to index separately. lsMu sync.Mutex lsCache map[string][]LsEntry } func New(repo string) *Client { return &Client{repo: repo, lsCache: map[string][]LsEntry{}} } // lsCacheLimit bounds the memo. Listings are keyed by version, and a // browsing session touches few; the cap is only so a long-lived process // cannot accumulate one entry per check-in ever viewed. const lsCacheLimit = 64 // exit codes documented by `zit help frames`. const ( exitNoNew = 3 exitCursorInvalid = 4 ) // Frames streams artifacts appended after c. A current cursor returns an // empty Batch with the same cursor; a dead cursor returns ErrCursorInvalid. func (c *Client) Frames(cur Cursor) (Batch, error) { args := []string{"frames", "-R", c.repo} if cur.Since != "" { args = append(args, "--since="+cur.Since, "--verify="+cur.Verify) } cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr stdout, err := cmd.StdoutPipe() if err != nil { return Batch{}, err } if err := cmd.Start(); err != nil { return Batch{}, err } batch, parseErr := parseFrames(bufio.NewReader(stdout)) waitErr := cmd.Wait() switch code := exitCode(waitErr); { case code == exitCursorInvalid: return Batch{}, ErrCursorInvalid case code == exitNoNew: // Trailer-only output; the parsed cursor is authoritative. case waitErr != nil: return Batch{}, fmt.Errorf("zitcli: frames: %w: %s", waitErr, strings.TrimSpace(stderr.String())) } if parseErr != nil { return Batch{}, fmt.Errorf("zitcli: frames: %w", parseErr) } return batch, nil } // LsEntry is one file of a check-in as listed by `zit ls`. // // Size, Perm and Binary come from --long (zit#96). Before it existed a // listing could show none of them: size and binary cost a subprocess // per file, and the permission was unreachable at any price, because it // lives in the manifest rather than the blob. type LsEntry struct { Blob string `json:"blob"` Name string `json:"name"` Size int64 `json:"size"` // Perm is the raw F-card token: "-" for a regular file, "x" // executable, "l" symbolic link. Perm string `json:"perm"` Binary bool `json:"binary"` } // Executable reports the x permission. func (e LsEntry) Executable() bool { return strings.Contains(e.Perm, "x") } // Symlink reports the l permission. A symlink's blob content is its // TARGET path, so a viewer must not render it as file content. func (e LsEntry) Symlink() bool { return !e.Executable() && strings.Contains(e.Perm, "l") } // Ls lists the effective file set of a check-in (an on-demand read, // 0001 §2: ~ms subprocess cost, off the hot path, cache above here). func (c *Client) Ls(version string) ([]LsEntry, error) { return c.ls(version, "") } // LsUnder lists only the paths beneath a directory (zit#96's --under), // so drawing one directory does not materialise the whole tree — 2,198 // paths to show 146 of them, on a repository the size of tcl. func (c *Client) LsUnder(version, under string) ([]LsEntry, error) { return c.ls(version, under) } func (c *Client) ls(version, under string) ([]LsEntry, error) { // ONLY an immutable version may be cached. "trunk" names whatever is // newest, so its listing changes on every commit — memoising it // would serve a stale tree forever, which is a correctness bug // wearing a performance fix's clothes. if !immutableVersion(version) { return c.lsUncached(version, under) } key := version + "\x00" + under c.lsMu.Lock() if hit, ok := c.lsCache[key]; ok { c.lsMu.Unlock() return hit, nil } c.lsMu.Unlock() entries, err := c.lsUncached(version, under) if err != nil { return nil, err } c.lsMu.Lock() if len(c.lsCache) >= lsCacheLimit { // Whole-map reset rather than an LRU: the cache is an // optimisation, the cost of a miss is bounded, and an eviction // policy here would be more machinery than the problem needs. c.lsCache = map[string][]LsEntry{} } c.lsCache[key] = entries c.lsMu.Unlock() return entries, nil } func (c *Client) lsUncached(version, under string) ([]LsEntry, error) { args := []string{"ls", "--long", "-R", c.repo} if under != "" { args = append(args, "--under="+under) } args = append(args, version) cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("zitcli: ls %s: %w: %s", version, err, strings.TrimSpace(stderr.String())) } var files []LsEntry for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { if line == "" { continue } // --long: <blob> <size> <perm> <text|binary> <name>. The name is // last and may contain spaces, so the split is bounded rather // than greedy. fields := strings.SplitN(line, " ", 5) if len(fields) != 5 { return nil, fmt.Errorf("zitcli: ls: unparseable line %q", line) } size, err := strconv.ParseInt(fields[1], 10, 64) if err != nil { return nil, fmt.Errorf("zitcli: ls: bad size in %q", line) } files = append(files, LsEntry{ Blob: fields[0], Size: size, Perm: fields[2], Binary: fields[3] == "binary", Name: fields[4], }) } return files, nil } // ErrNoEntry reports a read of a file that is not available at the // requested check-in — absent from its file set, or content shunned. var ErrNoEntry = errors.New("file not available at this check-in") // Cat emits a file's exact bytes at a check-in (an on-demand read, // 0001 §2). The emitted bytes are hash-verified by zit itself. A file // zit cannot serve (untracked at that check-in, shunned content) comes // back wrapping ErrNoEntry. func (c *Client) Cat(path, version string) ([]byte, error) { cmd := exec.Command("zit", "cat", "-R", c.repo, path, version) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return nil, fmt.Errorf("%w (%s@%.12s): %s", ErrNoEntry, path, version, strings.TrimSpace(stderr.String())) } if err != nil { return nil, fmt.Errorf("zitcli: cat %s@%s: %w: %s", path, version, err, strings.TrimSpace(stderr.String())) } return out, nil } // DiffEntry is one file in a name-status diff. type DiffEntry struct { Status string `json:"status"` // A, M, D Name string `json:"name"` } // Diff compares two check-ins: unified hunks plus the name-status // summary (two zit invocations; both on-demand reads, 0001 §2). func (c *Client) Diff(from, to string) (string, []DiffEntry, error) { unified, err := c.diffOut(from, to) if err != nil { return "", nil, err } brief, err := c.diffOut(from, to, "--brief") if err != nil { return "", nil, err } var entries []DiffEntry for _, line := range strings.Split(strings.TrimSuffix(brief, "\n"), "\n") { if line == "" { continue } status, name, found := strings.Cut(line, " ") if !found { return "", nil, fmt.Errorf("zitcli: diff --brief: unparseable line %q", line) } entries = append(entries, DiffEntry{Status: status, Name: name}) } return unified, entries, nil } func (c *Client) diffOut(from, to string, extra ...string) (string, error) { // -R diffs from --from/--to work against a bare store now (zit#69), // so quarry never needs a checkout on disk. args := append([]string{"diff", "-R", c.repo, "--from", from, "--to", to}, extra...) cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: diff %s..%s: %w: %s", from, to, err, strings.TrimSpace(stderr.String())) } return string(out), nil } func exitCode(err error) int { var ee *exec.ExitError if errors.As(err, &ee) { return ee.ExitCode() } return 0 } // parseFrames reads the frames wire format: zero or more // "artifact HASH SIZE\n" + SIZE raw bytes + "\n", then "cursor SINCE TOKEN\n". func parseFrames(r *bufio.Reader) (Batch, error) { var b Batch for { line, err := r.ReadString('\n') if err != nil { return b, fmt.Errorf("reading frame header: %w", err) } fields := strings.Fields(line) switch { case len(fields) == 3 && fields[0] == "artifact": size, err := strconv.Atoi(fields[2]) if err != nil { return b, fmt.Errorf("bad size in %q", strings.TrimSpace(line)) } raw := make([]byte, size) if _, err := io.ReadFull(r, raw); err != nil { return b, fmt.Errorf("artifact %s: %w", fields[1], err) } if nl, err := r.ReadByte(); err != nil || nl != '\n' { return b, fmt.Errorf("artifact %s: missing frame terminator", fields[1]) } b.Artifacts = append(b.Artifacts, Artifact{Hash: fields[1], Bytes: raw}) case len(fields) == 3 && fields[0] == "cursor": b.Cursor = Cursor{Since: fields[1], Verify: fields[2]} return b, nil default: return b, fmt.Errorf("unexpected frame line %q", strings.TrimSpace(line)) } } } // WikiCommit records a wiki artifact for title with body, returning the // new artifact hash (zit#71: a machine verb, hash-only stdout). func (c *Client) WikiCommit(title, user, mimetype string, body []byte) (string, error) { args := []string{"wiki", "commit", title, "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } cmd := exec.Command("zit", args...) cmd.Stdin = bytes.NewReader(body) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { return "", fmt.Errorf("zitcli: wiki commit %q: %w: %s", title, err, strings.TrimSpace(stderr.String())) } return strings.TrimSpace(string(out)), nil } // TicketNew records a fresh ticket's first change and returns its // minted ticket id and the artifact hash. func (c *Client) TicketNew(user string, fields map[string]string) (id, hash string, err error) { out, err := c.ticketRun(append([]string{"ticket", "new", "-R", c.repo, "--user", user}, fieldArgs(fields)...)) if err != nil { return "", "", err } id, hash, found := strings.Cut(strings.TrimSpace(out), " ") if !found { return "", "", fmt.Errorf("zitcli: ticket new: unparseable result %q", out) } return id, hash, nil } // TicketSet records a change to an existing ticket, returning the // artifact hash. ErrNoEntry reports an unknown ticket id. func (c *Client) TicketSet(id, user string, fields map[string]string) (string, error) { out, err := c.ticketRun(append([]string{"ticket", "set", id, "-R", c.repo, "--user", user}, fieldArgs(fields)...)) if err != nil { return "", err } return strings.TrimSpace(out), nil } func (c *Client) ticketRun(args []string) (string, error) { cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String())) } if err != nil { return "", fmt.Errorf("zitcli: %s: %w: %s", strings.Join(args[:2], " "), err, strings.TrimSpace(stderr.String())) } return string(out), nil } func fieldArgs(fields map[string]string) []string { keys := make([]string, 0, len(fields)) for k := range fields { keys = append(keys, k) } sort.Strings(keys) args := make([]string, 0, len(keys)) for _, k := range keys { args = append(args, k+"="+fields[k]) } return args } // ForumPost opens a new forum thread (zit#76): title is the H card, // body the W card, mimetype the N card (zit#77); returns the root // post's artifact hash. An empty mimetype omits the card, which Fossil // reads as text/x-fossil-wiki — see markup's dispatch. func (c *Client) ForumPost(title, user, mimetype string, body []byte) (string, error) { args := []string{"forum", "post", "-R", c.repo, "--user", user} if title != "" { args = append(args, "--title", title) } if mimetype != "" { args = append(args, "--mimetype", mimetype) } return c.stdinHash(args, body) } // ForumReply replies to postHash within its thread (zit resolves the // thread root transitively); returns the reply's artifact hash. // ErrNoEntry reports an unknown or non-forum post. func (c *Client) ForumReply(postHash, user, mimetype string, body []byte) (string, error) { args := []string{"forum", "reply", postHash, "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } return c.stdinHash(args, body) } // TechnoteAdd records a technote (zit#76). event reuses an id to record // an edit (latest wins); empty mints a fresh one. Returns the event id // and the artifact hash. // // comment is the C card — the title a timeline renders for the note. // It can only be written here: zit documents that no later verb can // attach the card, so a note recorded without one shows its event id // forever (zit#77). func (c *Client) TechnoteAdd(user, event, mimetype, comment string, body []byte) (id, hash string, err error) { args := []string{"technote", "add", "-R", c.repo, "--user", user} if mimetype != "" { args = append(args, "--mimetype", mimetype) } if comment != "" { args = append(args, "--comment", comment) } if event != "" { args = append(args, "--event", event) } out, err := c.stdinHash(args, body) if err != nil { return "", "", err } id, hash, found := strings.Cut(out, " ") if !found { return "", "", fmt.Errorf("zitcli: technote add: unparseable result %q", out) } return id, hash, nil } // stdinHash runs a machine verb with body on stdin, returning trimmed // stdout (the hash line). Exit 1 is a user error (unknown target, empty // body/author) and maps to ErrNoEntry. func (c *Client) stdinHash(args []string, body []byte) (string, error) { cmd := exec.Command("zit", args...) cmd.Stdin = bytes.NewReader(body) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if exitCode(err) == 1 { return "", fmt.Errorf("%w: %s", ErrNoEntry, strings.TrimSpace(stderr.String())) } if err != nil { return "", fmt.Errorf("zitcli: %s %s: %w: %s", args[0], args[1], err, strings.TrimSpace(stderr.String())) } return strings.TrimSpace(string(out)), nil } // ArtifactRaw returns one stored artifact's exact bytes, addressed by // hash. This is how attachment content reaches a reader: an attachment's // source is a plain blob, not a file within a check-in, so Cat (which // resolves a path at a version) cannot fetch it. // // max caps the response — an attachment is arbitrary user-supplied // content, and quarry buffers it to hand to the op layer, so an // unbounded read would be a memory denial of service. Exceeding the cap // is an error, never a truncated artifact: silently serving a prefix of // a binary is worse than refusing it. func (c *Client) ArtifactRaw(hash string, max int) ([]byte, error) { cmd := exec.Command("zit", "artifact", hash, "--raw", "-R", c.repo) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if exitCode(err) == 1 { return nil, fmt.Errorf("zitcli: artifact %s: %w", hash, ErrNoEntry) } return nil, fmt.Errorf("zitcli: artifact %s: %w: %s", hash, err, strings.TrimSpace(stderr.String())) } if len(out) > max { return nil, fmt.Errorf("zitcli: artifact %s: %d bytes exceeds the %d-byte limit", hash, len(out), max) } return out, nil } // TagAdd attaches a raw-namespace tag with a value to a check-in // (zit#80). Raw is not optional for a verdict: without --raw the name // enters the symbolic namespace, where it resolves to a check-in like a // release name does — and the SECOND check-in to pass CI would make // "ci-pass" an ambiguous name for both. // // propagate carries the tag to descendants; a CI verdict does not // propagate, because passing says something about one check-in. func (c *Client) TagAdd(name, checkin, value string, propagate bool) error { args := []string{"tag", "add", "--raw"} if propagate { args = append(args, "--propagate") } args = append(args, name, checkin) if value != "" { args = append(args, value) } args = append(args, "-R", c.repo) cmd := exec.Command("zit", args...) var stderr bytes.Buffer cmd.Stderr = &stderr if _, err := cmd.Output(); err != nil { return fmt.Errorf("zitcli: tag add %s %s: %w: %s", name, checkin, err, strings.TrimSpace(stderr.String())) } return nil } // FileChange is one check-in that changed a file, from `zit log PATH`. type FileChange struct { Hash string `json:"hash"` Date string `json:"date"` User string `json:"user"` Comment string `json:"comment"` } // FileHistory returns the check-ins that changed path, newest first // (zit#93). A path that never changed yields nothing, which is not an // error — it is the honest answer for a file that only ever existed in // one state. func (c *Client) FileHistory(version, path string, limit int) ([]FileChange, error) { if limit <= 0 { limit = 50 } cmd := exec.Command("zit", "log", "--long", "-n", strconv.Itoa(limit), "-R", c.repo, version, path) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if exitCode(err) == 1 { return nil, nil // no such path at this version: an empty history } return nil, fmt.Errorf("zitcli: log %s: %w: %s", path, err, strings.TrimSpace(stderr.String())) } var hist []FileChange for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { rest, ok := strings.CutPrefix(line, "checkin ") if !ok { continue // continuation lines of a multi-line comment } // checkin <id> <timestamp> <user> <comment…> f := strings.SplitN(rest, " ", 4) if len(f) < 3 { continue } fc := FileChange{Hash: f[0], Date: f[1], User: f[2]} if len(f) == 4 { fc.Comment = f[3] } hist = append(hist, fc) } return hist, nil } // immutableVersion reports whether a version string names one fixed // check-in — a full artifact hash. Branch names, tags, "tip" and // timestamps all resolve differently as history grows, so a listing // taken under them may not be cached. func immutableVersion(v string) bool { if len(v) != 40 && len(v) != 64 { return false } for i := 0; i < len(v); i++ { c := v[i] if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true }