feat(notes): operation log, block leases and sync API for collaborative editing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 50s
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>
This commit is contained in:
1 parent
bcc06da3ee
commit
1ea0c78b41
23 files changed
+3266
-287
No files matched your search
@@ -0,0 +1,201 @@
|
||||
// Package collab contains the database-independent core of collaborative note editing: the block tree,
|
||||
// operations with their conflict rules, block leases and change notification. The same rules run on the
|
||||
// server for every incoming operation; clients replay the resulting effects to stay in sync.
|
||||
package collab
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Props are per-block settings that change independently of the text.
|
||||
type Props struct {
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
ConflictOf string `json:"conflictOf,omitempty"` // ID of the block (or "title") this conflict copy belongs to
|
||||
ConflictAuthor string `json:"conflictAuthor,omitempty"` // display name of the author whose text lost
|
||||
}
|
||||
|
||||
type Block struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"` // "" = top level
|
||||
OrderKey string `json:"orderKey"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Props Props `json:"props"`
|
||||
Version int `json:"version"` // content version; props changes do not bump it
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
}
|
||||
|
||||
// Doc is the complete state of one note including deleted blocks (tombstones).
|
||||
type Doc struct {
|
||||
Title string
|
||||
TitleVersion int
|
||||
Blocks map[string]*Block
|
||||
}
|
||||
|
||||
func NewDoc() *Doc {
|
||||
return &Doc{Blocks: make(map[string]*Block)}
|
||||
}
|
||||
|
||||
func (d *Doc) Clone() *Doc {
|
||||
c := &Doc{Title: d.Title, TitleVersion: d.TitleVersion, Blocks: make(map[string]*Block, len(d.Blocks))}
|
||||
for id, b := range d.Blocks {
|
||||
cp := *b
|
||||
c.Blocks[id] = &cp
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func siblingLess(a, b *Block) bool {
|
||||
if a.OrderKey != b.OrderKey {
|
||||
return a.OrderKey < b.OrderKey
|
||||
}
|
||||
return a.ID < b.ID
|
||||
}
|
||||
|
||||
// Children returns the live children of parentID in display order.
|
||||
func (d *Doc) Children(parentID string) []*Block {
|
||||
out := make([]*Block, 0)
|
||||
for _, b := range d.Blocks {
|
||||
if !b.Deleted && b.ParentID == parentID {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return siblingLess(out[i], out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// FlatBlock is a live block with its depth, in document (depth-first) order.
|
||||
type FlatBlock struct {
|
||||
*Block
|
||||
Level int
|
||||
}
|
||||
|
||||
// Flatten returns all live blocks in display order with their nesting level.
|
||||
func (d *Doc) Flatten() []FlatBlock {
|
||||
children := make(map[string][]*Block)
|
||||
for _, b := range d.Blocks {
|
||||
if !b.Deleted {
|
||||
children[b.ParentID] = append(children[b.ParentID], b)
|
||||
}
|
||||
}
|
||||
for _, list := range children {
|
||||
sort.Slice(list, func(i, j int) bool { return siblingLess(list[i], list[j]) })
|
||||
}
|
||||
out := make([]FlatBlock, 0, len(d.Blocks))
|
||||
var walk func(parent string, level int)
|
||||
walk = func(parent string, level int) {
|
||||
for _, b := range children[parent] {
|
||||
out = append(out, FlatBlock{Block: b, Level: level})
|
||||
walk(b.ID, level+1)
|
||||
}
|
||||
}
|
||||
walk("", 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// subtree returns id and all descendants; with liveOnly, deleted blocks are skipped.
|
||||
func (d *Doc) subtree(id string, liveOnly bool) []string {
|
||||
children := make(map[string][]string)
|
||||
for _, b := range d.Blocks {
|
||||
if liveOnly && b.Deleted {
|
||||
continue
|
||||
}
|
||||
children[b.ParentID] = append(children[b.ParentID], b.ID)
|
||||
}
|
||||
out := []string{id}
|
||||
for i := 0; i < len(out); i++ {
|
||||
out = append(out, children[out[i]]...)
|
||||
}
|
||||
sort.Strings(out[1:])
|
||||
return out
|
||||
}
|
||||
|
||||
// liveAncestor walks up from id until it finds a live block; "" means top level.
|
||||
func (d *Doc) liveAncestor(id string) string {
|
||||
seen := map[string]bool{}
|
||||
for id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
b, ok := d.Blocks[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if !b.Deleted {
|
||||
return id
|
||||
}
|
||||
id = b.ParentID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isAncestor reports whether ancestorID is ancestorID itself or above id in the tree.
|
||||
func (d *Doc) isAncestor(ancestorID, id string) bool {
|
||||
seen := map[string]bool{}
|
||||
for id != "" && !seen[id] {
|
||||
if id == ancestorID {
|
||||
return true
|
||||
}
|
||||
seen[id] = true
|
||||
b, ok := d.Blocks[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
id = b.ParentID
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// keyAfter returns a key directly after block b among its live siblings.
|
||||
func (d *Doc) keyAfter(b *Block) string {
|
||||
next := ""
|
||||
for _, s := range d.Children(b.ParentID) {
|
||||
if siblingLess(b, s) {
|
||||
next = s.OrderKey
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == b.OrderKey {
|
||||
// Equal keys are only ordered by ID; append after the whole run instead
|
||||
next = ""
|
||||
for _, s := range d.Children(b.ParentID) {
|
||||
if s.OrderKey > b.OrderKey {
|
||||
next = s.OrderKey
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
k, err := KeyBetween(b.OrderKey, next)
|
||||
if err != nil {
|
||||
k, _ = KeyBetween(b.OrderKey, "")
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// Validate checks the tree invariants: parents exist, live blocks have live parents, no cycles, valid keys.
|
||||
func (d *Doc) Validate() error {
|
||||
for id, b := range d.Blocks {
|
||||
if b.ID != id {
|
||||
return fmt.Errorf("block %s stored under key %s", b.ID, id)
|
||||
}
|
||||
if !ValidOrderKey(b.OrderKey) {
|
||||
return fmt.Errorf("block %s has invalid order key %q", id, b.OrderKey)
|
||||
}
|
||||
if b.ParentID == "" {
|
||||
continue
|
||||
}
|
||||
p, ok := d.Blocks[b.ParentID]
|
||||
if !ok {
|
||||
return fmt.Errorf("block %s has missing parent %s", id, b.ParentID)
|
||||
}
|
||||
if !b.Deleted && p.Deleted {
|
||||
return fmt.Errorf("live block %s has deleted parent %s", id, p.ID)
|
||||
}
|
||||
if d.isAncestor(id, b.ParentID) {
|
||||
return fmt.Errorf("block %s is part of a cycle", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package collab
|
||||
|
||||
import "sync"
|
||||
|
||||
// Notifier wakes up long-poll requests waiting for changes of a document.
|
||||
type Notifier struct {
|
||||
mu sync.Mutex
|
||||
wait map[string]chan struct{}
|
||||
}
|
||||
|
||||
func NewNotifier() *Notifier {
|
||||
return &Notifier{wait: map[string]chan struct{}{}}
|
||||
}
|
||||
|
||||
// Changed returns a channel that is closed on the next Notify for docID.
|
||||
func (n *Notifier) Changed(docID string) <-chan struct{} {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
ch, ok := n.wait[docID]
|
||||
if !ok {
|
||||
ch = make(chan struct{})
|
||||
n.wait[docID] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(docID string) {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if ch, ok := n.wait[docID]; ok {
|
||||
close(ch)
|
||||
delete(n.wait, docID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package collab
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
OpInsert = "insert"
|
||||
OpSetContent = "set_content"
|
||||
OpSetProps = "set_props"
|
||||
OpMove = "move"
|
||||
OpDelete = "delete"
|
||||
OpRestore = "restore"
|
||||
OpSetTitle = "set_title"
|
||||
|
||||
StatusApplied = "applied"
|
||||
StatusConflict = "conflict"
|
||||
StatusRejected = "rejected"
|
||||
|
||||
maxContentLength = 100_000
|
||||
maxTypeLength = 20
|
||||
)
|
||||
|
||||
// PropsPatch changes selected block settings; nil fields stay as they are.
|
||||
type PropsPatch struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
Lang *string `json:"lang,omitempty"`
|
||||
}
|
||||
|
||||
// Op is a change requested by a client. OpID is chosen by the client and makes retries idempotent.
|
||||
type Op struct {
|
||||
OpID string `json:"opId"`
|
||||
Kind string `json:"kind"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
BaseVersion int `json:"baseVersion,omitempty"` // set_content, set_title
|
||||
ParentID *string `json:"parentId,omitempty"` // insert, move ("" = top level)
|
||||
OrderKey string `json:"orderKey,omitempty"` // insert, move
|
||||
Type string `json:"type,omitempty"` // insert
|
||||
Content *string `json:"content,omitempty"` // insert, set_content
|
||||
Props *Props `json:"props,omitempty"` // insert
|
||||
Patch *PropsPatch `json:"patch,omitempty"` // set_props
|
||||
IDs []string `json:"ids,omitempty"` // restore
|
||||
Title *string `json:"title,omitempty"` // set_title
|
||||
}
|
||||
|
||||
// Effect is a normalized, already validated change. The server log stores effects; replaying them in
|
||||
// order on an empty Doc reproduces the server state exactly.
|
||||
type Effect struct {
|
||||
Kind string `json:"kind"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
OrderKey string `json:"orderKey,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Props *Props `json:"props,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
IDs []string `json:"ids,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
TitleVersion int `json:"titleVersion,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
OpID string `json:"opId"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
ConflictBlockID string `json:"conflictBlockId,omitempty"`
|
||||
Seq int64 `json:"seq,omitempty"`
|
||||
}
|
||||
|
||||
// Actor identifies who sends an operation. ClientID is one browser tab / sync engine instance.
|
||||
type Actor struct {
|
||||
UserID string
|
||||
ClientID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// LeaseView answers which client currently holds the lease on a block.
|
||||
type LeaseView interface {
|
||||
HolderOf(blockID string) (clientID string, held bool)
|
||||
}
|
||||
|
||||
// Env provides outside knowledge needed to validate operations.
|
||||
type Env struct {
|
||||
Leases LeaseView // nil = no leases (e.g. server-internal writes)
|
||||
IDTaken func(id string) bool // true if the block ID exists in another document
|
||||
}
|
||||
|
||||
// ApplyEffect mutates the document with one effect. It never fails; effects are validated by Apply.
|
||||
func (d *Doc) ApplyEffect(e Effect) {
|
||||
switch e.Kind {
|
||||
case OpInsert:
|
||||
b := &Block{ID: e.BlockID, ParentID: e.ParentID, OrderKey: e.OrderKey, Type: e.Type, Content: e.Content, Version: e.Version, UpdatedBy: e.By}
|
||||
if e.Props != nil {
|
||||
b.Props = *e.Props
|
||||
}
|
||||
d.Blocks[e.BlockID] = b
|
||||
case OpSetContent:
|
||||
if b, ok := d.Blocks[e.BlockID]; ok {
|
||||
b.Content, b.Version, b.UpdatedBy = e.Content, e.Version, e.By
|
||||
}
|
||||
case OpSetProps:
|
||||
if b, ok := d.Blocks[e.BlockID]; ok {
|
||||
b.Type = e.Type
|
||||
if e.Props != nil {
|
||||
b.Props = *e.Props
|
||||
}
|
||||
}
|
||||
case OpMove:
|
||||
if b, ok := d.Blocks[e.BlockID]; ok {
|
||||
b.ParentID, b.OrderKey = e.ParentID, e.OrderKey
|
||||
}
|
||||
case OpDelete:
|
||||
for _, id := range e.IDs {
|
||||
if b, ok := d.Blocks[id]; ok {
|
||||
b.Deleted = true
|
||||
}
|
||||
}
|
||||
case OpRestore:
|
||||
for _, id := range e.IDs {
|
||||
if b, ok := d.Blocks[id]; ok {
|
||||
b.Deleted = false
|
||||
}
|
||||
}
|
||||
case OpSetTitle:
|
||||
d.Title, d.TitleVersion = e.Title, e.TitleVersion
|
||||
}
|
||||
}
|
||||
|
||||
// ConflictBlockID derives the ID of a conflict copy from the losing operation, so retries never duplicate it.
|
||||
func ConflictBlockID(opID string) string {
|
||||
sum := sha256.Sum256([]byte("conflict:" + opID))
|
||||
return "blk_cf_" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
type applier struct {
|
||||
d *Doc
|
||||
a Actor
|
||||
env Env
|
||||
effects []Effect
|
||||
}
|
||||
|
||||
func (ap *applier) emit(e Effect) {
|
||||
e.By = ap.a.UserID
|
||||
ap.d.ApplyEffect(e)
|
||||
ap.effects = append(ap.effects, e)
|
||||
}
|
||||
|
||||
func (ap *applier) foreignLease(blockID string) bool {
|
||||
if ap.env.Leases == nil {
|
||||
return false
|
||||
}
|
||||
holder, held := ap.env.Leases.HolderOf(blockID)
|
||||
return held && holder != ap.a.ClientID
|
||||
}
|
||||
|
||||
func rejected(op Op, reason string) Result {
|
||||
return Result{OpID: op.OpID, Status: StatusRejected, Reason: reason}
|
||||
}
|
||||
|
||||
// Apply validates op against d, mutates d and returns the produced effects. Rejected operations
|
||||
// produce no effects; conflicting text is kept as a separate conflict block.
|
||||
func Apply(d *Doc, op Op, a Actor, env Env) ([]Effect, Result) {
|
||||
ap := &applier{d: d, a: a, env: env}
|
||||
res := ap.apply(op)
|
||||
res.OpID = op.OpID
|
||||
return ap.effects, res
|
||||
}
|
||||
|
||||
func (ap *applier) apply(op Op) Result {
|
||||
d := ap.d
|
||||
switch op.Kind {
|
||||
case OpInsert:
|
||||
if op.BlockID == "" || len(op.BlockID) > 64 {
|
||||
return rejected(op, "invalid_id")
|
||||
}
|
||||
if _, exists := d.Blocks[op.BlockID]; exists || (ap.env.IDTaken != nil && ap.env.IDTaken(op.BlockID)) {
|
||||
return rejected(op, "exists")
|
||||
}
|
||||
parent := ""
|
||||
if op.ParentID != nil {
|
||||
parent = *op.ParentID
|
||||
}
|
||||
if parent != "" {
|
||||
if _, ok := d.Blocks[parent]; !ok {
|
||||
return rejected(op, "parent_missing")
|
||||
}
|
||||
parent = d.liveAncestor(parent)
|
||||
}
|
||||
if !ValidOrderKey(op.OrderKey) {
|
||||
return rejected(op, "invalid_order_key")
|
||||
}
|
||||
typ := op.Type
|
||||
if typ == "" {
|
||||
typ = "bullet"
|
||||
}
|
||||
if len(typ) > maxTypeLength {
|
||||
return rejected(op, "invalid_type")
|
||||
}
|
||||
content := ""
|
||||
if op.Content != nil {
|
||||
content = *op.Content
|
||||
}
|
||||
if len(content) > maxContentLength {
|
||||
return rejected(op, "too_long")
|
||||
}
|
||||
props := Props{}
|
||||
if op.Props != nil {
|
||||
props = *op.Props
|
||||
}
|
||||
ap.emit(Effect{Kind: OpInsert, BlockID: op.BlockID, ParentID: parent, OrderKey: op.OrderKey, Type: typ, Content: content, Props: &props, Version: 1})
|
||||
return Result{Status: StatusApplied, Version: 1}
|
||||
|
||||
case OpSetContent:
|
||||
b, ok := d.Blocks[op.BlockID]
|
||||
if !ok {
|
||||
return rejected(op, "missing")
|
||||
}
|
||||
if op.Content == nil {
|
||||
return rejected(op, "no_content")
|
||||
}
|
||||
if len(*op.Content) > maxContentLength {
|
||||
return rejected(op, "too_long")
|
||||
}
|
||||
if b.Deleted {
|
||||
// A late edit on a deleted block brings it back instead of losing the text
|
||||
ap.restore([]string{b.ID})
|
||||
}
|
||||
if *op.Content == b.Content {
|
||||
return Result{Status: StatusApplied, Version: b.Version}
|
||||
}
|
||||
if ap.foreignLease(b.ID) || op.BaseVersion != b.Version {
|
||||
return ap.conflictCopy(op, b, *op.Content)
|
||||
}
|
||||
ap.emit(Effect{Kind: OpSetContent, BlockID: b.ID, Content: *op.Content, Version: b.Version + 1})
|
||||
return Result{Status: StatusApplied, Version: b.Version}
|
||||
|
||||
case OpSetProps:
|
||||
b, ok := d.Blocks[op.BlockID]
|
||||
if !ok || b.Deleted {
|
||||
return rejected(op, "missing")
|
||||
}
|
||||
if op.Patch == nil {
|
||||
return rejected(op, "no_patch")
|
||||
}
|
||||
typ, props := b.Type, b.Props
|
||||
if p := op.Patch; p != nil {
|
||||
if p.Type != nil && *p.Type != "" && len(*p.Type) <= maxTypeLength {
|
||||
typ = *p.Type
|
||||
}
|
||||
if p.Checked != nil {
|
||||
props.Checked = *p.Checked
|
||||
}
|
||||
if p.Collapsed != nil {
|
||||
props.Collapsed = *p.Collapsed
|
||||
}
|
||||
if p.Lang != nil {
|
||||
props.Lang = *p.Lang
|
||||
}
|
||||
}
|
||||
if typ != b.Type || props != b.Props {
|
||||
ap.emit(Effect{Kind: OpSetProps, BlockID: b.ID, Type: typ, Props: &props})
|
||||
}
|
||||
return Result{Status: StatusApplied}
|
||||
|
||||
case OpMove:
|
||||
b, ok := d.Blocks[op.BlockID]
|
||||
if !ok || b.Deleted {
|
||||
return rejected(op, "missing")
|
||||
}
|
||||
if ap.foreignLease(b.ID) {
|
||||
return rejected(op, "locked")
|
||||
}
|
||||
parent := ""
|
||||
if op.ParentID != nil {
|
||||
parent = *op.ParentID
|
||||
}
|
||||
if parent != "" {
|
||||
p, ok := d.Blocks[parent]
|
||||
if !ok || p.Deleted {
|
||||
return rejected(op, "parent_missing")
|
||||
}
|
||||
if d.isAncestor(b.ID, parent) {
|
||||
return rejected(op, "cycle")
|
||||
}
|
||||
}
|
||||
if !ValidOrderKey(op.OrderKey) {
|
||||
return rejected(op, "invalid_order_key")
|
||||
}
|
||||
if parent != b.ParentID || op.OrderKey != b.OrderKey {
|
||||
ap.emit(Effect{Kind: OpMove, BlockID: b.ID, ParentID: parent, OrderKey: op.OrderKey})
|
||||
}
|
||||
return Result{Status: StatusApplied}
|
||||
|
||||
case OpDelete:
|
||||
b, ok := d.Blocks[op.BlockID]
|
||||
if !ok {
|
||||
return rejected(op, "missing")
|
||||
}
|
||||
if b.Deleted {
|
||||
return Result{Status: StatusApplied}
|
||||
}
|
||||
ids := d.subtree(b.ID, true)
|
||||
for _, id := range ids {
|
||||
if ap.foreignLease(id) {
|
||||
return rejected(op, "locked")
|
||||
}
|
||||
}
|
||||
ap.emit(Effect{Kind: OpDelete, IDs: ids})
|
||||
return Result{Status: StatusApplied}
|
||||
|
||||
case OpRestore:
|
||||
ids := make([]string, 0, len(op.IDs))
|
||||
for _, id := range op.IDs {
|
||||
if b, ok := d.Blocks[id]; ok && b.Deleted {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
ap.restore(ids)
|
||||
}
|
||||
return Result{Status: StatusApplied}
|
||||
|
||||
case OpSetTitle:
|
||||
if op.Title == nil {
|
||||
return rejected(op, "no_title")
|
||||
}
|
||||
if len(*op.Title) > 1000 {
|
||||
return rejected(op, "too_long")
|
||||
}
|
||||
if *op.Title == d.Title {
|
||||
return Result{Status: StatusApplied, Version: d.TitleVersion}
|
||||
}
|
||||
if op.BaseVersion != d.TitleVersion {
|
||||
return ap.titleConflict(op, *op.Title)
|
||||
}
|
||||
ap.emit(Effect{Kind: OpSetTitle, Title: *op.Title, TitleVersion: d.TitleVersion + 1})
|
||||
return Result{Status: StatusApplied, Version: d.TitleVersion}
|
||||
}
|
||||
return rejected(op, "unknown_kind")
|
||||
}
|
||||
|
||||
// restore brings back deleted blocks; blocks whose parent stays deleted move to the nearest live ancestor.
|
||||
func (ap *applier) restore(ids []string) {
|
||||
d := ap.d
|
||||
set := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = true
|
||||
}
|
||||
sorted := append([]string(nil), ids...)
|
||||
sort.Strings(sorted)
|
||||
ap.emit(Effect{Kind: OpRestore, IDs: sorted})
|
||||
for _, id := range sorted {
|
||||
b := d.Blocks[id]
|
||||
if b.ParentID == "" || set[b.ParentID] {
|
||||
continue
|
||||
}
|
||||
if p, ok := d.Blocks[b.ParentID]; ok && !p.Deleted {
|
||||
continue
|
||||
}
|
||||
ap.emit(Effect{Kind: OpMove, BlockID: b.ID, ParentID: d.liveAncestor(b.ParentID), OrderKey: b.OrderKey})
|
||||
}
|
||||
}
|
||||
|
||||
// conflictCopy keeps the losing text as a new block directly below the contested one.
|
||||
func (ap *applier) conflictCopy(op Op, b *Block, content string) Result {
|
||||
id := ConflictBlockID(op.OpID)
|
||||
if _, exists := ap.d.Blocks[id]; !exists {
|
||||
props := Props{ConflictOf: b.ID, ConflictAuthor: ap.a.Name}
|
||||
ap.emit(Effect{Kind: OpInsert, BlockID: id, ParentID: b.ParentID, OrderKey: ap.d.keyAfter(b), Type: b.Type, Content: content, Props: &props, Version: 1})
|
||||
}
|
||||
return Result{Status: StatusConflict, Version: b.Version, ConflictBlockID: id}
|
||||
}
|
||||
|
||||
// titleConflict keeps a losing title as a conflict block at the very top of the note.
|
||||
func (ap *applier) titleConflict(op Op, title string) Result {
|
||||
id := ConflictBlockID(op.OpID)
|
||||
if _, exists := ap.d.Blocks[id]; !exists {
|
||||
first := ""
|
||||
if roots := ap.d.Children(""); len(roots) > 0 {
|
||||
first = roots[0].OrderKey
|
||||
}
|
||||
key, err := KeyBetween("", first)
|
||||
if err != nil {
|
||||
key, _ = KeyBetween("", "")
|
||||
}
|
||||
props := Props{ConflictOf: "title", ConflictAuthor: ap.a.Name}
|
||||
ap.emit(Effect{Kind: OpInsert, BlockID: id, OrderKey: key, Type: "text", Content: title, Props: &props, Version: 1})
|
||||
}
|
||||
return Result{Status: StatusConflict, Version: ap.d.TitleVersion, ConflictBlockID: id}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package collab
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
anna = Actor{UserID: "u_anna", ClientID: "c_anna", Name: "Anna"}
|
||||
ben = Actor{UserID: "u_ben", ClientID: "c_ben", Name: "Ben"}
|
||||
)
|
||||
|
||||
func str(s string) *string { return &s }
|
||||
|
||||
type fixedLeases map[string]string
|
||||
|
||||
func (f fixedLeases) HolderOf(id string) (string, bool) { c, ok := f[id]; return c, ok }
|
||||
|
||||
// docWith builds a doc via real operations so versions and keys are realistic.
|
||||
func docWith(t *testing.T, blocks ...[3]string) *Doc { // {id, parent, content}
|
||||
t.Helper()
|
||||
d := NewDoc()
|
||||
keys := map[string]string{}
|
||||
for i, b := range blocks {
|
||||
k, _ := KeyBetween(keys[b[1]], "")
|
||||
keys[b[1]] = k
|
||||
_, res := Apply(d, Op{OpID: "seed" + string(rune('a'+i)), Kind: OpInsert, BlockID: b[0], ParentID: str(b[1]), OrderKey: k, Content: str(b[2])}, anna, Env{})
|
||||
if res.Status != StatusApplied {
|
||||
t.Fatalf("seed %v: %+v", b, res)
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustValid(t *testing.T, d *Doc) {
|
||||
t.Helper()
|
||||
if err := d.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetContentAppliesOnMatchingVersion(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Hallo"})
|
||||
_, res := Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Hallo Welt")}, anna, Env{})
|
||||
if res.Status != StatusApplied || d.Blocks["a"].Content != "Hallo Welt" || d.Blocks["a"].Version != 2 {
|
||||
t.Fatalf("got %+v, block %+v", res, d.Blocks["a"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleVersionCreatesConflictCopy(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Start"}, [3]string{"b", "", "Danach"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Von Anna")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Von Ben")}, ben, Env{})
|
||||
|
||||
if res.Status != StatusConflict || res.ConflictBlockID == "" {
|
||||
t.Fatalf("want conflict, got %+v", res)
|
||||
}
|
||||
if d.Blocks["a"].Content != "Von Anna" {
|
||||
t.Fatal("first writer must keep the block")
|
||||
}
|
||||
flat := d.Flatten()
|
||||
if len(flat) != 3 || flat[1].ID != res.ConflictBlockID || flat[1].Content != "Von Ben" || flat[2].ID != "b" {
|
||||
t.Fatalf("conflict copy must sit directly below the block: %+v", flat)
|
||||
}
|
||||
if c := d.Blocks[res.ConflictBlockID].Props; c.ConflictOf != "a" || c.ConflictAuthor != "Ben" {
|
||||
t.Fatalf("conflict props wrong: %+v", c)
|
||||
}
|
||||
mustValid(t, d)
|
||||
}
|
||||
|
||||
func TestForeignLeaseCreatesConflictCopy(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Start"})
|
||||
_, res := Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Ben tippt")}, ben, Env{Leases: fixedLeases{"a": "c_anna"}})
|
||||
if res.Status != StatusConflict || d.Blocks["a"].Content != "Start" {
|
||||
t.Fatalf("lease holder must win: %+v", res)
|
||||
}
|
||||
// The lease holder itself may write
|
||||
_, res = Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Anna tippt")}, anna, Env{Leases: fixedLeases{"a": "c_anna"}})
|
||||
if res.Status != StatusApplied {
|
||||
t.Fatalf("lease holder rejected: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdenticalContentIsNoConflict(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Gleich"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Neu")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("Neu")}, ben, Env{})
|
||||
if res.Status != StatusApplied || len(d.Blocks) != 1 {
|
||||
t.Fatalf("same text must not produce a conflict copy: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditOnDeletedBlockRestoresIt(t *testing.T) {
|
||||
d := docWith(t, [3]string{"p", "", "Eltern"}, [3]string{"c", "p", "Kind"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "p"}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetContent, BlockID: "c", BaseVersion: 1, Content: str("Spät getippt")}, ben, Env{})
|
||||
if res.Status != StatusApplied {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
c := d.Blocks["c"]
|
||||
if c.Deleted || c.Content != "Spät getippt" || c.ParentID != "" {
|
||||
t.Fatalf("block must be restored at the nearest live ancestor: %+v", c)
|
||||
}
|
||||
mustValid(t, d)
|
||||
}
|
||||
|
||||
func TestDeleteIsBlockedByForeignLeaseInSubtree(t *testing.T) {
|
||||
d := docWith(t, [3]string{"p", "", "Ast"}, [3]string{"c", "p", "Unterpunkt"})
|
||||
_, res := Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "p"}, anna, Env{Leases: fixedLeases{"c": "c_ben"}})
|
||||
if res.Status != StatusRejected || res.Reason != "locked" || d.Blocks["p"].Deleted {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
_, res = Apply(d, Op{OpID: "2", Kind: OpDelete, BlockID: "p"}, anna, Env{})
|
||||
if res.Status != StatusApplied || !d.Blocks["c"].Deleted {
|
||||
t.Fatal("delete must tombstone the whole subtree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveRejectsCyclesAndLockedBlocks(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
|
||||
if _, res := Apply(d, Op{OpID: "1", Kind: OpMove, BlockID: "a", ParentID: str("b"), OrderKey: "a0"}, anna, Env{}); res.Reason != "cycle" {
|
||||
t.Fatalf("want cycle, got %+v", res)
|
||||
}
|
||||
if _, res := Apply(d, Op{OpID: "2", Kind: OpMove, BlockID: "b", ParentID: str(""), OrderKey: "a5"}, anna, Env{Leases: fixedLeases{"b": "c_ben"}}); res.Reason != "locked" {
|
||||
t.Fatalf("want locked, got %+v", res)
|
||||
}
|
||||
if _, res := Apply(d, Op{OpID: "3", Kind: OpMove, BlockID: "b", ParentID: str(""), OrderKey: "a5"}, anna, Env{}); res.Status != StatusApplied {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
mustValid(t, d)
|
||||
}
|
||||
|
||||
func TestInsertIntoDeletedParentGoesToLiveAncestor(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpDelete, BlockID: "b"}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpInsert, BlockID: "n", ParentID: str("b"), OrderKey: "a0", Content: str("neu")}, ben, Env{})
|
||||
if res.Status != StatusApplied || d.Blocks["n"].ParentID != "a" {
|
||||
t.Fatalf("got %+v parent=%q", res, d.Blocks["n"].ParentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertRejectsTakenIDs(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"})
|
||||
if _, res := Apply(d, Op{OpID: "1", Kind: OpInsert, BlockID: "a", OrderKey: "a5"}, anna, Env{}); res.Reason != "exists" {
|
||||
t.Fatalf("got %+v", res)
|
||||
}
|
||||
env := Env{IDTaken: func(id string) bool { return id == "other_doc_block" }}
|
||||
if _, res := Apply(d, Op{OpID: "2", Kind: OpInsert, BlockID: "other_doc_block", OrderKey: "a5"}, anna, env); res.Reason != "exists" {
|
||||
t.Fatalf("block IDs of other documents must be rejected: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleConflictBecomesTopBlock(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Erster"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetTitle, BaseVersion: 0, Title: str("Titel Anna")}, anna, Env{})
|
||||
_, res := Apply(d, Op{OpID: "2", Kind: OpSetTitle, BaseVersion: 0, Title: str("Titel Ben")}, ben, Env{})
|
||||
if res.Status != StatusConflict || d.Title != "Titel Anna" {
|
||||
t.Fatalf("got %+v title=%q", res, d.Title)
|
||||
}
|
||||
if flat := d.Flatten(); flat[0].ID != res.ConflictBlockID || flat[0].Props.ConflictOf != "title" {
|
||||
t.Fatalf("title conflict must be the first block: %+v", flat[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPropsDoesNotBumpContentVersion(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "Todo"})
|
||||
Apply(d, Op{OpID: "1", Kind: OpSetProps, BlockID: "a", Patch: &PropsPatch{Checked: new(bool)}}, anna, Env{})
|
||||
yes := true
|
||||
Apply(d, Op{OpID: "2", Kind: OpSetProps, BlockID: "a", Patch: &PropsPatch{Checked: &yes}}, ben, Env{Leases: fixedLeases{"a": "c_anna"}})
|
||||
if !d.Blocks["a"].Props.Checked || d.Blocks["a"].Version != 1 {
|
||||
t.Fatalf("props must apply independently of text and leases: %+v", d.Blocks["a"])
|
||||
}
|
||||
}
|
||||
|
||||
// Replaying the effects of every operation on an empty doc must give exactly the same state.
|
||||
func TestEffectsReplayToSameState(t *testing.T) {
|
||||
d := docWith(t, [3]string{"a", "", "A"}, [3]string{"b", "a", "B"})
|
||||
replay := d.Clone()
|
||||
ops := []struct {
|
||||
op Op
|
||||
a Actor
|
||||
}{
|
||||
{Op{OpID: "1", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A2")}, anna},
|
||||
{Op{OpID: "2", Kind: OpSetContent, BlockID: "a", BaseVersion: 1, Content: str("A3")}, ben},
|
||||
{Op{OpID: "3", Kind: OpDelete, BlockID: "a"}, anna},
|
||||
{Op{OpID: "4", Kind: OpSetContent, BlockID: "b", BaseVersion: 1, Content: str("B2")}, ben},
|
||||
{Op{OpID: "5", Kind: OpSetTitle, Title: str("T")}, anna},
|
||||
}
|
||||
for _, o := range ops {
|
||||
effects, _ := Apply(d, o.op, o.a, Env{})
|
||||
for _, e := range effects {
|
||||
replay.ApplyEffect(e)
|
||||
}
|
||||
}
|
||||
if !docsEqual(d, replay) {
|
||||
t.Fatal("replayed effects differ from server state")
|
||||
}
|
||||
}
|
||||
|
||||
func docsEqual(a, b *Doc) bool {
|
||||
if a.Title != b.Title || a.TitleVersion != b.TitleVersion || len(a.Blocks) != len(b.Blocks) {
|
||||
return false
|
||||
}
|
||||
for id, x := range a.Blocks {
|
||||
y, ok := b.Blocks[id]
|
||||
if !ok || *x != *y {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestLeaseManager(t *testing.T) {
|
||||
now := time.Unix(1000, 0)
|
||||
m := NewLeaseManager(func() time.Time { return now })
|
||||
|
||||
g, den := m.Hold("d", anna, []string{"a", "b"})
|
||||
if len(g) != 2 || len(den) != 0 {
|
||||
t.Fatalf("anna: granted=%v denied=%v", g, den)
|
||||
}
|
||||
if g, den = m.Hold("d", ben, []string{"a", "c"}); len(g) != 1 || den[0] != "a" {
|
||||
t.Fatalf("ben: granted=%v denied=%v", g, den)
|
||||
}
|
||||
if others := m.Others("d", "c_ben"); len(others) != 2 || others[0].Name != "Anna" {
|
||||
t.Fatalf("others: %+v", others)
|
||||
}
|
||||
|
||||
now = now.Add(LeaseTTL + time.Second)
|
||||
if g, _ = m.Hold("d", ben, []string{"a"}); len(g) != 1 {
|
||||
t.Fatal("expired lease must be available")
|
||||
}
|
||||
|
||||
m.ReleaseAllExcept("d", "c_ben", []string{"a"})
|
||||
if _, held := m.View("d").HolderOf("c"); held {
|
||||
t.Fatal("lease not in keep set must be released")
|
||||
}
|
||||
|
||||
m.Touch("d", anna)
|
||||
m.Touch("d", ben)
|
||||
if n, people := m.Sessions("d"); n != 2 || len(people) != 2 {
|
||||
t.Fatalf("sessions: %d %+v", n, people)
|
||||
}
|
||||
m.Leave("d", "c_ben")
|
||||
if _, held := m.View("d").HolderOf("a"); held {
|
||||
t.Fatal("leaving must drop the client's leases")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier(t *testing.T) {
|
||||
n := NewNotifier()
|
||||
ch := n.Changed("d")
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatal("must not fire before Notify")
|
||||
default:
|
||||
}
|
||||
n.Notify("d")
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Notify must wake waiters")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package collab
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Order keys sort siblings lexicographically (ties broken by block ID) and allow inserting between any two
|
||||
// keys without renumbering. Format as in the "fractional-indexing" scheme: an integer part whose first
|
||||
// character encodes its length ('a'..'z' positive, 'A'..'Z' negative) followed by base-62 digits, then an
|
||||
// optional fraction without trailing '0'. Appending stays short ("a0", "a1", …, "az", "b00", …).
|
||||
const keyDigits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
const smallestInteger = "A00000000000000000000000000"
|
||||
|
||||
var errInvalidOrderKey = errors.New("ungültiger Sortierschlüssel")
|
||||
|
||||
func digitIndex(c byte) int {
|
||||
return strings.IndexByte(keyDigits, c)
|
||||
}
|
||||
|
||||
func integerLength(head byte) int {
|
||||
switch {
|
||||
case head >= 'a' && head <= 'z':
|
||||
return int(head-'a') + 2
|
||||
case head >= 'A' && head <= 'Z':
|
||||
return int('Z'-head) + 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func integerPart(key string) (string, bool) {
|
||||
if key == "" {
|
||||
return "", false
|
||||
}
|
||||
n := integerLength(key[0])
|
||||
if n == 0 || n > len(key) {
|
||||
return "", false
|
||||
}
|
||||
return key[:n], true
|
||||
}
|
||||
|
||||
// ValidOrderKey reports whether k is a well-formed key.
|
||||
func ValidOrderKey(k string) bool {
|
||||
if k == "" || len(k) > 256 || k == smallestInteger {
|
||||
return false
|
||||
}
|
||||
ip, ok := integerPart(k)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(k); i++ {
|
||||
if digitIndex(k[i]) < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
frac := k[len(ip):]
|
||||
return frac == "" || frac[len(frac)-1] != '0'
|
||||
}
|
||||
|
||||
func incrementInteger(x string) (string, bool) {
|
||||
head, digs := x[0], []byte(x[1:])
|
||||
carry := true
|
||||
for i := len(digs) - 1; carry && i >= 0; i-- {
|
||||
d := digitIndex(digs[i]) + 1
|
||||
if d == len(keyDigits) {
|
||||
digs[i] = '0'
|
||||
} else {
|
||||
digs[i] = keyDigits[d]
|
||||
carry = false
|
||||
}
|
||||
}
|
||||
if !carry {
|
||||
return string(head) + string(digs), true
|
||||
}
|
||||
if head == 'Z' {
|
||||
return "a0", true
|
||||
}
|
||||
if head == 'z' {
|
||||
return "", false
|
||||
}
|
||||
h := head + 1
|
||||
if h > 'a' {
|
||||
digs = append(digs, '0')
|
||||
} else {
|
||||
digs = digs[:len(digs)-1]
|
||||
}
|
||||
return string(h) + string(digs), true
|
||||
}
|
||||
|
||||
func decrementInteger(x string) (string, bool) {
|
||||
head, digs := x[0], []byte(x[1:])
|
||||
borrow := true
|
||||
for i := len(digs) - 1; borrow && i >= 0; i-- {
|
||||
d := digitIndex(digs[i]) - 1
|
||||
if d == -1 {
|
||||
digs[i] = 'z'
|
||||
} else {
|
||||
digs[i] = keyDigits[d]
|
||||
borrow = false
|
||||
}
|
||||
}
|
||||
if !borrow {
|
||||
return string(head) + string(digs), true
|
||||
}
|
||||
if head == 'a' {
|
||||
return "Z" + string(keyDigits[len(keyDigits)-1]), true
|
||||
}
|
||||
if head == 'A' {
|
||||
return "", false
|
||||
}
|
||||
h := head - 1
|
||||
if h < 'Z' {
|
||||
digs = append(digs, 'z')
|
||||
} else {
|
||||
digs = digs[:len(digs)-1]
|
||||
}
|
||||
return string(h) + string(digs), true
|
||||
}
|
||||
|
||||
// midpoint returns a fraction between a and b (a may be "" = 0, b may be "" = 1).
|
||||
func midpoint(a, b string) string {
|
||||
if b != "" {
|
||||
n := 0
|
||||
for n < len(b) {
|
||||
ca := byte('0')
|
||||
if n < len(a) {
|
||||
ca = a[n]
|
||||
}
|
||||
if ca != b[n] {
|
||||
break
|
||||
}
|
||||
n++
|
||||
}
|
||||
if n > 0 {
|
||||
rest := ""
|
||||
if n < len(a) {
|
||||
rest = a[n:]
|
||||
}
|
||||
return b[:n] + midpoint(rest, b[n:])
|
||||
}
|
||||
}
|
||||
da := 0
|
||||
if a != "" {
|
||||
da = digitIndex(a[0])
|
||||
}
|
||||
db := len(keyDigits)
|
||||
if b != "" {
|
||||
db = digitIndex(b[0])
|
||||
}
|
||||
if db-da > 1 {
|
||||
return string(keyDigits[(da+db)/2])
|
||||
}
|
||||
if b != "" && len(b) > 1 {
|
||||
return b[:1]
|
||||
}
|
||||
rest := ""
|
||||
if len(a) > 1 {
|
||||
rest = a[1:]
|
||||
}
|
||||
return string(keyDigits[da]) + midpoint(rest, "")
|
||||
}
|
||||
|
||||
// KeyBetween returns a key strictly between a and b. An empty a means "before everything",
|
||||
// an empty b means "after everything".
|
||||
func KeyBetween(a, b string) (string, error) {
|
||||
if (a != "" && !ValidOrderKey(a)) || (b != "" && !ValidOrderKey(b)) {
|
||||
return "", errInvalidOrderKey
|
||||
}
|
||||
if a != "" && b != "" && a >= b {
|
||||
return "", errInvalidOrderKey
|
||||
}
|
||||
switch {
|
||||
case a == "" && b == "":
|
||||
return "a0", nil
|
||||
case a == "":
|
||||
ib, _ := integerPart(b)
|
||||
fb := b[len(ib):]
|
||||
if ib == smallestInteger {
|
||||
return ib + midpoint("", fb), nil
|
||||
}
|
||||
if ib < b {
|
||||
return ib, nil
|
||||
}
|
||||
res, ok := decrementInteger(ib)
|
||||
if !ok {
|
||||
return "", errInvalidOrderKey
|
||||
}
|
||||
return res, nil
|
||||
case b == "":
|
||||
ia, _ := integerPart(a)
|
||||
fa := a[len(ia):]
|
||||
if i, ok := incrementInteger(ia); ok {
|
||||
return i, nil
|
||||
}
|
||||
return ia + midpoint(fa, ""), nil
|
||||
}
|
||||
ia, _ := integerPart(a)
|
||||
fa := a[len(ia):]
|
||||
ib, _ := integerPart(b)
|
||||
fb := b[len(ib):]
|
||||
if ia == ib {
|
||||
return ia + midpoint(fa, fb), nil
|
||||
}
|
||||
i, ok := incrementInteger(ia)
|
||||
if !ok {
|
||||
return "", errInvalidOrderKey
|
||||
}
|
||||
if i < b {
|
||||
return i, nil
|
||||
}
|
||||
return ia + midpoint(fa, ""), nil
|
||||
}
|
||||
|
||||
// KeysBetween returns n increasing keys between a and b, spread so that bulk inserts stay short.
|
||||
func KeysBetween(a, b string, n int) ([]string, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if n == 1 {
|
||||
k, err := KeyBetween(a, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{k}, nil
|
||||
}
|
||||
if b == "" {
|
||||
keys := make([]string, 0, n)
|
||||
prev := a
|
||||
for i := 0; i < n; i++ {
|
||||
k, err := KeyBetween(prev, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys = append(keys, k)
|
||||
prev = k
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
if a == "" {
|
||||
keys := make([]string, n)
|
||||
next := b
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
k, err := KeyBetween("", next)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys[i] = k
|
||||
next = k
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
mid := n / 2
|
||||
c, err := KeyBetween(a, b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left, err := KeysBetween(a, c, mid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
right, err := KeysBetween(c, b, n-mid-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(append(left, c), right...), nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package collab
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKeyBetweenBasics(t *testing.T) {
|
||||
cases := [][2]string{{"", ""}, {"", "a0"}, {"a0", ""}, {"a0", "a1"}, {"a0", "a0V"}, {"a1", "a2"}, {"a0V", "a1"}, {"Zz", "a0"}, {"az", ""}, {"", "Zz"}}
|
||||
for _, c := range cases {
|
||||
k, err := KeyBetween(c[0], c[1])
|
||||
if err != nil {
|
||||
t.Fatalf("KeyBetween(%q,%q): %v", c[0], c[1], err)
|
||||
}
|
||||
if !ValidOrderKey(k) {
|
||||
t.Fatalf("KeyBetween(%q,%q) = %q is not a valid key", c[0], c[1], k)
|
||||
}
|
||||
if (c[0] != "" && k <= c[0]) || (c[1] != "" && k >= c[1]) {
|
||||
t.Fatalf("KeyBetween(%q,%q) = %q is not between", c[0], c[1], k)
|
||||
}
|
||||
}
|
||||
if _, err := KeyBetween("a1", "a0"); err == nil {
|
||||
t.Fatal("reversed range must fail")
|
||||
}
|
||||
if _, err := KeyBetween("a0V0", ""); err == nil {
|
||||
t.Fatal("trailing zero key must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// Random inserts between neighbours must always produce a strictly increasing sequence.
|
||||
func TestKeyBetweenRandomInserts(t *testing.T) {
|
||||
r := rand.New(rand.NewSource(42))
|
||||
keys := []string{}
|
||||
for i := 0; i < 3000; i++ {
|
||||
pos := r.Intn(len(keys) + 1)
|
||||
a, b := "", ""
|
||||
if pos > 0 {
|
||||
a = keys[pos-1]
|
||||
}
|
||||
if pos < len(keys) {
|
||||
b = keys[pos]
|
||||
}
|
||||
k, err := KeyBetween(a, b)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", i, err)
|
||||
}
|
||||
keys = append(keys[:pos], append([]string{k}, keys[pos:]...)...)
|
||||
}
|
||||
if !sort.StringsAreSorted(keys) {
|
||||
t.Fatal("keys are not sorted")
|
||||
}
|
||||
for i := 1; i < len(keys); i++ {
|
||||
if keys[i] == keys[i-1] {
|
||||
t.Fatalf("duplicate key %q", keys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Appending at the end, the most common case, must keep keys short.
|
||||
func TestKeyBetweenAppendStaysShort(t *testing.T) {
|
||||
last := ""
|
||||
for i := 0; i < 1000; i++ {
|
||||
k, _ := KeyBetween(last, "")
|
||||
last = k
|
||||
}
|
||||
if len(last) > 4 {
|
||||
t.Fatalf("key after 1000 appends too long: %d chars", len(last))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysBetweenBulk(t *testing.T) {
|
||||
for _, r := range [][2]string{{"", ""}, {"a0", ""}, {"", "a0"}, {"a0", "a1"}} {
|
||||
keys, err := KeysBetween(r[0], r[1], 500)
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %v", r, err)
|
||||
}
|
||||
if len(keys) != 500 || !sort.StringsAreSorted(keys) {
|
||||
t.Fatalf("%v: wrong or unsorted result", r)
|
||||
}
|
||||
for i, k := range keys {
|
||||
if !ValidOrderKey(k) || (i > 0 && keys[i-1] == k) {
|
||||
t.Fatalf("%v: bad key %q", r, k)
|
||||
}
|
||||
if (r[0] != "" && k <= r[0]) || (r[1] != "" && k >= r[1]) {
|
||||
t.Fatalf("%v: key %q out of range", r, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user