Artifact af6a654b6b1f0d9af9ab0f9598417423f0257fb04f8b6e02fe4a5dc4532534c2:
- File
internal/manifest/submanifest_write.go
— part of check-in
[6d79130418]
at
2026-10-18 16:58:56
on branch feat/submanifest-reader
— feat(manifest): select k on enable as zit does, and add disabling
The draft's automatic selection is advisory, and zit lets history outrank the snapshot: on enable it replays the newest 200 check-ins through the tree builder at the snapshot's k and its two neighbours and pins the value whose steady-state commits materialize the fewest bytes, the lowest on a tie, keeping the snapshot when fewer than two check-ins replay or every candidate stays flat. replayK ports that step by step, so two writers enabling on one history pin alike.
DisableSubmanifests turns emission off and keeps the pin, returning zit's note when trees already exist. The out-of-range fallback logs zit's report. (user: [email protected], size: 12233) [annotate] [blame] [check-ins using]
package manifest // The writer-side settings of draft-fossil-submanifests-00 and the decisions // they drive: whether a commit emits a tree and at which k (§8.1, §6.3), how // a local request enables emission and pins k (§8.1, §6.3), the manual and // the automatic re-tune (§6.4), and storing a tree beside its manifest // (§6.2). Defaults leave every commit byte-identical to a writer that has // never heard of trees. import ( "database/sql" "errors" "fmt" "log/slog" "sort" "strconv" "strings" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" ) // The three project-scope settings, named as configuration exchange carries // them (§6.3, §8.1, §6.4). const ( SettingEnableSubmanifests = "enable-submanifests" SettingSubmanifestK = "submanifest-k" SettingAutoRetune = "submanifest-auto-retune" ) // SubmanifestEnableWarning is what an operator is told on enabling emission // (§8.1, §7.2, §8.2): the consequences that cannot be undone. The wording is // zit's, so both writers warn alike. const SubmanifestEnableWarning = "enabling sub-manifests makes this repository write tree manifests; " + "from the first one, peers below Fossil 2.29 will be refused every S-bearing artifact rather " + "than receive a converted or downgraded one, and disabling the setting later cannot retire " + "the artifacts already written. While this specification is provisional a repository " + "containing an S-bearing artifact must not be synchronized with any repository that is not " + "itself a disposable test fixture." // ParseSettingBool reads a boolean setting as zit and fossil write one: on, // off, 1 or 0, and fossil's yes, no, true and false. func ParseSettingBool(v string) (value, ok bool) { switch strings.ToLower(v) { case "on", "1", "yes", "true": return true, true case "off", "0", "no", "false": return false, true } return false, false } // ParseSettingK reads submanifest-k: a decimal from MinPartitionK to // MaxPartitionK inclusive (§6.3). func ParseSettingK(v string) (int, bool) { k, err := strconv.Atoi(v) if err != nil || !validPartitionK(k) { return 0, false } return k, true } func validPartitionK(k int) bool { return k >= MinPartitionK && k <= MaxPartitionK } // submanifestSettings are the three settings as stored. type submanifestSettings struct { enabled bool k int // the pin, or 0 when there is no valid one rawK string // the stored text, for the out-of-range report autoRetune bool } func readSubmanifestSettings(q db.Querier) (submanifestSettings, error) { var s submanifestSettings read := func(name string) (string, error) { var value sql.NullString err := q.QueryRow("SELECT value FROM config WHERE name=?", name).Scan(&value) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", fmt.Errorf("%s: %w", name, err) } return value.String, nil } enable, err := read(SettingEnableSubmanifests) if err != nil { return s, err } if s.rawK, err = read(SettingSubmanifestK); err != nil { return s, err } retune, err := read(SettingAutoRetune) if err != nil { return s, err } s.enabled, _ = ParseSettingBool(enable) s.k, _ = ParseSettingK(s.rawK) s.autoRetune, _ = ParseSettingBool(retune) return s, nil } // emissionK is the k a commit partitions at, or 0 for a flat manifest: // §8.1 emits no S card unless enable-submanifests is on, nor after the // session's server sent avoid-submanifests (§7.2); §6.3 falls back to flat, // and reports it, when the stored k is not a value from 1 to 12. func emissionK(s submanifestSettings, avoid bool) int { if !s.enabled || avoid { return 0 } if s.k == 0 { // zit's report, naming this library's re-tune command. slog.Warn(fmt.Sprintf("sub-manifests: pinned submanifest-k is out of range (%s; must be %d..%d)"+ " — emitting a flat manifest. Re-pin with 'libfossil repo submanifests retune --k N'.", s.rawK, MinPartitionK, MaxPartitionK)) } return s.k } // writeSubmanifestSetting stores one of the three settings with fossil's // seconds-since-epoch mtime, so configuration exchange carries it. func writeSubmanifestSetting(q db.Querier, name, value string) error { if _, err := q.Exec("REPLACE INTO config(name, value, mtime) VALUES(?, ?, strftime('%s','now'))", name, value); err != nil { return fmt.Errorf("config %s: %w", name, err) } return nil } // errNoSelection is §6.3's automatic selection without a result. var errNoSelection = errors.New("no partition parameter can be selected") // EnableSubmanifests is §8.1's local request to turn emission on. A valid // pin takes precedence: an explicit k that differs from it is refused, since // changing it is a re-tune. With no valid pin, an explicit k (1 to 12) is // pinned, and k = 0 asks for §6.3's automatic selection over current, the // check-in the next commit will descend from (0 for none). A request that // cannot be met changes nothing. Selection is zit's: the §6.3 snapshot // over current, refined by a replay of recent history when there is one. It // returns the pinned k; the caller owes the operator // SubmanifestEnableWarning. func EnableSubmanifests(r *repo.Repo, current libfossil.FslID, k int) (pinned int, err error) { if r == nil { panic("manifest.EnableSubmanifests: r must not be nil") } err = r.WithTx(func(tx *db.Tx) error { s, err := readSubmanifestSettings(tx) if err != nil { return err } switch { case s.k != 0 && k != 0 && k != s.k: return fmt.Errorf("submanifest-k is pinned to %d; changing it is a re-tune", s.k) case s.k != 0: pinned = s.k case k != 0: if !validPartitionK(k) { return fmt.Errorf("submanifest-k %d is outside %d..%d", k, MinPartitionK, MaxPartitionK) } pinned = k default: if pinned, err = selectForCheckin(tx, current); err != nil { return err } // As zit does, recent history outranks the snapshot when it can // speak (submanifest_replay.go). k, ok, err := replayK(tx, pinned) if err != nil { return err } if ok { pinned = k } } if pinned != s.k { if err := writeSubmanifestSetting(tx, SettingSubmanifestK, strconv.Itoa(pinned)); err != nil { return err } } if s.enabled { return nil } return writeSubmanifestSetting(tx, SettingEnableSubmanifests, "on") }) if err != nil { return 0, fmt.Errorf("manifest.EnableSubmanifests: %w", err) } return pinned, nil } // selectForCheckin runs §6.3's automatic selection over current's file set. func selectForCheckin(q db.Querier, current libfossil.FslID) (int, error) { if current <= 0 { return 0, fmt.Errorf("%w: there is no current check-in", errNoSelection) } d, err := loadCheckinDeck(q, current) if err != nil { return 0, err } files, err := checkinFileCards(q, nil, d) if err != nil { return 0, fmt.Errorf("files of the current check-in: %w", err) } k, ok := SelectPartitionK(files) if !ok { return 0, fmt.Errorf("%w: no k from %d to %d partitions the current check-in's %d files", errNoSelection, MinPartitionK, MaxPartitionK, len(files)) } return k, nil } // loadCheckinDeck parses the check-in manifest at rid. func loadCheckinDeck(q db.Querier, rid libfossil.FslID) (*deck.Deck, error) { data, err := content.Expand(q, rid) if err != nil { return nil, fmt.Errorf("expand check-in %d: %w", rid, err) } d, err := deck.Parse(data) if err != nil { return nil, fmt.Errorf("parse check-in %d: %w", rid, err) } if d.Type != deck.Checkin { return nil, fmt.Errorf("artifact %d is not a check-in", rid) } return d, nil } // SubmanifestDisableNote is zit's note on disabling emission in a // repository that already holds trees. const SubmanifestDisableNote = "note: this repository already holds S-bearing artifacts. Disabling stops new\n" + "tree manifests; it cannot retire the existing ones, and peers below Fossil\n" + "2.29 are still refused every one of them." // DisableSubmanifests turns emission off (§8.1): later commits are flat, and // the pin stays, so re-enabling is not a re-tune. Existing trees cannot be // retired; when the repository holds any it returns SubmanifestDisableNote. func DisableSubmanifests(r *repo.Repo) (note string, err error) { if r == nil { panic("manifest.DisableSubmanifests: r must not be nil") } err = r.WithTx(func(tx *db.Tx) error { if err := writeSubmanifestSetting(tx, SettingEnableSubmanifests, "off"); err != nil { return err } seen, err := SeenSubmanifest(tx) if seen { note = SubmanifestDisableNote } return err }) if err != nil { return "", fmt.Errorf("manifest.DisableSubmanifests: %w", err) } return note, nil } // RetuneSubmanifests is §6.4's manual, operator-initiated re-tune: it pins // k, from 1 to 12, for the commits that follow. History is not rewritten. func RetuneSubmanifests(r *repo.Repo, k int) error { if r == nil { panic("manifest.RetuneSubmanifests: r must not be nil") } if !validPartitionK(k) { return fmt.Errorf("manifest.RetuneSubmanifests: k %d is outside %d..%d", k, MinPartitionK, MaxPartitionK) } return r.WithTx(func(tx *db.Tx) error { return writeSubmanifestSetting(tx, SettingSubmanifestK, strconv.Itoa(k)) }) } // storeCheckinTree stores the canonical tree of files at k, children before // parents (§6.2), marks each new sub-manifest for sync and records the // seen-submanifest indicator (§8.1). It returns the manifest's S run, empty // when the construction is flat. func storeCheckinTree(tx *db.Tx, files []deck.FileCard, k int) ([]deck.SubmanifestCard, error) { tree, err := BuildTree(files, k, func(data []byte) (string, error) { rid, uuid, err := blob.Store(tx, data) if err != nil { return "", fmt.Errorf("store sub-manifest: %w", err) } if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", rid); err != nil { return "", fmt.Errorf("unsent sub-manifest: %w", err) } return uuid, nil }) if err != nil { return nil, err } if len(tree.S) == 0 { return nil, nil } if err := MarkSeenSubmanifest(tx); err != nil { return nil, err } return tree.S, nil } // checkinTree is a commit's tree decision: the settings it read, the k it // partitions at (0 for flat), its files in canonical order and the S run of // the stored tree, empty when the manifest stays flat. type checkinTree struct { settings submanifestSettings k int files []deck.FileCard s []deck.SubmanifestCard } // storeTreeFor makes the tree decision for a check-in of fCards and, when // it yields a tree, stores the tree. func storeTreeFor(tx *db.Tx, opts CheckinOpts, fCards []deck.FileCard) (checkinTree, error) { var ct checkinTree var err error if ct.settings, err = readSubmanifestSettings(tx); err != nil { return ct, fmt.Errorf("sub-manifest settings: %w", err) } if ct.k = emissionK(ct.settings, opts.AvoidSubmanifests); ct.k == 0 { return ct, nil } ct.files = make([]deck.FileCard, len(fCards)) copy(ct.files, fCards) sort.Slice(ct.files, func(i, j int) bool { return deck.Compare(ct.files[i].Name, ct.files[j].Name) < 0 }) for i := 1; i < len(ct.files); i++ { if ct.files[i-1].Name == ct.files[i].Name { return ct, fmt.Errorf("file %q is listed twice", ct.files[i].Name) } } ct.s, err = storeCheckinTree(tx, ct.files, ct.k) return ct, err } // retune is §6.4's automatic re-tune, run once the commit is the current // check-in, over its file set, when it partitioned at a pinned k: while // submanifest-auto-retune is on it re-pins k when the gate clears; // otherwise it only advises. func (ct checkinTree) retune(tx *db.Tx) error { if ct.k == 0 { return nil } target, ok := retuneTarget(partitionCosts(ct.files), ct.k) if !ok { return nil } if !ct.settings.autoRetune { slog.Info("manifest.Checkin: submanifest-k has drifted from the analyzed optimum", "submanifest-k", ct.k, "optimum", target) return nil } return writeSubmanifestSetting(tx, SettingSubmanifestK, strconv.Itoa(target)) }