Files
pad/internal/collab/observer.go
T
xarmian 3e8bf36f3d feat(collab): count failed op-log appends (TASK-3574) (#2051)
pad_collab_append_failures_total counts each batch of sync frames whose
op-log append failed. The frames are still broadcast, so if the
originating tab closes before its catch-up, a later SyncStep2 can be the
only stored copy of the edit, and the item reads clean. The lead ruled
this counter the trigger for the SyncStep2 coverage check: any nonzero
week where that case could have occurred means build it.

Wired as an optional AppendFailureObserver on the TASK-3501 observer,
like OverflowObserver; the Room calls onAppendFail, set by the manager.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 04:24:47 -04:00

127 lines
4.6 KiB
Go

package collab
import "sync"
// Observer receives counts the room manager can see on the collab Join path
// (TASK-3501). It exists to measure how often an editor tab loses its socket
// and comes back, before deciding IDEA-3494 (persisting unsent offline edits).
//
// WHAT THESE COUNT, AND WHAT THEY DO NOT. A RESUME is a Join that announces a
// cursor (`?since=N`, N > 0): a tab whose editor had been anchored to the
// op-log reconnecting on the same page load. It is a PROXY for "this tab was
// offline", and a biased one:
// - inflated by anything that drops a socket without the user being offline:
// laptop sleep, a network blip, a server restart or deploy;
// - blind to a tab that went offline and was then CLOSED or crashed, which
// never comes back to be counted. That is the case IDEA-3494 is about, and
// only the browser can see it;
// - blind to whether the tab had made edits while away: the relay does not
// parse Yjs, so the catch-up frame looks the same either way.
//
// A force_refresh on a resume is an UPPER BOUND on hand-backs
// (OfflineRecoveryNotice): the client shows one only when it also held unsent
// edits, which the server cannot see.
//
// CALLBACKS RUN ON THE JOIN GOROUTINE, outside every manager lock, but before
// Join returns: a slow callback delays that connection's teardown, and one
// that calls Join itself is not supported. The metrics adapter only
// increments counters.
type Observer interface {
// ResumeJoined counts one Join with a cursor (since > 0), once per Join
// whatever its outcome.
ResumeJoined()
// ResumeForceRefreshed counts a resume refused with force_refresh, by
// reason (one of the ResumeRefresh constants, bounded for a label).
ResumeForceRefreshed(reason string)
}
// Reasons a resume is refused with force_refresh.
const (
// ResumeRefreshPruned: the item's op-log is empty, so the whole log was
// pruned (dormant GC, PruneAndApply, a schema rebuild).
ResumeRefreshPruned = "pruned"
// ResumeRefreshBehindMin: the cursor is below the op-log's MIN, so rows the
// client still needed were pruned.
ResumeRefreshBehindMin = "behind_min"
// ResumeRefreshRestored: the client's seed predates a version restore.
ResumeRefreshRestored = "restored"
)
// CompactedResumeObserver is optional on an Observer (TASK-3531): it counts a
// resume admitted because a compaction snapshot covers its cursor, where
// before compaction it would have been refused with force_refresh. Runs on
// the Join goroutine, like the resume callbacks.
type CompactedResumeObserver interface {
CompactedResumeAdmitted()
}
// OverflowObserver is optional on an Observer (TASK-1273): it counts a peer
// closed because the op bus had to drop an op for it. Separate so an
// Observer that predates it keeps compiling. Unlike the Join callbacks above,
// it runs on the bus's overflow goroutine.
type OverflowObserver interface {
OverflowClosed()
}
// AppendFailureObserver is optional on an Observer (TASK-3574): it counts a
// batch of sync frames whose op-log append failed. The frames are still
// broadcast, so a peer can end up holding the only copy of an edit, which a
// later SyncStep2 may then be the only persisted carrier of; recovery replays
// step2 bytes, but such an item reads clean. A nonzero count in any week where
// that case could have occurred is the ruled trigger for building the
// step2 coverage check (TASK-3574 trail). Runs on the connection's read
// goroutine, under the room's append lock.
type AppendFailureObserver interface {
AppendFailed()
}
// observable is the nil-safe Observer holder the RoomManager embeds.
// Reporting before SetObserver is a no-op.
type observable struct {
obsMu sync.RWMutex
obs Observer
}
// SetObserver attaches an Observer; nil detaches. Safe while running.
func (o *observable) SetObserver(obs Observer) {
o.obsMu.Lock()
o.obs = obs
o.obsMu.Unlock()
}
func (o *observable) observer() Observer {
o.obsMu.RLock()
defer o.obsMu.RUnlock()
return o.obs
}
func (o *observable) reportResumeJoined() {
if obs := o.observer(); obs != nil {
obs.ResumeJoined()
}
}
func (o *observable) reportCompactedResumeAdmitted() {
if obs, ok := o.observer().(CompactedResumeObserver); ok {
obs.CompactedResumeAdmitted()
}
}
func (o *observable) reportOverflowClose() {
if obs, ok := o.observer().(OverflowObserver); ok {
obs.OverflowClosed()
}
}
func (o *observable) reportAppendFailure() {
if obs, ok := o.observer().(AppendFailureObserver); ok {
obs.AppendFailed()
}
}
func (o *observable) reportResumeForceRefreshed(reason string) {
if obs := o.observer(); obs != nil {
obs.ResumeForceRefreshed(reason)
}
}