mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
3e8bf36f3d
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
127 lines
4.6 KiB
Go
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)
|
|
}
|
|
}
|