Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions designs/read_your_own_write_client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
Read your own write client
==========================

## Background

Controller-Runtimes default client writes to the api and reads from an informercache. As a result, performing a read
right after a write tends to not return that write since it hasn't made it back to the informer cache yet. This
leads to bugs and workarounds like replacing the read portion of the client to read from the API instead. The goal of
this proposal is to provide the same consistency in the cache-reading client a live client would with regards to

@sbueringer sbueringer Mar 30, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was chatting a bit with @serathius at KubeCon and he mentioned that in core Kubernetes they intentionally didn't implement that kind of "strong consistency" (if I got it right they are not blocking all reads after writes, they do it more fine-granularly).

I don't have time right now to lookup the k/k implementations, but maybe we should:

  • ensure that we have metrics & data before we make a decision to turn this feature on per default
  • also enable cases with lower consistency guarantees, e.g. a controller might only want to wait until writes for a specific reconcile.Request have been observed and not also for other writes of other controller workers or other controllers (requires more research on what exactly was done in k/k, just wanted to surface it already in case that's useful)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Right, while controller-runtime can provide more options to user, I think the defaults should be aligned with k/k to avoid surprises by both operator users and authors. Eventual consistency of controllers is one of the reasons Kubernetes can scale so well. Operators should also take advantage of it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

+1 k/k's method of reading its own writes is that it will only pause reconciliation if:

  1. An object the reconciler wrote has not been read back yet
  2. Only objects the requested object has written previously will stall reconciliation(this one is tricky to generalize, especially in cases that we still want to pause reconciliation on other resources)

In an example statefulset Foo may create pods -> foo1, foo2, foo3 and statefulset Bar creates bar1, bar2 and bar3

If foo2 is stuck and we have not seen the write, that does not prevent bar from processing its objects. I wonder if we want to only wait for writes inside of that request by default but expose helpers that can pause for the whole reconciler if necessary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

More context on the changes in k/k: kubernetes/website#54792 (Thx @michaelasp for writing the blog!)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We're implementing stale controller mitigation in Cluster API on top of controller-runtime.
It's a bit Cluster API specific because we have an additional wrapper for our reconcilers but just fyi in case it's helpful anyway: kubernetes-sigs/cluster-api#13720 (commit: "Implement stale controller mitigation in MD controller")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds like a good idea.

What do you think about the following on top (can be a future goal).

With the way this feature currently works it will return errors on Reads if the relevant Writes have not been seen. In general I see an advantage in being able to check very early in a Reconcile if the cache is up-to-date.
This is also what achieved with the implementations in k/k because they check the consistency store at the beginning of a reconcile if all relevant writes have made it into the cache.

I think we could also enable this use case by having an additional method on the "consistency client" that allows to check if all writes made it into the cache (either all or just for the specific key).

Example:

// Early in reconcile
c := client.WithConsistentReadsForKey("namespace/name")
if !c.IsUpTodate() {
   // Requeue
}

c.Write
....

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

What do you think about the following on top (can be a future goal).

Its a good idea, but it also makes me think that maybe for the first implementation we should have neither what I proposed nor what you proposed and just implement the somewhat inefficient "wait for everything", because that is good enough in many cases already and the topic of "What ways should we have to control what to block on" seems like it may warrant its own discussion?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sounds good, maybe mention that topic as a future goal to somewhat keep track of it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sounds good, maybe mention that topic as a future goal to somewhat keep track of it?

Done

@sbueringer sbueringer Jul 31, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Lgtm (leaving this conversation open in case others want to respond as well)

writes performed through the same client.

## Goals

* Any read from the client (`Get` or `List`) contains all writes performed through the same client that were

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This will cause certain high throughput cases to become slower, we should make sure that these cases are documented. The main case that comes to mind is some sort of controller that:

  1. Receives an event
  2. Does a list that does not need read your own write consistency
  3. Does some operation on the GVK of that list

Now we would have to block for every write on that list until the cache catches up. Agreed however that most controllers would benefit from having the cache up to date with its writes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yeah, fundamentally the tradeoff is performance vs consistency. I believe that the consistency is more important in most cases. For the cases where it is not, this whole feature can be disabled.

@sbueringer sbueringer Mar 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We could add an option to Get/List calls that allows to disable consistent read (not sure if we should do that initially though, but I think it's something that we could add later as well).

EDIT: Saw that the option is mentioned below

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Somewhat related. I think sooner or later we should document some patterns on how to use this feature (including trade-offs involved).

E.g. I'm not sure how much sense it makes to share one consistency client across many (different) reconcilers in the same controller. If the client is shared all reconcilers are getting blocked, but because these reconcilers are running concurrently anyway there might be not much need/benefit for read-your-own-write across reconcilers.

Concrete example from CAPI:

  • It would be helpful to use a consistency client for the MachineSet reconciler so subsequent Reconciles of the same MachineSet can observe the Machines that have been created/deleted in previous Reconcile calls
  • But it doesn't necessarily make sense to use the same consistency client for the Cluster / MachineDeployment / Machine / ... reconcilers, because they have to deal with the fact that the MachineSet reconciler is running concurrently anyway (i.e. no need to introduce a "global mutex")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We could add an option to Get/List calls that allows to disable consistent read (not sure if we should do that initially though, but I think it's something that we could add later as well).

DisableReadYourWritesConsistency option was added to all read & write requests

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

DisableReadYourWritesConsistency option was added to all read & write requests

Not sure what you mean to say with this comment, the design mentions this

@sbueringer sbueringer Oct 5, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not sure what you mean to say with this comment, the design mentions this

I just wanted to update this thread with an info what we did :) (I think when we discussed this in March we didn't have the option yet)

committed by the server at the time the read happened
Comment thread
sbueringer marked this conversation as resolved.
Resolved
* Reads do not get blocked on writes that started after they started, as otherwise they may end up getting
blocked indefinitely if many writes are happening.
* Writes themselves do not get blocked waiting for them to be observable by reads because:
Comment thread
sbueringer marked this conversation as resolved.
Resolved
* This may lead to successful writes returning an error. While that should not result in incorrect behavior
of a controller as controllers are expected to be idempotent, it would lead to performance degradation, as
the error will typical lead to a retry with backoff and any work done before encountering it has to be
re-done

## Non-Goals

* Any sort of consistency with writes originating from a client in another binary - The only way to do this would be
to do a get or list against the api at which point it doesn't make sense to have a cache-backed client at
all
* Consistency between multiple clients constructed using the same informercache
* Consistency for writes that succeeded, but where the information if the write succeeded didn't make it
back to the client for any reason like the connection breaking. This is primarily for practical
purposes, if the write doesn't get a response indicating success or failure back, it is impossible to tell if
the write did or did not succeed
* Automatically dealing with configurations where the cache doesn't contain all objects that are written, for

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yeah this is tricky, if we don't have all the objects then it is not clear how we track the RV in the store. I'd like to look more at how the cache in controller runtime currently works with field/label selectors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This here is how it is configured: https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.23.3/pkg/cache#Config

Fundemantelly it boils down to being able to configure field and/or labelSelector per GVK and or namespace and namespace(s) per gvk. The latter is implemented clientside, since the KAS does not allow watchichg multiple namespaces

example because its label or field selector doesn't match them. We will provide an option for this case and
may or may not try to detect this correctly in the future
Comment thread
sbueringer marked this conversation as resolved.
Resolved
* Dealing with configurations where a Transform strips off required fields like RV or UID
* Support `DeleteAllOf` - The apiservers response to DeleteAllOf is insufficient to implement this correctly. DeleteAllOf
will error and instruct the user to use `List` and `Delete`
Comment thread
sbueringer marked this conversation as resolved.
Resolved
* Fail writes that use optimistic locking clientside - This could be done in the future, but is initially out of scope

### Future goals

* Allow more fine-granular configuration about what to wait on as this warrants its own discussion
* Optimize blocking of namespaced list to only consider objects in the namespace

## Implementation
Comment thread
sbueringer marked this conversation as resolved.
Resolved

The basic idea of the implementation is to make writes block concurrent reads to the same GVK+Key for Get and GVK for List
before the request is sent. If the request succeeds, the client will store either the returned resourceVersion
or the gvk+objectkey+uid of an object if it was deleted from storage. The client will then copy this RV/deleted object within
the get/list and block the request until it observed it or the requests context times out.
It is important that we block before executing the write request and not after, because we can not know when exactly the server
Comment thread
sbueringer marked this conversation as resolved.
Resolved
commits it, only when it tells us that it commited it.

The implementation is gated behind a `EnableReadYourWritesConsistency: *bool` `client.Options.Cache` setting. It will initially
be disabled by default, the goal is to enable it by default once we are confident in the implementation.


### Changes to the Client

Add a new `consistentClient` wrapper to the client, which will wrap any new client that is constructed with
`options.Cache.EnableReadYourWritesConsistency: new(true)`. This wrapper is responsible for ensuring that a) reads
wait for all writes that started before them to the resource(s) they are reading to finish and b) reads then wait
for the cache to catch up to a state after the write. a) is achieved using the following:

```
type WriteBarriers interface {
// Begin keeps the passed key locked until the returned release func was called.
Begin(key types.NamespacedName) (release func())

// Seal seals the currently-active set of locks to key and returns a channel that
// closes when they are done.
Seal(key types.NamespacedName) <-chan struct{}

// SealAll is Seal for all keys.
SealAll() []<-chan struct{}
}
```
The reason we need this and can not use a simple mutex is that a mutex would allow us to wait for at most one write and
it would serialize all writes.

b) is achieved by adding a `consistencyHandler` that is added as an eventhandler to the cache and exposes the
following methods:
```
func (h *ConsistencyHandler) AddPendingDelete(key types.NamespacedName, uid types.UID)
func (h *ConsistencyHandler) OnAdd(raw any, _ bool)
func (h *ConsistencyHandler) OnDelete(raw any)
func (h *ConsistencyHandler) OnUpdate(_, newObj any)
func (h *ConsistencyHandler) Register(ctx context.Context, informer cacheapi.Informer) error
func (h *ConsistencyHandler) Registered() bool
func (h *ConsistencyHandler) RemovePendingDelete(key types.NamespacedName, uid types.UID)
func (h *ConsistencyHandler) SetMinimumRV(key types.NamespacedName, rv int64)
func (h *ConsistencyHandler) WaitForGet(ctx context.Context, key types.NamespacedName) error
func (h *ConsistencyHandler) WaitForList(ctx context.Context) error
```

`Delete` requests will then call `AddPendingDelete` before issuing the request and `RemovePendingDelete` if the request
didn't succeed or the object didn't get removed from storage and a RV is returned. The ordering is important here,
if we added the Delete after the request finished, it is theoretically possible that the handler already observed it
and will then prevent all reads waiting for an event it will never receive. All other write requests call `SetMinimumRV`.

`Get`/`List` call `WaitForGet`/`WaitForList` which will copy the current set of deletes that is being waited on as well
as the `ResoureVersion` from `SetMinimumRV` and wait for the cache to observe them. If the cache already observed them,
it will return right away.

Add a new `DisableReadYourWritesConsistency` option that can be used for any client method and disables the
above-described logic for that call.

### Changes to the cache

Create a new package `pkg/cache/cacheapi` that contains the interface definitions that are currently in
`pkg/cache` as well as the interface definitions from `pkg/client` they reference. This allows the client
package to use the `Informer` interface without a cyclic import. The places where these interfaces are
currently defined will add an alias to the new location to avoid a breaking change.
Loading