Artifact a98ef303bb963bed007680edc24fda110d26c63d95f4215014f0861e321c8ff3:
- File
internal/manifest/ticket_test.go
— part of check-in
[39f646e0ae]
at
2026-07-27 19:23:13
on branch trunk
— fix: crosslink ticket artifacts and stop warning for ordinary file blobs (#192)
* fix: write the ticket event row with its tag (#184), quiet the cascade (#186)
crosslinkTicket applied the tkt-<uuid> tag and returned the event row as a pending item for a second pass that was a no-op stub, so the row was never written at all. collectCrosslinkCandidates excludes any blob that already has a tagxref row naming its own rid as srcid, so the tag the incomplete operation wrote also hid the artifact from every later sweep. Cloning fossil's own repository produced 96 event rows of type 't' against canonical fossil's 3,808 -- 100% of the D,J,K,U,Z ticket-change shape lost, with nothing logged and test-integrity still clean, because only derived tables were wrong.
Implement the ticket rebuild instead of reordering the writes: there was no event write to reorder. ticketRebuildEntry replays every artifact carrying the ticket's tag in mtime order and derives its ticket, ticketchng and event rows, mirroring fossil's ticket_rebuild_entry, ticket_insert and manifest_ticket_event. It runs inline in the transaction that writes the tag, so the marker meaning "already linked" and the rows that make that true commit together or not at all. Replaying the whole tag set per artifact also makes the result independent of arrival order.
The event wording, the ticket/ticketchng column mapping including '+field' append semantics, the %h escaping and the two hyperlink hash lengths were all derived from the fossil binary rather than assumed, and are pinned by TestFossilBinaryTicketParity: a corpus built with `fossil ticket add`/`change` is crosslinked by us, rebuilt by fossil, and the two derivations compared row for row. TestCrosslink_TicketArtifactsProduceEventRows is the count assertion whose absence let this ship.
Audited the other three exclusion predicates for the same split-write shape: forumpost and attachment are each written by the same function, in the same transaction, as the event row that accompanies them, and propagate errors, so neither can diverge. No change made to them.
Secondary (#186): AfterDephantomize warned once per blob that failed to parse as a manifest, which is the ordinary outcome for file content and what the whole-repository sweep has always skipped silently -- ~39k lines on a fossil clone, burying the failures that matter. It now skips them the way linkBatch does. A blob that parses but fails to link is still a real fault and still warns.
* docs: record the ticket rebuild's measured cost and why it stays inline
The inline rebuild is quadratic in one ticket's artifact count. The cheaper deferred shape stays atomic but needs two call sites to remember to invoke it, and a forgotten call reintroduces #184 silently. Write the measurements and that reasoning down at the function, so a later profile of a repository with one hot ticket does not turn into an optimization back into the bug. (user: [email protected], size: 4062) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" ) // ticketArtifact builds one D,J,K,U,Z ticket-change artifact -- the card shape // `fossil ticket add` and `fossil ticket change` emit, and the shape issue #184 // lost in full. deck.Marshal orders the J run and computes the Z card, so the // bytes are what the parser would accept off the wire. func ticketArtifact(t *testing.T, ticketUUID string, when time.Time, user string, fields ...deck.TicketField) []byte { t.Helper() d := &deck.Deck{ Type: deck.Ticket, D: when, J: fields, K: ticketUUID, U: &user, } raw, err := d.Marshal() if err != nil { t.Fatalf("marshal ticket artifact for %s: %v", ticketUUID, err) } return raw } // storeTicketArtifact stores one ticket artifact and returns its rid. func storeTicketArtifact(t *testing.T, r *repo.Repo, raw []byte) libfossil.FslID { t.Helper() rid, _, err := blob.Store(r.DB(), raw) if err != nil { t.Fatalf("blob.Store: %v", err) } return rid } // ticketUUID returns a distinct, well-formed 40-hex ticket id for index n. func ticketUUID(n int) string { return strings.Repeat(fmt.Sprintf("%x", n%16), 40) } // TestCrosslink_TicketArtifactsProduceEventRows is the regression test whose // absence let issue #184 ship: every ticket artifact must leave an event row // behind, and the count must match the artifact count exactly. // // Before the fix, crosslinkTicket wrote only the tkt-<uuid> tag and handed the // event row to a pending-item pass that was a no-op stub, so this count was // zero while the sweep still reported every artifact linked -- and the tag it // did write satisfied collectCrosslinkCandidates' tagxref exclusion, hiding // the artifacts from every later sweep. func TestCrosslink_TicketArtifactsProduceEventRows(t *testing.T) { r := setupTestRepo(t) base := time.Date(2024, 3, 1, 10, 0, 0, 0, time.UTC) // Two tickets, each with a create artifact and two changes: enough to // exercise the first-artifact and subsequent-artifact branches, and to // prove the count is per artifact rather than per ticket. type spec struct { ticket int fields []deck.TicketField } specs := []spec{ {1, []deck.TicketField{{Name: "status", Value: "Open"}, {Name: "title", Value: "first bug"}, {Name: "type", Value: "Code_Defect"}}}, {1, []deck.TicketField{{Name: "status", Value: "Closed"}, {Name: "resolution", Value: "Fixed"}}}, {1, []deck.TicketField{{Name: "priority", Value: "High"}}}, {2, []deck.TicketField{{Name: "status", Value: "Open"}, {Name: "title", Value: "second bug"}}}, {2, []deck.TicketField{{Name: "icomment", Value: "a note"}}}, } for i, s := range specs { raw := ticketArtifact(t, ticketUUID(s.ticket), base.Add(time.Duration(i)*time.Minute), "alice", s.fields...) storeTicketArtifact(t, r, raw) } if _, err := Crosslink(r); err != nil { t.Fatalf("Crosslink: %v", err) } var events int if err := r.DB().QueryRow("SELECT count(*) FROM event WHERE type='t'").Scan(&events); err != nil { t.Fatalf("count ticket events: %v", err) } if events != len(specs) { t.Fatalf("event rows of type 't' = %d, want %d (one per ticket artifact)", events, len(specs)) } // A second sweep must find nothing left to do. If the tag were still the // only marker a ticket artifact wrote, this is where the permanent // exclusion of issue #184 would show up as a stable-but-wrong state. linked, err := Crosslink(r) if err != nil { t.Fatalf("second Crosslink: %v", err) } if linked != 0 { t.Fatalf("second Crosslink linked %d artifacts, want 0", linked) } if err := r.DB().QueryRow("SELECT count(*) FROM event WHERE type='t'").Scan(&events); err != nil { t.Fatalf("recount ticket events: %v", err) } if events != len(specs) { t.Fatalf("after a second sweep event rows of type 't' = %d, want %d", events, len(specs)) } }