Files
seaweedfs/weed/pb/filer_pb/filer_pb_event_scan.go
T
Chris Lu 594fc667d5 Cut per-subscriber replay decode and widen replay concurrency (#9917)
* Filter metadata events before unmarshaling them per subscriber

Every subscriber unmarshaled every log entry into a full event just to
run the path filter, and entries carry complete chunk lists, so a fleet
of path-filtered subscribers spends almost all replay CPU materializing
events it then discards. A shallow wire scan now extracts just the
directory, entry names and rename destination into a skeleton event,
feeds the same matcher, and skips the decode for entries the subscriber
cannot match. Any scan surprise (malformed bytes, merged duplicate
message fields) falls back to the full decode, and the unsynced-events
heartbeat keeps firing for skipped entries.

* Raise the legacy replay cap

The cap was sized when every replay pinned a private chunk reader per
source filer. Replays now share decoded chunks, so sixteen needlessly
serializes subscriber catch-up; the expensive part stays bounded by the
cache's load gate.

* Weight concurrent log-chunk loads by size

The flat eight-load gate let eight tiny chunks through as reluctantly as
eight full ones. Charge each load's chunk size against a 128MB in-flight
budget instead: small chunks decode wide open while full-size ones still
serialize enough to cap the transient peak. Oversized weights clamp to
the budget so they can always acquire.

* Propagate heartbeat send failures and reset the skip counter

A failed heartbeat send means the stream is gone, so end the replay
instead of scanning on. A delivered event also resets the skip counter,
keeping the heartbeat cadence relative to the last thing the client
actually received.

* Share the unsynced-events counter across the prefilter and delivery

Two independent counters could starve the heartbeat: alternating drops
reset each side before either reached its threshold. One shared counter
increments on every dropped entry, prefiltered or not, and only an
actual delivery resets it, restoring the original cadence exactly.

* Tighten comments

* Benchmark the subscription match paths

For a thousand-chunk event that the subscriber filters out, the shallow
scan matches in 10us and 9 allocations against 175us and 4031
allocations for the full decode.
2026-06-10 13:08:34 -07:00

143 lines
3.4 KiB
Go

package filer_pb
import (
"google.golang.org/protobuf/encoding/protowire"
)
// ScanMetadataEventSkeleton extracts just the fields the subscription matcher
// reads, without materializing the entries' chunk lists. ok=false means the
// caller must fall back to a full unmarshal.
func ScanMetadataEventSkeleton(data []byte) (skeleton *SubscribeMetadataResponse, ok bool) {
skeleton = &SubscribeMetadataResponse{}
rest := data
for len(rest) > 0 {
num, typ, n := protowire.ConsumeTag(rest)
if n < 0 {
return nil, false
}
rest = rest[n:]
switch {
case num == 1 && typ == protowire.BytesType: // directory
v, m := protowire.ConsumeBytes(rest)
if m < 0 {
return nil, false
}
skeleton.Directory = string(v)
rest = rest[m:]
case num == 2 && typ == protowire.BytesType: // event_notification
if skeleton.EventNotification != nil {
// repeated occurrences of a message field merge; punt to a full decode
return nil, false
}
v, m := protowire.ConsumeBytes(rest)
if m < 0 {
return nil, false
}
notification, scanOk := scanEventNotificationSkeleton(v)
if !scanOk {
return nil, false
}
skeleton.EventNotification = notification
rest = rest[m:]
case num == 3 && typ == protowire.VarintType: // ts_ns
v, m := protowire.ConsumeVarint(rest)
if m < 0 {
return nil, false
}
skeleton.TsNs = int64(v)
rest = rest[m:]
default:
m := protowire.ConsumeFieldValue(num, typ, rest)
if m < 0 {
return nil, false
}
rest = rest[m:]
}
}
return skeleton, true
}
func scanEventNotificationSkeleton(data []byte) (*EventNotification, bool) {
notification := &EventNotification{}
rest := data
for len(rest) > 0 {
num, typ, n := protowire.ConsumeTag(rest)
if n < 0 {
return nil, false
}
rest = rest[n:]
switch {
case num == 1 && typ == protowire.BytesType: // old_entry
if notification.OldEntry != nil {
return nil, false
}
v, m := protowire.ConsumeBytes(rest)
if m < 0 {
return nil, false
}
name, scanOk := scanEntryName(v)
if !scanOk {
return nil, false
}
notification.OldEntry = &Entry{Name: name}
rest = rest[m:]
case num == 2 && typ == protowire.BytesType: // new_entry
if notification.NewEntry != nil {
return nil, false
}
v, m := protowire.ConsumeBytes(rest)
if m < 0 {
return nil, false
}
name, scanOk := scanEntryName(v)
if !scanOk {
return nil, false
}
notification.NewEntry = &Entry{Name: name}
rest = rest[m:]
case num == 4 && typ == protowire.BytesType: // new_parent_path
v, m := protowire.ConsumeBytes(rest)
if m < 0 {
return nil, false
}
notification.NewParentPath = string(v)
rest = rest[m:]
default:
m := protowire.ConsumeFieldValue(num, typ, rest)
if m < 0 {
return nil, false
}
rest = rest[m:]
}
}
return notification, true
}
// scanEntryName walks the whole entry so field order does not matter.
func scanEntryName(data []byte) (string, bool) {
name := ""
rest := data
for len(rest) > 0 {
num, typ, n := protowire.ConsumeTag(rest)
if n < 0 {
return "", false
}
rest = rest[n:]
if num == 1 && typ == protowire.BytesType { // name; last occurrence wins like proto merge
v, m := protowire.ConsumeBytes(rest)
if m < 0 {
return "", false
}
name = string(v)
rest = rest[m:]
continue
}
m := protowire.ConsumeFieldValue(num, typ, rest)
if m < 0 {
return "", false
}
rest = rest[m:]
}
return name, true
}