oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package live is the fan-out behind a deck being edited: one accepted
2// update announced to every browser that has the deck open, and the set
3// of people currently in it.
4//
5// It is Docs' hub (its internal/docs/hub.go) with presence bolted on,
6// and the two things it carries are carried differently on purpose.
7//
8// A SEQ is advisory — "there is something at least this new". It never
9// says what changed, and a subscriber must not treat it as a cursor
10// assignment: the bytes come from GET /d/{sqid}/updates?since=<its own
11// cursor>, through the one replay path, so a subscriber that missed ten
12// notifications is still exactly right after one fetch.
13//
14// A PRESENCE event is the whole set for that deck, never a delta, for
15// present.Hub's reason: a subscriber that applies one is right rather
16// than approximately right, and there is no "fetch the presence I
17// missed" endpoint to make a skipped one safe.
18//
19// The two together decide the channel's shape. It is buffered eight —
20// a burst of keystroke batches is a real thing — and a subscriber that
21// falls behind that is DROPPED, channel closed, rather than having
22// events skipped. The handler holding it ends the response, the browser
23// reconnects, and its connect events re-read both the current seq and
24// the current set. Skipping would leave a stale cursor look correct.
25//
26// Presence lives here and nowhere else: it is not document state (spec
27// §3.3), so it is never written to the update log, never snapshotted,
28// and is gone when this process is.
29//
30// Scope, like Docs' and like present.Hub's: one process. Slides is one
31// instance per team, so there is no second process to reach; this type
32// is the seam that changes on the day there is.
33package live
34
35import (
36 "sort"
37 "sync"
38 "time"
39)
40
41// TTL is how long one person's presence survives without a heartbeat.
42// The client posts every 20 s and on every change (spec §3.3), so this
43// is two missed beats plus a little slack — long enough that a phone
44// switching networks does not flicker out of the deck, short enough
45// that somebody who closed their laptop stops being shown as present
46// while their colleague waits for a reply.
47const TTL = 45 * time.Second
48
49// sweepInterval is how often the sweeper looks for expired presences.
50// A third of TTL, so somebody who has gone is shown as gone within
51// about fifteen seconds of the deadline rather than at the next event
52// somebody else happens to cause.
53const sweepInterval = 15 * time.Second
54
55// subscriberBuffer is how many events a subscriber may fall behind by
56// before it is dropped. See the package comment for why it is dropped
57// rather than skipped.
58const subscriberBuffer = 8
59
60// Presence is one browser tab in one deck at one moment.
61//
62// Client is the tab's own random string, minted per tab rather than
63// per person: one person with the deck open twice is two cursors, and
64// keying on the user id would make the second tab overwrite the first.
65//
66// Name and Colour are stamped by the SERVER from the users row, never
67// taken from the request body — otherwise anybody signed in could
68// appear in a deck under somebody else's name.
69//
70// At is when the last heartbeat landed, and it is what the sweeper
71// reads. It carries no JSON tag because it is bookkeeping: the browser
72// is told who is here, not when each of them last said so.
73type Presence struct {
74 Client string `json:"client"`
75 Name string `json:"name"`
76 Colour string `json:"colour"`
77 // Slide is the id of the slide this tab is looking at, and Block
78 // the id of the block its caret is in. Both may be empty — a tab
79 // that has just opened the deck is present without being anywhere
80 // in particular yet.
81 Slide string `json:"slide"`
82 Block string `json:"block"`
83 At time.Time `json:"-"`
84}
85
86// PresenceSet is everybody currently in one deck, ordered by Client so
87// two reads of an unchanged set produce byte-identical JSON — which is
88// what lets a client diff cheaply and what makes the tests here able to
89// say "the set did not change" at all.
90type PresenceSet []Presence
91
92// Event is what a subscriber receives. Exactly one of the two halves is
93// meaningful: a nil Presence makes it a seq announcement, a non-nil one
94// makes it a presence announcement whose Seq is not to be read.
95//
96// A pointer rather than a length check, because an EMPTY set is a real
97// event — it is what "the last person left" looks like — and a
98// nil-versus-empty slice distinction is exactly the kind that gets
99// flattened by an innocent refactor.
100type Event struct {
101 Seq int64
102 Presence *PresenceSet
103}
104
105// Hub is the per-process fan-out, keyed by deck sqid.
106//
107// Keyed by sqid rather than by deck id because that is what a URL, a
108// handler and a browser all already hold; nothing here ever needs to
109// touch the database, so there is nothing to look an id up against.
110type Hub struct {
111 mu sync.Mutex
112 subs map[string]map[chan Event]struct{}
113 // people is deck sqid -> client -> that client's last heartbeat.
114 people map[string]map[string]Presence
115 // now is the clock, swappable by this package's own tests so
116 // expiry is asserted by moving time rather than by waiting for it.
117 now func() time.Time
118
119 stop chan struct{}
120 stopOnce sync.Once
121}
122
123// New builds an empty hub and starts its sweeper. One per app (app.go).
124//
125// The sweeper is a goroutine rather than work done lazily on the next
126// event, because the event that matters most is the one nobody causes:
127// the last person in a deck closing their laptop. Lazily, their cursor
128// would sit on somebody else's screen until that person typed.
129//
130// Close stops it. Every caller that builds a Hub must, or a test binary
131// leaks one goroutine per app it builds.
132func New() *Hub {
133 h := &Hub{
134 subs: make(map[string]map[chan Event]struct{}),
135 people: make(map[string]map[string]Presence),
136 now: time.Now,
137 stop: make(chan struct{}),
138 }
139 go h.sweeper(sweepInterval)
140 return h
141}
142
143// Close stops the sweeper. Idempotent, because the app that owns a hub
144// may be closed by both a shutdown path and a test cleanup.
145func (h *Hub) Close() {
146 h.stopOnce.Do(func() { close(h.stop) })
147}
148
149// Subscribe registers interest in one deck and returns the channel to
150// wait on plus the function that unregisters it.
151//
152// The returned cancel is idempotent — the stream handler defers it and
153// may also have reason to stop early — and it never closes the channel.
154// Only the publish path closes one, and only when it is dropping the
155// subscriber, so "the channel is closed" carries exactly one meaning
156// for a reader: you fell behind, reconnect. A cancel that closed too
157// would make that signal ambiguous at the one moment it matters.
158func (h *Hub) Subscribe(deck string) (<-chan Event, func()) {
159 ch := make(chan Event, subscriberBuffer)
160
161 h.mu.Lock()
162 if h.subs[deck] == nil {
163 h.subs[deck] = make(map[chan Event]struct{})
164 }
165 h.subs[deck][ch] = struct{}{}
166 h.mu.Unlock()
167
168 var once sync.Once
169 return ch, func() {
170 once.Do(func() {
171 h.mu.Lock()
172 defer h.mu.Unlock()
173 h.removeLocked(deck, ch)
174 })
175 }
176}
177
178// PublishSeq tells everyone watching deck that there is content through
179// seq. It runs on whichever request goroutine just committed an update,
180// so it never blocks.
181func (h *Hub) PublishSeq(deck string, seq int64) {
182 h.mu.Lock()
183 defer h.mu.Unlock()
184 h.publishLocked(deck, Event{Seq: seq})
185}
186
187// PublishPresence records one heartbeat and announces the deck's whole
188// set. A zero At is stamped here rather than refused, so a caller that
189// only has {client, slide, block} to hand cannot accidentally publish a
190// presence that the sweeper reads as forty-five seconds stale.
191func (h *Hub) PublishPresence(deck string, p Presence) {
192 h.mu.Lock()
193 defer h.mu.Unlock()
194
195 if p.At.IsZero() {
196 p.At = h.now()
197 }
198 if h.people[deck] == nil {
199 h.people[deck] = make(map[string]Presence)
200 }
201 h.people[deck][p.Client] = p
202
203 set := h.setLocked(deck)
204 h.publishLocked(deck, Event{Presence: &set})
205}
206
207// Snapshot is the deck's current set, for the stream's connect event —
208// so a tab that opens after everybody else already arrived sees them,
209// rather than waiting for each of them to move.
210//
211// Expired entries are filtered here as well as swept, because a read
212// must never be able to report somebody as present past their deadline
213// just because the sweeper's next tick has not come round yet.
214func (h *Hub) Snapshot(deck string) PresenceSet {
215 h.mu.Lock()
216 defer h.mu.Unlock()
217 return h.setLocked(deck)
218}
219
220// sweeper is the goroutine New starts.
221func (h *Hub) sweeper(every time.Duration) {
222 t := time.NewTicker(every)
223 defer t.Stop()
224 for {
225 select {
226 case <-h.stop:
227 return
228 case <-t.C:
229 h.sweep()
230 }
231 }
232}
233
234// sweep drops every presence past TTL and republishes the set for each
235// deck that actually changed — and only those, because an unchanged
236// deck republished every fifteen seconds would be a heartbeat pretending
237// to be an event, waking every open tab for nothing.
238func (h *Hub) sweep() {
239 h.mu.Lock()
240 defer h.mu.Unlock()
241
242 cut := h.now().Add(-TTL)
243 for deck, set := range h.people {
244 changed := false
245 for client, p := range set {
246 if p.At.Before(cut) {
247 delete(set, client)
248 changed = true
249 }
250 }
251 if len(set) == 0 {
252 // Drop the inner map with its last member, or this map
253 // keeps one empty entry per deck ever opened for the life
254 // of the process.
255 delete(h.people, deck)
256 }
257 if !changed {
258 continue
259 }
260 announced := h.setLocked(deck)
261 h.publishLocked(deck, Event{Presence: &announced})
262 }
263}
264
265// setLocked builds deck's set: a sorted copy with expired entries left
266// out. Callers hold h.mu.
267func (h *Hub) setLocked(deck string) PresenceSet {
268 cut := h.now().Add(-TTL)
269 set := make(PresenceSet, 0, len(h.people[deck]))
270 for _, p := range h.people[deck] {
271 if p.At.Before(cut) {
272 continue
273 }
274 set = append(set, p)
275 }
276 sort.Slice(set, func(i, j int) bool { return set[i].Client < set[j].Client })
277 return set
278}
279
280// publishLocked sends e to everyone watching deck. Callers hold h.mu,
281// which is what makes the close below safe: no send can race it.
282func (h *Hub) publishLocked(deck string, e Event) {
283 for ch := range h.subs[deck] {
284 select {
285 case ch <- e:
286 default:
287 h.removeLocked(deck, ch)
288 close(ch)
289 }
290 }
291}
292
293// removeLocked takes ch out of deck's set and drops the set with its
294// last member. Callers hold h.mu.
295func (h *Hub) removeLocked(deck string, ch chan Event) {
296 subs, ok := h.subs[deck]
297 if !ok {
298 return
299 }
300 delete(subs, ch)
301 if len(subs) == 0 {
302 delete(h.subs, deck)
303 }
304}
305
306// deckCount and presenceDecks report how many decks the two maps hold.
307// They exist for the tests that assert neither grows forever; nothing
308// in the app reads them.
309func (h *Hub) deckCount() int {
310 h.mu.Lock()
311 defer h.mu.Unlock()
312 return len(h.subs)
313}
314
315func (h *Hub) presenceDecks() int {
316 h.mu.Lock()
317 defer h.mu.Unlock()
318 return len(h.people)
319}
320