Skip to content

Relay: use §11.4.3's same-upstream and Prior Object ID Gap next-Object rules - #102

Merged
floatdrop merged 2 commits into
draft-20from
fix/next-object-gap
Sep 26, 2026
Merged

floatdrop merged 2 commits into
draft-20from
fix/next-object-gap

Conversation

@floatdrop

Copy link
Copy Markdown
Owner

Summary

The relay kept an Object on an existing downstream subgroup stream only when its ID was one greater than the previous Object's. A publisher that skips Object IDs therefore cost one reset and reopen per gap. §11.4.3 gives three ways to tell "the next Object", and the relay now uses two more:

  • Same upstream stream: the Object was read next from the same upstream subgroup stream as the previously sent one. Only Objects that failed the subscriber's filters may come between, and Forward State counts as one of those filters ("The Forward parameter is also a type of filter", §5.1.5).
  • Prior Object ID Gap (§12.9): the Object's gap property says the IDs between do not exist. The gap must end exactly at the previous Object. A gap that also covers an Object already received makes the track malformed, so the relay doesn't trust it and reopens.

The run breaks, and the relay resets and reopens as before, when something that should have been sent in between wasn't:

  • Dropped by the relay: the Object overflowed the queue or expired (§12.3).
  • Lost the duplicate check: a copy dropped because another publisher's copy of the same Object won (§2.1).
  • Another publisher: an Object from a different upstream that no gap property covers.

Whether a stream ends with a FIN or a reset is unchanged. A publisher skipping IDs (0, 2, 5) now gets one stream that ends with a FIN. Filtered or paused runs still end with a reset.

Decisions

  • Scope (chosen with you): the same-upstream rule, plus the third rule only through Prior Object ID Gap. The relay does not use its cache or evaluate Range Filters to prove the missing IDs are in other Subgroups or filtered out.
  • Duplicates count as Objects in between. Without this, an upstream's later Object could be placed after an earlier one on a stream that never carried the Object between them. TestFanout_NextObject_DuplicateBetweenBreaksRun covers it.

API

  • New: message.PriorObjectIDGap(raw []byte) (uint64, bool). It is zero-alloc (a test checks this) and reuses the CheckObjectProperties walker.

Tests

  • Red first: these were verified failing on the unpatched relay:
    • TestFanout_NextObject_GapOnOneUpstreamStaysOnStream
    • _FilteredObjectsBetween
    • _AcrossUpstreams (the gap cases, including the review follow-up's covering gap)
    • TestRelay_ForwardStateOmissionKeepsStream
    • TestPriorObjectIDGap
  • Guards: the other-publisher, duplicate-between and relay-drop cases (TestSubgroupWriter_RelayDropBreaksRun: overflow and expiry) keep the rule from being over-applied. They pass before and after.
  • Each part is needed: I removed each piece of the change in turn, including an off-by-one in the gap check, and a test failed every time.
  • Rewritten because they relied on a single-upstream gap forcing a reopen:
    • TestPublishDone_AfterGapReopen now uses a second publisher.
    • The FIRST_OBJECT-on-reopen assertion moved into the white-box drop test.
    • TestFanout_GapInForwardedObjectIDsOpensNewStream was replaced by the new tests.
  • Checks: lint, full suite, -race and make bench-quick (no allocs/op increase) all pass.

Known gaps, not in this PR

Assumption, marked per CLAUDE.md: a gap property covering a received Object doesn't end the track. The relay doesn't trust such a gap and reopens the stream, but it doesn't end the track either. §12.9 makes that a malformed track, and §2.4.2 then requires PUBLISH_DONE MALFORMED_TRACK and no caching.

  • Not new: the relay behaved the same way before this PR.
  • Why it's left out: CheckObjectProperties already skips every §12.9 condition that needs earlier Objects. Detecting them properly needs state for the Objects received in each Group, so it belongs on its own branch rather than as a special case in the fanout.
  • If that's wrong: this case should call endMalformedTrack, and the gap covering the previous Object test should expect PUBLISH_DONE MALFORMED_TRACK instead of a reopen.

Object Properties may be lost when two publishers feed one Subgroup. When two publishers feed one Subgroup, the merged downstream stream reuses the first contributor's SUBGROUP_HEADER. If that header has the Properties bit clear, a later contributor's Object Properties are probably lost on the way out. I suspect this from reading the code and haven't confirmed it with a test. It needs its own branch.

🤖 Generated with Claude Code

floatdrop and others added 2 commits September 26, 2026 11:12
…Object rules

The relay kept an Object on an existing downstream subgroup stream only
when its ID was one greater than the previous Object's, so a publisher
skipping Object IDs cost a reset and a reopen per gap. §11.4.3 lists
three ways to tell "the next Object"; the relay now also uses:

- the Object was read next from the same upstream subgroup stream as the
  previously sent one, with only Objects that failed the subscriber's
  filters between (Forward State is one, §5.1.5);
- its Prior Object ID Gap (§12.9) says the IDs between do not exist.

Knowing from the cache or the Range Filters that the missing IDs are in
other Subgroups or filtered out is not used (scope chosen with the user).

runFanout numbers every Object read from an inbound stream, dedup losers
included, since each is an Object between its neighbours. admit carries
the run across filtered Objects; a queue overflow or a §12.3 expiry drop
breaks it, since the dropped Object was not sent.

New: message.PriorObjectIDGap (zero-alloc, shares the
CheckObjectProperties walker).

Tests: TestFanout_NextObject_GapOnOneUpstreamStaysOnStream,
_FilteredObjectsBetween, _AcrossUpstreams (gap case) and
TestRelay_ForwardStateOmissionKeepsStream were verified red on the
unpatched relay; the other-upstream, duplicate-between and relay-drop
cases guard against over-applying the rule and pass before and after.
Each part of the change was removed in turn and a test failed each time.
Tests that relied on a single-upstream gap forcing a reopen now use a
second publisher (PUBLISH_DONE) or moved into the white-box drop test
(FIRST_OBJECT on a reopened stream).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ious Object

Review follow-up. A gap reaching past the previous Object covers an
Object the relay already received, which §12.9 makes a malformed track,
so it cannot show the Object is the next one (§11.4.3). The Object now
goes on a new stream. Ending the track for it (§2.4.2) is left out: like
the other §12.9 conditions that need earlier Objects, it is not detected.

Also asserts message.PriorObjectIDGap does not allocate.

The "gap covering the previous Object" case was verified red before the
fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@floatdrop
floatdrop merged commit 86892aa into draft-20 Sep 26, 2026
11 checks passed
@floatdrop
floatdrop deleted the fix/next-object-gap branch September 26, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant