Artifact f6ccd04d337e79dd4ad11055f975b1a01115ca4dfdd76aee973bd30a92f431ab:
- File
internal/sync/ckin_lock.go
— part of check-in
[e44a484403]
at
2026-10-13 06:34:48
on branch main
— fix(sync): apply ci-lock and ci-unlock for any requester (#400)
sync-protocol-01 Section 9.4 applies both pragmas with no Write test, as fossil's page_xfer does. The leaf sweep before each lock write bounds the lock table. nobody's lock row stores a null login; ci-lock-fail fossilizes LOGIN and names a login-less holder nobody. lock-timeout is read as db_get_int reads it.
Tracked in #392. (user: [email protected], size: 4574) [annotate] [blame] [check-ins using]
package sync import ( "database/sql" "errors" "strings" "time" "github.com/danmestas/go-libfossil/db" ) // DefaultCkinLockTimeout is the default duration after which a ci-lock expires. const DefaultCkinLockTimeout = 60 * time.Second // lockTimeout is the repository's lock-timeout setting in seconds, read as // fossil's db_get_int("lock-timeout", 60) reads it (§9.4): absent gives 60, // and otherwise SQLite's integer conversion applies, so a value that is not // a number reads as 0. A timeout of 0 or less makes every lock stale at the // next ci-lock, which is what fossil does with such a setting. func lockTimeout(d *db.DB) time.Duration { var n int64 err := d.QueryRow("SELECT CAST(value AS INTEGER) FROM config" + " WHERE name='lock-timeout'").Scan(&n) if err != nil { return DefaultCkinLockTimeout } return time.Duration(n) * time.Second } // CkinLockFail reports that another client holds the check-in lock. type CkinLockFail struct { HeldBy string Since time.Time } // ckinLockPrefix names a check-in lock's config row, as fossil's ci-lock // does: ci-lock-HASH, holding JSON {"login":...,"clientid":...}, with the // time the lock was taken in the mtime column (§9.4). A repository served by // both fossil and this server so has one lock table. const ckinLockPrefix = "ci-lock-" func configKey(parentUUID string) string { return ckinLockPrefix + parentUUID } // releaseCkinLocks drops the check-in locks clientID holds, and any lock // whose record is not JSON, as fossil's pragma ci-unlock does (§9.4). func releaseCkinLocks(d *db.DB, clientID string) { d.Exec("DELETE FROM config WHERE name GLOB 'ci-lock-*'"+ " AND (NOT json_valid(value) OR json_extract(value, '$.clientid')=?)", clientID) } // processCkinLock handles a ci-lock pragma request, as fossil's page_xfer // does: stale locks go first; a live lock on parentUUID is kept as it is, // with no refresh, and reported unless clientID holds it; with none, a lock // is written for user and clientID, unacknowledged (§9.4). An empty user is // nobody, whose login fossil stores as JSON null. func processCkinLock(d *db.DB, parentUUID, clientID, user string, timeout time.Duration) *CkinLockFail { if parentUUID == "" { panic("sync.processCkinLock: empty parentUUID") } expireStaleLocks(d, timeout) var login, holder string var since int64 // mtime is cast: the column is declared DATETIME, which some drivers // would otherwise hand back as a time. err := d.QueryRow("SELECT coalesce(json_extract(value, '$.login'), ''),"+ " coalesce(json_extract(value, '$.clientid'), ''), CAST(mtime AS INTEGER)"+ " FROM config"+ " WHERE name=? AND json_valid(value)", configKey(parentUUID)). Scan(&login, &holder, &since) if err == nil { if holder != clientID { return &CkinLockFail{HeldBy: login, Since: time.Unix(since, 0)} } return nil } if !errors.Is(err, sql.ErrNoRows) { // A lock that cannot be read is not overwritten: the pragma has no // error reply, and taking the lock could clobber another client's. return nil } d.Exec("REPLACE INTO config(name, value, mtime)"+ " VALUES(?, json_object('login', nullif(?, ''), 'clientid', ?), ?)", configKey(parentUUID), user, clientID, time.Now().Unix()) return nil } // expireStaleLocks removes the ci-lock rows at least timeout old, or whose // check-in is no longer a leaf. As in fossil, only rows holding JSON are // considered; ci-unlock is what clears the others. func expireStaleLocks(d *db.DB, timeout time.Duration) { cutoff := time.Now().Unix() - int64(timeout.Seconds()) // Locks written under the old libfossil-ci-lock- key match neither this // sweep nor ci-unlock, so nothing else would ever clear them. d.Exec("DELETE FROM config WHERE name GLOB 'libfossil-ci-lock-*'") d.Exec("DELETE FROM config WHERE name GLOB 'ci-lock-*' AND json_valid(value)"+ " AND mtime <= ?", cutoff) rows, err := d.Query("SELECT name FROM config WHERE name GLOB 'ci-lock-*'" + " AND json_valid(value)") if err != nil { return } defer rows.Close() var toDelete []string for rows.Next() { var name string if rows.Scan(&name) != nil { continue } uuid := strings.TrimPrefix(name, ckinLockPrefix) var rid int64 if d.QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid) != nil { toDelete = append(toDelete, name) continue } var dummy int if d.QueryRow("SELECT 1 FROM leaf WHERE rid=?", rid).Scan(&dummy) != nil { toDelete = append(toDelete, name) } } for _, name := range toDelete { d.Exec("DELETE FROM config WHERE name=?", name) } }