oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import "sync"
4
5// hub is this instance's in-process fan-out: one document's accepted
6// update, announced to whoever currently has that document open. It
7// is the "broadcast primitive" rastrillo's own SSE handler
8// (jobs/events.go) says it deliberately did without — that one polls
9// its store once a second because it had nothing to wait on, and
10// this exists precisely so the stream handler here can wait instead
11// of poll.
12//
13// Scope, deliberately: one process. Docs is one instance per team
14// (the design spec's opening line, and its §3 "One SQLite file per
15// instance"), so there is no second process to reach and nothing here
16// needs a broker, a database channel, or a network hop. If Docs ever
17// runs more than one process per team, this type is the seam that has
18// to change, and nothing else does.
19//
20// What travels is a seq, and it is ADVISORY. It says "there is
21// something at least this new"; it never says "here is what changed"
22// and a subscriber must not treat it as a cursor assignment. That is
23// what makes the lossy one-slot channel below correct rather than
24// merely convenient: a subscriber that misses ten notifications still
25// fetches ?since=<its own cursor> and receives all ten updates.
26type hub struct {
27 mu sync.Mutex
28 subs map[int64]map[chan int64]struct{}
29}
30
31func newHub() *hub {
32 return &hub{subs: make(map[int64]map[chan int64]struct{})}
33}
34
35// subscribe registers interest in docID and returns the channel to
36// wait on plus the function that unregisters it. The channel is
37// buffered one deep: it carries "something happened", not a queue of
38// everything that did.
39//
40// The returned stop is idempotent, because its caller (the stream
41// handler) defers it and may also have reason to stop early. Calling
42// it twice, or after the hub has forgotten the document, is a no-op
43// rather than a panic on a closed channel.
44//
45// stop does not close the channel at all, and the honest reason is
46// narrower than "otherwise it would race a broadcast": it would not,
47// today. broadcast holds h.mu from the map lookup through the send
48// and stop takes the same mutex, so a send to a channel this function
49// has already removed cannot happen, and closing under the lock would
50// be safe as the code stands. Not closing is still the better choice,
51// because it stays correct if those sends ever move out from under
52// the lock — a plausible future, since holding a mutex across a send
53// is exactly what a later reader will want to remove. Leaving the
54// channel to the garbage collector costs nothing: nothing holds a
55// reference once it is out of the map.
56//
57// (The first version of this comment asserted the race as fact.
58// AGENTS.md's own phrase for that is a comment-shaped lie, and this
59// file would rather be correct about why it is right than sound more
60// certain.)
61func (h *hub) subscribe(docID int64) (<-chan int64, func()) {
62 ch := make(chan int64, 1)
63
64 h.mu.Lock()
65 if h.subs[docID] == nil {
66 h.subs[docID] = make(map[chan int64]struct{})
67 }
68 h.subs[docID][ch] = struct{}{}
69 h.mu.Unlock()
70
71 var once sync.Once
72 return ch, func() {
73 once.Do(func() {
74 h.mu.Lock()
75 defer h.mu.Unlock()
76 subs, ok := h.subs[docID]
77 if !ok {
78 return
79 }
80 delete(subs, ch)
81 // Drop the inner map with its last subscriber, or this
82 // map accumulates one empty entry per document ever
83 // opened for the life of the process.
84 if len(subs) == 0 {
85 delete(h.subs, docID)
86 }
87 })
88 }
89}
90
91// broadcast tells docID's subscribers that seq exists.
92//
93// It runs on the HTTP request goroutine that just accepted an update
94// (handlers.go's updatesPost), so it must never block: a browser tab
95// on a wedged connection would otherwise hold a stranger's POST open.
96// Hence the default case — a full slot already means "go and fetch",
97// and a second notification would tell the subscriber nothing the
98// first one does not.
99func (h *hub) broadcast(docID, seq int64) {
100 h.mu.Lock()
101 defer h.mu.Unlock()
102
103 for ch := range h.subs[docID] {
104 select {
105 case ch <- seq:
106 default:
107 }
108 }
109}
110
111// documentCount reports how many documents have at least one
112// subscriber. It exists for the tests that assert this map does not
113// grow forever; nothing in the app reads it.
114func (h *hub) documentCount() int {
115 h.mu.Lock()
116 defer h.mu.Unlock()
117 return len(h.subs)
118}
119