···77 "log/slog"
88 "os"
99 "strings"
1010+ "sync"
10111112 securejoin "github.com/cyphar/filepath-securejoin"
1213 _ "github.com/mattn/go-sqlite3"
···1718type DB struct {
1819 db *sql.DB
1920 logger *slog.Logger
2121+2222+ // uidAssignMu serialises GetOrAssignOwnerUID across goroutines so that
2323+ // concurrent callers don't race on the uid_counter read-modify-write.
2424+ uidAssignMu sync.Mutex
2025}
21262227type DBTX interface {
···193198194199 if err := orm.RunMigration(conn, logger, "create-knot-members", func(tx *sql.Tx) error {
195200 _, mErr := tx.ExecContext(ctx, `
196196- create table if not exists knot_members (
197197- id integer primary key autoincrement,
198198- did text not null,
199199- rkey text not null,
200200- subject text not null,
201201- created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
202202- unique (did, rkey)
201201+ create table if not exists knot_members (
202202+ id integer primary key autoincrement,
203203+ did text not null,
204204+ rkey text not null,
205205+ subject text not null,
206206+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
207207+ unique (did, rkey)
208208+ );
209209+ `)
210210+ return mErr
211211+ }); err != nil {
212212+ return nil, err
213213+ }
214214+215215+ if err := orm.RunMigration(conn, logger, "add-isolated-at-to-repo-keys", func(tx *sql.Tx) error {
216216+ _, mErr := tx.ExecContext(ctx, `ALTER TABLE repo_keys ADD COLUMN isolated_at DATETIME`)
217217+ return mErr
218218+ }); err != nil {
219219+ return nil, err
220220+ }
221221+222222+ if err := orm.RunMigration(conn, logger, "add-owner-uid-tables", func(tx *sql.Tx) error {
223223+ _, mErr := tx.ExecContext(ctx, `
224224+ CREATE TABLE IF NOT EXISTS owner_uid_assignments (
225225+ owner_did TEXT PRIMARY KEY,
226226+ uid INTEGER NOT NULL UNIQUE,
227227+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
203228 );
229229+230230+ CREATE TABLE IF NOT EXISTS uid_counter (
231231+ next_uid INTEGER NOT NULL DEFAULT 100000
232232+ );
233233+ `)
234234+ if mErr != nil {
235235+ return mErr
236236+ }
237237+ // Seed the counter only if the table is empty.
238238+ _, mErr = tx.ExecContext(ctx, `
239239+ INSERT INTO uid_counter (next_uid)
240240+ SELECT 100000 WHERE NOT EXISTS (SELECT 1 FROM uid_counter)
204241 `)
205242 return mErr
206243 }); err != nil {
+102
knotserver/db/owner_uid.go
···11+package db
22+33+import (
44+ "database/sql"
55+)
66+77+// GetOrAssignOwnerUID returns the virtual UID for ownerDID, minting a new one
88+// from the uid_counter table if this owner has not been seen before.
99+// UIDs start at 100000 and increment by one per unique owner.
1010+//
1111+// A process-wide mutex serialises concurrent callers so two simultaneous
1212+// requests for distinct DIDs do not race to claim the same counter value.
1313+// The mutex is local to this DB instance and SQLite itself only permits one
1414+// writer at a time, so this does not impede throughput in practice.
1515+func (d *DB) GetOrAssignOwnerUID(ownerDID string) (uint32, error) {
1616+ d.uidAssignMu.Lock()
1717+ defer d.uidAssignMu.Unlock()
1818+1919+ tx, err := d.db.Begin()
2020+ if err != nil {
2121+ return 0, err
2222+ }
2323+ defer tx.Rollback()
2424+2525+ var uid uint32
2626+ err = tx.QueryRow(
2727+ `SELECT uid FROM owner_uid_assignments WHERE owner_did = ?`,
2828+ ownerDID,
2929+ ).Scan(&uid)
3030+ if err == nil {
3131+ return uid, tx.Commit()
3232+ }
3333+ if err != sql.ErrNoRows {
3434+ return 0, err
3535+ }
3636+3737+ if err := tx.QueryRow(`SELECT next_uid FROM uid_counter`).Scan(&uid); err != nil {
3838+ return 0, err
3939+ }
4040+ if _, err := tx.Exec(`UPDATE uid_counter SET next_uid = next_uid + 1`); err != nil {
4141+ return 0, err
4242+ }
4343+ if _, err := tx.Exec(
4444+ `INSERT INTO owner_uid_assignments (owner_did, uid) VALUES (?, ?)`,
4545+ ownerDID, uid,
4646+ ); err != nil {
4747+ return 0, err
4848+ }
4949+5050+ return uid, tx.Commit()
5151+}
5252+5353+// AllReposForMigration returns all (repo_did, owner_did) pairs with a
5454+// non-null owner. Pass force=true to include already-migrated repos.
5555+func (d *DB) AllReposForMigration(force bool) ([]RepoMigrationRow, error) {
5656+ query := `SELECT repo_did, owner_did FROM repo_keys WHERE owner_did IS NOT NULL`
5757+ if !force {
5858+ query += ` AND isolated_at IS NULL`
5959+ }
6060+ rows, err := d.db.Query(query)
6161+ if err != nil {
6262+ return nil, err
6363+ }
6464+ defer rows.Close()
6565+6666+ var result []RepoMigrationRow
6767+ for rows.Next() {
6868+ var r RepoMigrationRow
6969+ if err := rows.Scan(&r.RepoDID, &r.OwnerDID); err != nil {
7070+ return nil, err
7171+ }
7272+ result = append(result, r)
7373+ }
7474+ return result, rows.Err()
7575+}
7676+7777+// CountUnmigratedRepos returns the number of repos that have not yet been
7878+// isolation-migrated.
7979+func (d *DB) CountUnmigratedRepos() (int, error) {
8080+ var n int
8181+ err := d.db.QueryRow(`
8282+ SELECT count(1) FROM repo_keys
8383+ WHERE owner_did IS NOT NULL
8484+ AND isolated_at IS NULL
8585+ `).Scan(&n)
8686+ return n, err
8787+}
8888+8989+// MarkRepoIsolated sets isolated_at to the current time for repoDID.
9090+func (d *DB) MarkRepoIsolated(repoDID string) error {
9191+ _, err := d.db.Exec(
9292+ `UPDATE repo_keys SET isolated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE repo_did = ?`,
9393+ repoDID,
9494+ )
9595+ return err
9696+}
9797+9898+// RepoMigrationRow is a row returned by AllReposForMigration.
9999+type RepoMigrationRow struct {
100100+ RepoDID string
101101+ OwnerDID string
102102+}
+91
knotserver/db/owner_uid_test.go
···11+package db
22+33+import (
44+ "context"
55+ "fmt"
66+ "path/filepath"
77+ "sync"
88+ "testing"
99+)
1010+1111+// TestGetOrAssignOwnerUID_Concurrent verifies that concurrent calls for
1212+// distinct DIDs each get a unique UID, with no duplicates from a race on
1313+// the uid_counter table.
1414+func TestGetOrAssignOwnerUID_Concurrent(t *testing.T) {
1515+ d := newTestDB(t)
1616+1717+ const n = 50
1818+ results := make([]uint32, n)
1919+ var wg sync.WaitGroup
2020+ wg.Add(n)
2121+ for i := 0; i < n; i++ {
2222+ go func(i int) {
2323+ defer wg.Done()
2424+ uid, err := d.GetOrAssignOwnerUID(fmt.Sprintf("did:plc:test-%03d", i))
2525+ if err != nil {
2626+ t.Errorf("GetOrAssignOwnerUID(%d): %v", i, err)
2727+ return
2828+ }
2929+ results[i] = uid
3030+ }(i)
3131+ }
3232+ wg.Wait()
3333+3434+ seen := map[uint32]int{}
3535+ for i, uid := range results {
3636+ if uid == 0 {
3737+ t.Errorf("result[%d] is zero (probably an error above)", i)
3838+ continue
3939+ }
4040+ if prev, ok := seen[uid]; ok {
4141+ t.Errorf("uid %d was returned for both index %d and %d", uid, prev, i)
4242+ }
4343+ seen[uid] = i
4444+ }
4545+4646+ if len(seen) != n {
4747+ t.Errorf("got %d unique UIDs, want %d", len(seen), n)
4848+ }
4949+}
5050+5151+// TestGetOrAssignOwnerUID_Idempotent verifies that repeated calls for the
5252+// same DID return the same UID.
5353+func TestGetOrAssignOwnerUID_Idempotent(t *testing.T) {
5454+ d := newTestDB(t)
5555+ const did = "did:plc:stable"
5656+5757+ first, err := d.GetOrAssignOwnerUID(did)
5858+ if err != nil {
5959+ t.Fatalf("first call: %v", err)
6060+ }
6161+ for i := 0; i < 5; i++ {
6262+ uid, err := d.GetOrAssignOwnerUID(did)
6363+ if err != nil {
6464+ t.Fatalf("repeat call %d: %v", i, err)
6565+ }
6666+ if uid != first {
6767+ t.Errorf("repeat call %d returned %d, want %d", i, uid, first)
6868+ }
6969+ }
7070+}
7171+7272+// TestGetOrAssignOwnerUID_StartsAt100000 verifies the counter seeds correctly.
7373+func TestGetOrAssignOwnerUID_StartsAt100000(t *testing.T) {
7474+ d := newTestDB(t)
7575+ uid, err := d.GetOrAssignOwnerUID("did:plc:first")
7676+ if err != nil {
7777+ t.Fatalf("first call: %v", err)
7878+ }
7979+ if uid != 100000 {
8080+ t.Errorf("first UID = %d, want 100000", uid)
8181+ }
8282+}
8383+8484+func newTestDB(t *testing.T) *DB {
8585+ t.Helper()
8686+ d, err := Setup(context.Background(), filepath.Join(t.TempDir(), "test.db"))
8787+ if err != nil {
8888+ t.Fatalf("db.Setup: %v", err)
8989+ }
9090+ return d
9191+}