Skip to content

ateapi: apply a replica's own worker writes to its cache at commit - #1583

Merged
Julian Gutierrez Oschmann (juli4n) merged 3 commits into
agent-substrate:mainfrom
shrutiyam-glitch:worker-local-publish
Sep 15, 2026
Merged

Julian Gutierrez Oschmann (juli4n) merged 3 commits into
agent-substrate:mainfrom
shrutiyam-glitch:worker-local-publish

Conversation

@shrutiyam-glitch

@shrutiyam-glitch Shrutiya Mohan (shrutiyam-glitch) commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1370

Summary
atepg keeps a registry of the live WatchWorkers channels and publishes each committed worker event onto them at commit, about one poll interval ahead of the outbox row carrying the same event. Replicas see their own writes sooner without a second notification API.

Why
Replicas previously learned of their own writes only from the outbox watch, ~50ms later. In that window the scheduler re-picks a worker it just assigned and retries on the precondition check, and capacity a replica just freed stays invisible to its own placement. Cross-replica propagation is unchanged.

Key changes

  • WatchWorkers enrolls its channel; the poller deregisters before closing it, which is what makes a send on a closed channel impossible.
  • writeAndAppendEvent publishes only after tx.Commit returns.
  • The commit-time copy is decoded from the same payload the outbox stores, so both deliveries are identical, including unknown-field pruning.
  • Sends are non-blocking and never advance the poll cursor, so a full buffer costs a watcher nothing and gap-free delivery is unchanged.
  • Consumers now also see their own writes out of xid order and again on the poll. workercache already fences creates and updates on version, so duplicates and stale events are no-ops.

Testing
TestLocalPublishReachesWatchers, TestLocalPublishSurvivesWatchClose, and TestCache_WatchEventsAreFenced are new.
TestWatchWorkers_DeliveryFencedByOldestTransaction now consumes the commit-time copy first and asserts the xmin fence on the outbox copy; the fence governs the poll, and a local publish bypasses it by construction.

Full atepg and workercache suites pass under -race.

  • Tests pass
  • Appropriate changes to documentation are included in the PR

// Version fencing deduplicates later watch events. Deletes are ignored
// to prevent out-of-order watch replays from resurrecting workers.
func (c *Cache) ApplyLocal(event store.WorkerEvent) {
if event.Type == store.WorkerEventDeleted {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should ignore worker deletion? The worker name is the Pod UID, a worker resurrecting is not something that can realistically happen.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it, this makes sense.
even if the scheduler picks a non-existent worker, the bind would eventually fail.

Comment thread cmd/ateapi/main.go Outdated
}
// Apply local writes directly to the cache on commit to eliminate outbox
// polling delay; later watch events are deduplicated by version.
if publisher, ok := persistence.(interface {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do this registration in WorkerCache.Start?

if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("committing transaction: %w", err)
}
if worker != nil && p.localEvents != nil && eventType != store.WorkerEventDeleted {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should always publish events, including delete.

if err != nil {
return err
}
if publisher, ok := c.store.(interface {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel this can be brittle. If the type assertion fails (e.g. the store gets wrapped into some sort of delegate pattern), the type assertion will silently fail and the performance optimization just gets disabled. I could be hard to troubleshoot.

Have you considered a model where we don't need the downstream consumers of the store API to register? The store itself could keep a registry of the watches it has registered, and publish WorkerEvent on them. This would need zero changes outside the store/atepg package.

// worker events ahead of outbox delivery. Later outbox duplicates are deduplicated
// by version.
func (p *Persistence) PublishEventsLocally(sink func(store.WorkerEvent)) {
p.localEvents = sink

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be brittle, if you have multiple callers registering sinks, only the last one will get the events. See my other comment about whether we can come up with a model where clients of the store don't need to register themselves.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I had registered the sink directly so the cache updated synchronously — ApplyEvent called inline at commit — since workercache is the only watcher.

Switched to the registry you suggested: Persistence keeps its own set of live WatchWorkers channels and publishes committed events onto them, so nothing downstream registers and there's no capability to probe for. workercache reads them off the same channel the outbox poller feeds. This trades away the synchronous guarantee.
Locally published events also arrive ahead of their xid position and again when the poll reaches them, so the channel now carries duplicates. Gap-free delivery is unaffected — the cursor only advances on the poller's send — and workercache version-fences creates and updates, so both are no-ops there.

The worker cache used to register a callback with the store, discovered through a type assertion. Wrapping the store in a delegate would have made that assertion fail silently, disabling the optimization with no signal.

The store now keeps its own set of live WatchWorkers channels and publishes committed events onto them, so nothing downstream registers and there is no capability to probe for.

Events are published from the payload the outbox stores rather than the in-memory Worker. The two differ: the stored encoding discards unknown fields, so the cache was previously handed unpruned messages on the fast path and pruned ones on the poll path for the same event.

Locally published events reach a watcher ahead of their xid position and again when the poll delivers them, so WatchWorkers consumers must now tolerate duplicates as well as reconcile versions.
@juli4n
Julian Gutierrez Oschmann (juli4n) merged commit a30645f into agent-substrate:main Sep 15, 2026
9 checks passed
@BenTheElder

Copy link
Copy Markdown
Collaborator

Thanks!

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.

Make store publish worker-watch updates locally

3 participants