Skip to content

multi-actor worker API - #1283

Open
Benjamin Elder (BenTheElder) wants to merge 6 commits into
agent-substrate:mainfrom
BenTheElder:worker-assignments-as-rows
Open

multi-actor worker API#1283
Benjamin Elder (BenTheElder) wants to merge 6 commits into
agent-substrate:mainfrom
BenTheElder:worker-assignments-as-rows

Conversation

@BenTheElder

@BenTheElder Benjamin Elder (BenTheElder) commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Part of #1266

This is a draft of the core API + data store changes.

It's still a large PR, apologies.

The "as rows" commit could be split out, but this takes it to ~all of the breaking changes we can't hide behind updating internals.

Same for the claimlock, but in both cases it seems these are worth understanding when considering the API shape.

They're loadbearing for performance once we actually have multi-actor workers.

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

x-ref: #1164 🙈

@thockin Tim Hockin (thockin) left a comment

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 focused on API stuff. The atomic update of an assignment and the worker seems like something that will hurt us later, but maybe workers just not updated often enough for that to matter?

It's tricky logic and could do with more explanation (and specifically the "not updated often enough to matter" rational).

I did not focus on validation yet

Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
// declared resource limits. An unset message, or a zero field within it, means
// "unknown/unset" for that dimension: treated as unconstrained so placement is
// not blocked (matching the pre-capacity behavior).
// WorkerCapacity is what a worker pod has to give the Actors it hosts, and also

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 comment is also unclear. I think you should clarify a resource model but "give/take" wording is sort of clunky.

IIUC this is the total capacity of the worker, independent of what may currently be allocated to actors. Is that right? So "available" is "worker.capacity - worker.status.allocated".

How would we model overcommit? Worker lies about capacity? Maybe add an overcommit scalar (e.g. 1.5)?

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.

How would we model overcommit? Worker lies about capacity? Maybe add an overcommit scalar (e.g. 1.5)?

My gut take was worker lies about capacity.

We will have to filter this stuff up to autoscaling, that approach seems easy enough to reason about but perhaps too limiting.

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.

We plan to consolidate this with status.

Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
if err != nil {
return fmt.Errorf("marshaling worker: %w", err)
}
_, err = tx.Exec(ctx, `UPDATE workers SET version = $1, proto = $2 WHERE name = $3`,

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.

Shouldn't you do precondition here anyway? (where UID=... AND VERSION=...)

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.

The row lock handles this currently. But we should probably defensively cover it as well.

@BenTheElder

Benjamin Elder (BenTheElder) commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator Author

The atomic update of an assignment and the worker seems like something that will hurt us later, but maybe workers just not updated often enough for that to matter?

FWIW I've pushed 28k claims/s with a test of the storage layer, it scales pretty well with cores.. For overcomitted toy actors, 97k in 60s ... mostly bound by dataplane. This is only going to hurt us if the rest of the system gets really fast or if we have reason to do a lot of other writes to workers unrelated to scheduling. We're by far dataplane bound even at fairly considerable scale with this.

We could do something else like ... a distributed scheduling architecture, but I'm not sure that's necessary.


I've realized some other defects in this as-is when I got some time to sit and think over the weekend. Working on it.

  1. I'd really like registered capacity to be orthogonal to pod creation. The worker should self report CPU and memory, even if we initially just downward API that in from the pods. That's a minor tweak (we already do this for actor count) but it needs some work.

  2. ... devices. They're under-modeled currently, but they do sort of work in main. This regresses. Not straighforward.

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

I will iterate on this a bit more, including some cleanup to the commits etc, and notify when it's ready.

For now, logging off.

@EItanya Eitan Yarmush (EItanya) left a comment

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.

Overall this makes sense, I still need to mull the whole thing over, but I am really liking the direction.

Codex found a couple potential races that we can confirm:

  Binding commits before the Actor is updated to reference the Worker. If ateapi crashes in
  between, the Worker holds an assignment that the Actor does not know about.

  Later, DeleteActor skips Worker cleanup when actor.status.worker_assignment is empty. There is
  no assignment-to-Actor foreign key or reconciler, so the capacity can remain leaked until the
  Worker disappears.

  Smallest sound fix: deletion must look up and release an assignment by Actor UID even when the
  Actor lacks a backlink. Add a test covering bind → missing Actor update → delete.

  When an Actor is already assigned to the same Worker, BindActorToWorker subtracts the old
  resources and adds the replacement, but does not call admit.

  Because Actor.actor_template is mutable, a stale claim can be resumed with larger limits and
  push the Worker over capacity.

  Smallest fix: subtract the previous reservation, run admission against that effective Worker,
  then add the replacement.

Comment on lines +1798 to +1801
// The Worker being reported on. atespace is always empty; Workers are
// global-scoped.
// +k8s:opaqueType
ObjectRef worker = 1;

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.

Doesn't this need to be inferred from the certificate anyway, is this redundant?

@BenTheElder Benjamin Elder (BenTheElder) Sep 2, 2026

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.

We don't currently dial directly from the worker, it comes from ateom => atelet.

I have confidence that will change in the future but ... not sure when. Probably after we actually have multi-actor worker ... bit chicken and egg.

Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
Comment thread pkg/proto/ateapipb/ateapi.proto Outdated
// The Actor currently bound to this Worker, if any.
// The Actors bound to this Worker.
//
// Populated only by GetWorker. ListWorkers leaves it empty and reports

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.

So to be clear, this field is stored in the new worker_assignments table, but represented in the API separately?

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.

Yes. Though we dropped that in a later commit / iteration, for the subresource with ListWorkerAssignments as a dedicated RPC.

// +k8s:optional
// +k8s:minimum=1
int64 cpu_milli = 1;
Resources resources = 1;

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.

Feel free to throw this away. If we parse this into a map in go anyway, should it be a map in the proto as well?

@BenTheElder Benjamin Elder (BenTheElder) Sep 2, 2026

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.

Tim Hockin (@thockin) had an earlier comment about reusing the Resources type. I'm on the fence. Later we probably need to model more things. I don't love the Resources type but I like that it matches what we have on ActorTemplate.

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.

So I lean towards keeping it for now. If we want to use cpu_milli / memory bytes consistently we should rewrite ActorTemplate too.

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 love Resources but it maps to k8s. There are ...issues... properly handling quantity strings, which I am chasing down on the k8s side. I think we should ultimately decide to either support quantity strings all over or enforce quantized int values (e.g. cpu_millicores, memory_mb) all over.

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.

I suggest we bulk edit that in a follow-up, it's somewhat orthogonal and I'm not totally sold either way yet.

We're also going to need more complex resource types O(soon) to express GPUs properly, a count really isn't sufficient.

Comment thread cmd/ateapi/internal/store/atepg/atepg.go Outdated
Comment on lines +1509 to +1510
// The ordinary bind is a first bind, and the insert landing is proof
// nothing was there to account for: one statement, no read.

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 really hate to be that guy but these comments are horrendously confusing

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.

fair :-)

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Codex found a couple potential races that we can confirm:

These are real and should be fixed.

I need to do some other remaining cleanup.

Comment thread pkg/proto/ateapipb/ateapi.proto

@thockin Tim Hockin (thockin) left a comment

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.

Proto seems straight forward at this point

//
// +k8s:optional
// +k8s:minimum=1
int32 page_size = 2;

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.

Julian Gutierrez Oschmann (@juli4n) Should we bundle these into a generic ListOptions ?

//
// +k8s:optional
// +k8s:immutable
WorkerCapacity capacity = 10;

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.

As per discussion, my takeaway was this moves to status. If you disagree respond, else close this in due course. Or are we killing this entirely?

}

message WorkerStatus {
reserved 2; // was assignments, now the ActorAssignment subresource

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.

We really don't need this

// What this Worker can supply to the Actors it hosts.
//
// Mutable: a Worker can be resized, and it reports its own actor ceiling.
// Clearing is rejected. Shrinking below allocated stops new placements and

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.

If you want to prevent clearing: +k8s:update=NoUnset

// +k8s:optional
// +k8s:minimum=1
int64 cpu_milli = 1;
Resources resources = 1;

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 love Resources but it maps to k8s. There are ...issues... properly handling quantity strings, which I am chasing down on the k8s side. I think we should ultimately decide to either support quantity strings all over or enforce quantized int values (e.g. cpu_millicores, memory_mb) all over.

// What a Worker supplies, named as an ActorTemplate names what it asks for,
// so the two are one vocabulary and subtract directly. A name the Worker does
// not report is unconstrained rather than absent, so a Worker that has said
// nothing is not unschedulable.

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 this semantic works -- if I ask for 128 cores, you can't put me somewhere unless you know I have them. If I ask for GPU, you can't put me on a worker that has no GPUs.

@BenTheElder Benjamin Elder (BenTheElder) Sep 3, 2026

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.

Agree ...

Aside: this is also a meh claude comment for sure.

my main takeaway from this PR is to cut off claude for API changes and stick to boilerplate fixes. even writing out what you want winds up with ... ehh not there yet.

next time I will entirely hand-craft the API commit at minimum.

// Actor is placed only where free covers its limits in every dimension.
//
// A Worker overcommits by reporting more than it has.
message WorkerCapacity {

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.

Do we need this intermediate struct if we use Resources?

// carried them would grow with occupancy. The inverse of WorkerAssignment.
message ActorAssignment {
// atespace is always empty, as it is on the Worker these belong to. name is
// the Actor's UID, so the Actor that caused an assignment is what addresses

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 thought we said that metadata.name would match metadata.uid but I am also fine with this approach.

message SetWorkerCapacityRequest {
// The Worker being reported on. atespace is always empty; Workers are
// global-scoped.
// +k8s:opaqueType

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.

Why opaque?

// What the Worker can hold. An unset dimension keeps what is recorded rather
// than clearing it, so a reporter that knows only its actor ceiling does not
// erase the compute capacity taken from the pod's limits.
// +k8s:opaqueType

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.

Why opaque?

status.assignment held a single Actor. It becomes repeated, reusing field 2 so
an old record reads back as a set of one, and status.allocated carries the
running total that placement reads for every Worker on every decision.

WorkerCapacity now holds a Resources set, named the way an ActorTemplate names
its limits so that the two subtract, alongside an actors ceiling for the costs
that cpu and memory do not cover. ActorAssignment.resources records what an
Actor was admitted for.

Capacity can now change over a Worker's life, but it cannot be cleared.

A Worker reports its own capacity through the new WorkerReporting service.
That is separate from Control because it is served only to atelet, and only
for the Workers on its own node.
Readers use resources.WorkerAssignmentFor, and binding and releasing update
status.allocated alongside the assignment list.

Accounting moves to named quantities in resources.Quantities, which emits them
in sorted order. Resources is a repeated field, so two totals that differ only
in order compare unequal and rewrite the record for no change.

Placement compares what is free in each dimension, and eligibility is asked
separately from room. A caller re-validating a Worker that already holds the
Actor would otherwise be told there is no space and evict it. A Worker whose
recorded capacity does not parse is treated as full.

Clearing capacity is rejected by a hand-written check. Declarative validation
can mark a field immutable, but it cannot allow a field to change while still
requiring it to be set.

ListWorkers omits the assignments and reports occupancy through allocated.
kubectl-ate prints ASSIGNED(n/m).
atelet reports on behalf of the Workers on its node, authenticated by its own
client certificate as it is for MintCert. A Worker on another node comes back
as NOT_FOUND. The atelet authentication moves into a package both services
share.

Capacity is the Worker's to report rather than the control plane's to infer,
so the syncer no longer derives cpu and memory from the pod. Until a report
arrives, the ceiling CreateWorker reifies holds the Worker to one Actor.

A reporter can leave out dimensions it does not know, and those keep whatever
is already recorded. Re-sending an unchanged capacity does not write.

Nothing calls this yet.
A Worker carried its assignments inside its own record, so the record, its
change event, and every watcher's copy of it all grew with the number of Actors
on it. Each assignment becomes a row of its own.

status.allocated stays on the Worker, because placement reads it for every
candidate. The store updates it in the same transaction as the assignment it
counts.

Binding does not first read the assignment it might be replacing. The insert
landing is proof that nothing was there, and a read could not see a claim that
commits after it.
Claiming a worker rewrote its whole record, so goroutines racing on the same
worker refused one another: 21% of activations failed at 12 in flight, and 67%
at 24.

BindActorToWorker now takes an admit callback and asks inside the transaction
that holds the worker's row lock, so the answer cannot go stale before the
write, and a refusal rolls the speculative row back. That removes the version
precondition, the fresh read, and the assignment lookup.

The retry budget had to grow, because a claim refused for want of room re-runs
scheduling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api User-facing API changes area/node

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants