MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
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>
198 lines
5.1 KiB
Go
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
|
|
}
|