Skip to content

Commit d9e8337

Browse files
committed
ci / vfs / doc: inconsistency fixes
1 parent 46c8ce1 commit d9e8337

8 files changed

Lines changed: 130 additions & 24 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,13 @@ jobs:
140140
CGO_ENABLED: '0'
141141
run: |
142142
go build ./
143-
go build ./gorm/...
144143
go build ./fts/...
145144
go build ./vfs/...
146145
go build ./vec/... || echo "vec not supported on ${GOOS}/${GOARCH} — skipping"
146+
# gorm is a separate module — cross-build its dialector from its own
147+
# dir. Build the package (`./`), not `./...`: gorm/examples/vec imports
148+
# gosqlite.org/vec, which isn't transpiled on every target.
149+
(cd gorm && go build ./)
147150
148151
# Run the SQL conformance suite under tests/sql/. This is our methodical
149152
# feature-by-feature proof that every documented SQLite SQL surface the

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ Plain `database/sql` works too: `sql.Open("sqlite", "file:app.db")`. The full se
141141

142142
Full per-package recipes, the `_*` DSN-flag table, and build-tag mapping: **[Migrating](docs/guides/migrating.md)**, **[DSN flags](docs/reference/dsn-flags.md)**, **[Build tags](docs/reference/build-tags.md)**. Runnable: [`examples/migrating/`](examples/migrating/).
143143

144+
The drop-in claim is **CI-enforced**: every push runs `gorm.io/gorm`'s full integration suite, plus vendored subsets of `mattn/go-sqlite3`'s and `modernc.org/sqlite`'s own test suites, against this module (with an xorm-compatibility lane alongside) — so compatibility is checked by tests upstream wrote, not by tests we wrote to flatter ourselves. Recipes and divergence tables: [`dev/upstream/`](dev/upstream/).
145+
144146
## Why CGo-free
145147

146148
Because SQLite is transpiled to Go (via `modernc.org/sqlite`) rather than C-bound, you get: builds in `golang:alpine` / distroless with no `apk add`; `GOOS=… GOARCH=… go build` cross-compilation that just works; clean `go test -race`; reproducible builds with no vendored amalgamation; and CI on providers that disallow CGo. The cost is a constant-factor gap on hot UDF/per-row paths — invisible for most applications. More in [Getting started](docs/getting-started.md#why-cgo-free) and [Performance](docs/reference/performance.md).

dev/upstream/gorm.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,17 @@ ergonomics every user needs.
122122
This is what allowed the suite to flip from 1 fail → 0 fail on the
123123
second local run.
124124

125-
## Why we run this manually rather than in CI today
126-
127-
The setup churns ~20 indirect modules (postgres, mysql, sqlserver, mssql,
128-
gaussdb drivers — even though we only exercise SQLite, the gorm/tests
129-
module brings them all in). The CI matrix would slow down by 60–90s per
130-
job. We may add a single-job gorm-upstream lane later; for now, treat
131-
this matrix as a periodic check, run on dependency bumps and before
132-
release tags.
125+
## How this runs in CI
126+
127+
This is a dedicated `gorm-upstream` job in `.github/workflows/ci.yml`,
128+
on every push and pull request to `main`. It's a separate job on
129+
purpose: the `gorm/tests` module pulls in ~20 indirect drivers
130+
(postgres, mysql, sqlserver, mssql, gaussdb) even though we only
131+
exercise SQLite, which adds 60–90s — isolating it keeps the main test
132+
matrix fast while still gating merges on the suite. The job pins
133+
`GORM_VERSION`, wires the shim above, runs the full `gorm/tests`, fails
134+
on any `--- FAIL`, and also fails if the `--- PASS` count regresses
135+
below its expected floor.
133136

134137
When SQLite or gorm bumps, the steps to repeat:
135138

docs/guides/custom-vfs.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ db.Close()
1919
vfs.Unregister("myvfs") // after every db against it is closed
2020
```
2121

22-
Implement [`vfs.VFS`](https://pkg.go.dev/gosqlite.org/vfs#VFS) (`Open`/`Delete`/`Access`/`FullPathname`) and [`vfs.File`](https://pkg.go.dev/gosqlite.org/vfs#File) (`ReadAt`/`WriteAt`/`Truncate`/`Sync`/`Size`/locking/`Close`). Embed `vfs.NoLock` to satisfy the advisory-lock trio with accept-everything semantics (correct for single-process backends). Return a `vfs.VFSError` from any method to surface a specific `SQLITE_*` result code; a plain error becomes `SQLITE_IOERR`.
22+
Implement [`vfs.VFS`](https://pkg.go.dev/gosqlite.org/vfs#VFS) (`Open`/`Delete`/`Access`/`FullPathname`) and [`vfs.File`](https://pkg.go.dev/gosqlite.org/vfs#File) (`ReadAt`/`WriteAt`/`Truncate`/`Sync`/`Size`/locking/`Close`). Embed `vfs.NoLock` to satisfy the advisory-lock trio with accept-everything semantics correct only for **single-connection** access (multiple connections in WAL mode need real locking; see [WAL](#wal--the-shmfile-capability)). Return a `vfs.VFSError` from any method to surface a specific `SQLITE_*` result code; a plain error becomes `SQLITE_IOERR`.
2323

2424
A complete ~80-line in-memory backend is at [`examples/features/vfs/custom/`](../../examples/features/vfs/custom/main.go).
2525

2626
## WAL — the ShmFile capability
2727

28-
A custom VFS runs in rollback-journal mode by default. To unlock WAL, have your `File` also implement `vfs.ShmFile` — a single `ShmGroup() string` method declaring which open files share a WAL index. The dispatcher owns the shared memory and the 8-slot WAL lock table, so you never touch unsafe memory or the lock protocol. WAL coordination is in-process (it backs multiple `database/sql` connections to one Go-managed database within a process, not cross-process WAL over a real disk).
28+
A custom VFS runs in rollback-journal mode by default. To unlock WAL, have your `File` also implement `vfs.ShmFile` — a single `ShmGroup() string` method declaring which open files share a WAL index. The dispatcher owns the shared memory and the 8-slot WAL lock table, so you never touch unsafe memory or the shared-memory lock protocol. WAL coordination is in-process (it backs multiple `database/sql` connections to one Go-managed database within a process, not cross-process WAL over a real disk).
29+
30+
> **Multi-connection WAL needs real db-file locking — do not embed `vfs.NoLock`.** The dispatcher arbitrates the WAL *shared-memory* locks, but SQLite still gates destructive operations — notably the checkpoint it runs when a connection closes, which resets the `-wal` — on first acquiring an EXCLUSIVE *db-file* lock. `vfs.NoLock` grants that EXCLUSIVE even while other connections are active, so the close-checkpoint can reset the WAL under a concurrent writer and corrupt the database. Implement real `Lock` / `Unlock` / `CheckReservedLock` on the main db file: many connections may share `LockShared`; `LockExclusive` must fail while any other connection holds `LockShared`. The reference `File` in the `vfs` package tests is a complete in-process example.
2931
3032
## Instrumentation — Wrap
3133

justfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ cross-build:
161161
export GOOS=$(echo "$triple" | cut -d/ -f1); \
162162
export GOARCH=$(echo "$triple" | cut -d/ -f2); \
163163
printf " %-18s " "$triple"; \
164-
go build ./ ./gorm/... ./fts/... ./vfs/... 2>/dev/null && \
164+
go build ./ ./fts/... ./vfs/... 2>/dev/null && \
165+
(cd gorm && go build ./ 2>/dev/null) && \
165166
(go build ./vec/... 2>/dev/null || echo -n "(vec skipped) ") && \
166167
echo "ok" || echo "FAILED"; \
167168
done

skills/custom-vfs/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ type File interface {
3434
}
3535
```
3636

37-
- **Embed `vfs.NoLock`** in your File to get the lock trio for free (correct for single-process backends).
37+
- **Embed `vfs.NoLock`** in your File to get the lock trio for free correct only for **single-connection** access (see the WAL caveat below; multi-connection WAL needs real locking).
3838
- **Errors:** return `&vfs.VFSError{Code: sqlite3.SQLITE_READONLY}` (or any `SQLITE_*`) for a specific code; a plain error becomes `SQLITE_IOERR`. A short read past EOF returns `io.EOF` (dispatcher zero-fills + reports SHORT_READ).
3939
- **Buffers are copied at the boundary** — a `File.ReadAt` is handed a fresh slice; don't alias it past the call.
4040

4141
## WAL
4242

43-
Default is rollback-journal. For WAL, also implement `vfs.ShmFile` (one method, `ShmGroup() string`, declaring which files share a WAL index). The dispatcher owns the shared memory + lock table. In-process only.
43+
Default is rollback-journal. For WAL, also implement `vfs.ShmFile` (one method, `ShmGroup() string`, declaring which files share a WAL index). The dispatcher owns the shared memory + WAL lock table. In-process only.
44+
45+
> **Multi-connection WAL needs real db-file locking — do NOT embed `vfs.NoLock`.** The dispatcher arbitrates the WAL *shared-memory* locks, but SQLite still gates destructive operations — notably the checkpoint it runs when a connection closes, which resets the `-wal` — on first acquiring an EXCLUSIVE *db-file* lock. `NoLock` hands out that EXCLUSIVE even while other connections are active, so the close-checkpoint can reset the WAL under a concurrent writer and corrupt the database. Implement real `Lock` / `Unlock` / `CheckReservedLock` on the main db file: many holders may share `LockShared`; `LockExclusive` must fail while any other connection holds `LockShared`. The reference `File` in the `vfs` package tests shows the full pattern.
4446
4547
## Instrumentation
4648

vfs/interface.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ type File interface {
8080
Size() (int64, error)
8181

8282
// Lock raises the advisory lock to at least level; Unlock lowers it.
83-
// A single-process backend can accept every transition (see NoLock).
83+
// A single-connection backend can accept every transition (see NoLock);
84+
// multiple connections in WAL mode require real arbitration here.
8485
Lock(level LockLevel) error
8586
Unlock(level LockLevel) error
8687
// CheckReservedLock reports whether some connection (possibly
@@ -203,8 +204,19 @@ const (
203204
)
204205

205206
// NoLock is an embeddable helper supplying accept-everything advisory
206-
// locking — the correct behaviour for any single-process or
207-
// exclusive-access File. Embed it to drop three methods of boilerplate:
207+
// locking. It is correct only when a database is reached by ONE connection at
208+
// a time — single-connection use, or exclusive-locking mode — where there is
209+
// nothing to arbitrate and every lock can be granted.
210+
//
211+
// Do NOT embed NoLock when several connections share a database in WAL mode.
212+
// SQLite gates destructive operations — notably the checkpoint it runs when a
213+
// connection closes, which resets the -wal file — on first acquiring an
214+
// EXCLUSIVE db-file lock; NoLock hands out that EXCLUSIVE even while other
215+
// connections are active, so the reset can corrupt the database under a
216+
// concurrent writer. A multi-connection backend must implement real
217+
// Lock/Unlock/CheckReservedLock (many holders may share SHARED; EXCLUSIVE must
218+
// fail while any other connection holds SHARED) — see the reference File in the
219+
// vfs package tests.
208220
//
209221
// type myFile struct {
210222
// vfs.NoLock

vfs/interface_test.go

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@ type refMemVFS struct {
2828
type refMemData struct {
2929
mu sync.Mutex
3030
data []byte
31+
32+
// In-process advisory lock state, shared by every connection that opens
33+
// this name. NoLock is fine for a single connection, but multi-connection
34+
// WAL needs a real lock: SQLite gates its destructive checkpoint-on-close
35+
// (which resets the -wal) on acquiring an EXCLUSIVE db-file lock, so that
36+
// EXCLUSIVE must fail while other connections hold SHARED.
37+
lmu sync.Mutex
38+
nShared int // connections holding >= SHARED
39+
writer *refMemFile // the one connection holding RESERVED..EXCLUSIVE (nil if none)
3140
}
3241

3342
func newRefMemVFS() *refMemVFS { return &refMemVFS{files: map[string]*refMemData{}} }
@@ -70,10 +79,80 @@ func (v *refMemVFS) Access(name string, _ vfs.AccessOp) (bool, error) {
7079
func (v *refMemVFS) FullPathname(name string) (string, error) { return name, nil }
7180

7281
type refMemFile struct {
73-
vfs.NoLock // single-process backend: accept every advisory lock
74-
v *refMemVFS
75-
name string
76-
d *refMemData
82+
v *refMemVFS
83+
name string
84+
d *refMemData
85+
lock vfs.LockLevel // this connection's current advisory lock level
86+
}
87+
88+
var errLockBusy = &vfs.VFSError{Code: sqlite3.SQLITE_BUSY}
89+
90+
// Lock raises this connection's advisory lock toward level, arbitrating in
91+
// process against the other connections on the same name. Multiple holders may
92+
// share SHARED; only one may hold RESERVED..EXCLUSIVE; EXCLUSIVE additionally
93+
// requires that no other connection holds SHARED.
94+
func (f *refMemFile) Lock(level vfs.LockLevel) error {
95+
if level <= f.lock {
96+
return nil
97+
}
98+
d := f.d
99+
d.lmu.Lock()
100+
defer d.lmu.Unlock()
101+
switch level {
102+
case vfs.LockShared:
103+
if d.writer != nil && d.writer.lock >= vfs.LockPending {
104+
return errLockBusy // a PENDING/EXCLUSIVE writer blocks new readers
105+
}
106+
d.nShared++
107+
f.lock = vfs.LockShared
108+
case vfs.LockReserved:
109+
if d.writer != nil && d.writer != f {
110+
return errLockBusy
111+
}
112+
d.writer = f
113+
f.lock = vfs.LockReserved
114+
case vfs.LockPending, vfs.LockExclusive:
115+
if d.writer != nil && d.writer != f {
116+
return errLockBusy
117+
}
118+
d.writer = f
119+
self := 0
120+
if f.lock >= vfs.LockShared {
121+
self = 1
122+
}
123+
if d.nShared > self {
124+
f.lock = vfs.LockPending // hold the intent so no new SHARED is granted
125+
return errLockBusy
126+
}
127+
f.lock = level
128+
}
129+
return nil
130+
}
131+
132+
// Unlock lowers this connection's advisory lock toward level.
133+
func (f *refMemFile) Unlock(level vfs.LockLevel) error {
134+
if level >= f.lock {
135+
return nil
136+
}
137+
d := f.d
138+
d.lmu.Lock()
139+
defer d.lmu.Unlock()
140+
if f.lock >= vfs.LockReserved && level < vfs.LockReserved && d.writer == f {
141+
d.writer = nil
142+
}
143+
if f.lock >= vfs.LockShared && level < vfs.LockShared {
144+
d.nShared--
145+
}
146+
f.lock = level
147+
return nil
148+
}
149+
150+
// CheckReservedLock reports whether some connection holds RESERVED or higher.
151+
func (f *refMemFile) CheckReservedLock() (bool, error) {
152+
d := f.d
153+
d.lmu.Lock()
154+
defer d.lmu.Unlock()
155+
return d.writer != nil && d.writer.lock >= vfs.LockReserved, nil
77156
}
78157

79158
func (f *refMemFile) ReadAt(p []byte, off int64) (int, error) {
@@ -127,7 +206,10 @@ func (f *refMemFile) Size() (int64, error) {
127206

128207
func (f *refMemFile) SectorSize() int { return 512 }
129208
func (f *refMemFile) DeviceCharacteristics() vfs.DeviceFlags { return 0 }
130-
func (f *refMemFile) Close() error { return nil }
209+
func (f *refMemFile) Close() error {
210+
_ = f.Unlock(vfs.LockNone)
211+
return nil
212+
}
131213

132214
// compile-time proof the reference types satisfy the public interfaces.
133215
var (
@@ -157,8 +239,7 @@ func TestUserVFS_ReadWrite(t *testing.T) {
157239
if err != nil {
158240
t.Fatal(err)
159241
}
160-
// Single connection: NoLock means no cross-conn arbitration, and the
161-
// shared in-memory backing must not see concurrent writers.
242+
// Single connection keeps this rollback-journal smoke test simple.
162243
db.SetMaxOpenConns(1)
163244

164245
t.Cleanup(func() {

0 commit comments

Comments
 (0)