Skip to content

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

Open
shrutiyam-glitch wants to merge 3 commits into
agent-substrate:mainfrom
shrutiyam-glitch:worker-local-publish
Open

ateapi: apply a replica's own worker writes to its cache at commit#1583
shrutiyam-glitch wants to merge 3 commits into
agent-substrate:mainfrom
shrutiyam-glitch:worker-local-publish

Conversation

@shrutiyam-glitch

@shrutiyam-glitch shrutiyam-glitch commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1370

Summary
Adds an optional in-process sink (PublishEventsLocally) to synchronously update the local worker cache (workercache.ApplyLocal) at commit time. This provides "read-your-own-writes" consistency, bypassing the outbox polling delay. Later outbox duplicates are safely discarded by the cache's version fencing.

Why
Previously, replicas learned of their own worker writes via the outbox watch (a ~50ms delay). This blind spot caused:

  • The scheduler to re-pick just-assigned workers, resulting in version conflicts and retries.
  • Freed capacity to remain invisible to local placement, triggering spurious "no free workers" errors.

This change eliminates these self-inflicted conflicts. Cross-replica propagation remains unchanged (journal-backed).

Key changes

  • All events are published: Local worker writes, including deletions, are synchronously applied to the cache.
  • Exhaustive hook: Integrated into writeAndAppendEvent, the single funnel for all worker mutations.
  • Safe cloning: The published worker is cloned to prevent pointer aliasing between the cache and API caller.
  • Graceful fallback: Backends lacking this capability simply remain watch-only.

Testing

  • Added TestPublishEventsLocally (Postgres sync firing, cloning, and local publishing).

  • Added TestCache_ApplyLocal_* tests (immediate application and version fencing).

  • 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.
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

2 participants