Files
MindSift/internal/collab/lease.go
T
sandrodachandClaude Opus 5.5 1ea0c78b41
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
feat(notes): operation log, block leases and sync API for collaborative editing
Phase 1 of the collaboration plan (server side):
- internal/collab: block tree with parent IDs and fractional order keys,
  operations with conflict rules (version check, block leases, conflict
  copies instead of lost text, restore on late edits, cycle checks),
  in-memory leases/sessions and long-poll notifier.
- Storage: per-note lock + transaction, append-only effect log (doc_ops),
  idempotent results per opId (doc_op_results), snapshot, log paging,
  central DocRole. Old flat block data is converted to the tree.
- The existing whole-document save (PUT/PATCH) runs as an adapter through
  the same engine, so the current editor keeps working and writes to the log.
- API: GET /docs/{id}/snapshot, POST /docs/{id}/sync, GET /docs/{id}/changes
  (long poll), POST /docs/{id}/leave; DOC_SEQ poke for WebSocket/SSE rooms.
- Backend accepts editor (write) shares for notes and folders; the forced
  read-only migration is gone. The share dialog still offers read only.
- Tests: conflict matrix, order keys, leases, storage adapter round trip,
  idempotency, API protocol incl. long poll, and a randomized simulation
  (4 clients, lost/duplicated requests and responses, offline phases,
  lease expiry) checking convergence, no lost text and exactly-once results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:47:56 +01:00

198 lines
5.1 KiB
Go

package collab
import (
"sort"
"sync"
"time"
)
const (
LeaseTTL = 20 * time.Second
SessionTimeout = 30 * time.Second
)
type lease struct {
clientID string
userID string
name string
expires time.Time
}
type session struct {
userID string
name string
lastSeen time.Time
}
// LeaseInfo describes a block held by another client, for display ("Anna bearbeitet …").
type LeaseInfo struct {
BlockID string `json:"blockId"`
UserID string `json:"userId"`
Name string `json:"name"`
}
// Presence is one client that synced a document recently.
type Presence struct {
UserID string `json:"userId"`
Name string `json:"name"`
}
// LeaseManager keeps block leases and active sessions in memory. Leases are short-lived; after a server
// restart clients simply request them again with their next sync.
type LeaseManager struct {
mu sync.Mutex
now func() time.Time
leases map[string]map[string]lease // docID -> blockID -> lease
sessions map[string]map[string]session // docID -> clientID -> session
}
func NewLeaseManager(now func() time.Time) *LeaseManager {
if now == nil {
now = time.Now
}
return &LeaseManager{now: now, leases: map[string]map[string]lease{}, sessions: map[string]map[string]session{}}
}
func (m *LeaseManager) docLeases(docID string) map[string]lease {
l := m.leases[docID]
if l == nil {
l = map[string]lease{}
m.leases[docID] = l
}
return l
}
func (m *LeaseManager) prune(docID string, now time.Time) {
for id, l := range m.leases[docID] {
if !now.Before(l.expires) {
delete(m.leases[docID], id)
}
}
for cid, s := range m.sessions[docID] {
if now.Sub(s.lastSeen) > SessionTimeout {
delete(m.sessions[docID], cid)
}
}
}
// Hold acquires or renews leases for a client. A block held by another live client is denied.
func (m *LeaseManager) Hold(docID string, a Actor, blockIDs []string) (granted, denied []string) {
m.mu.Lock()
defer m.mu.Unlock()
now := m.now()
m.prune(docID, now)
l := m.docLeases(docID)
for _, id := range blockIDs {
if cur, ok := l[id]; ok && cur.clientID != a.ClientID {
denied = append(denied, id)
continue
}
l[id] = lease{clientID: a.ClientID, userID: a.UserID, name: a.Name, expires: now.Add(LeaseTTL)}
granted = append(granted, id)
}
return granted, denied
}
// Release gives up leases held by this client.
func (m *LeaseManager) Release(docID, clientID string, blockIDs []string) {
m.mu.Lock()
defer m.mu.Unlock()
l := m.leases[docID]
for _, id := range blockIDs {
if cur, ok := l[id]; ok && cur.clientID == clientID {
delete(l, id)
}
}
}
// ReleaseAllExcept drops every lease of the client in this document that is not in keep.
func (m *LeaseManager) ReleaseAllExcept(docID, clientID string, keep []string) {
m.mu.Lock()
defer m.mu.Unlock()
keepSet := make(map[string]bool, len(keep))
for _, id := range keep {
keepSet[id] = true
}
for id, cur := range m.leases[docID] {
if cur.clientID == clientID && !keepSet[id] {
delete(m.leases[docID], id)
}
}
}
// View returns a snapshot of the current leases of a document for operation validation.
func (m *LeaseManager) View(docID string) LeaseView {
m.mu.Lock()
defer m.mu.Unlock()
now := m.now()
m.prune(docID, now)
snap := make(leaseSnapshot, len(m.leases[docID]))
for id, l := range m.leases[docID] {
snap[id] = l.clientID
}
return snap
}
// Others lists blocks held by clients other than clientID.
func (m *LeaseManager) Others(docID, clientID string) []LeaseInfo {
m.mu.Lock()
defer m.mu.Unlock()
m.prune(docID, m.now())
out := make([]LeaseInfo, 0)
for id, l := range m.leases[docID] {
if l.clientID != clientID {
out = append(out, LeaseInfo{BlockID: id, UserID: l.userID, Name: l.name})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].BlockID < out[j].BlockID })
return out
}
// Touch records that a client synced the document; it counts as an open session for SessionTimeout.
func (m *LeaseManager) Touch(docID string, a Actor) {
m.mu.Lock()
defer m.mu.Unlock()
s := m.sessions[docID]
if s == nil {
s = map[string]session{}
m.sessions[docID] = s
}
s[a.ClientID] = session{userID: a.UserID, name: a.Name, lastSeen: m.now()}
}
// Leave removes a client's session and all its leases (tab closed, app sent to background).
func (m *LeaseManager) Leave(docID, clientID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions[docID], clientID)
for id, l := range m.leases[docID] {
if l.clientID == clientID {
delete(m.leases[docID], id)
}
}
}
// Sessions returns how many clients have the document open and who they are.
func (m *LeaseManager) Sessions(docID string) (int, []Presence) {
m.mu.Lock()
defer m.mu.Unlock()
m.prune(docID, m.now())
seen := map[string]bool{}
people := make([]Presence, 0)
for _, s := range m.sessions[docID] {
if !seen[s.userID] {
seen[s.userID] = true
people = append(people, Presence{UserID: s.userID, Name: s.name})
}
}
sort.Slice(people, func(i, j int) bool { return people[i].UserID < people[j].UserID })
return len(m.sessions[docID]), people
}
type leaseSnapshot map[string]string
func (s leaseSnapshot) HolderOf(blockID string) (string, bool) {
c, ok := s[blockID]
return c, ok
}