-
Notifications
You must be signed in to change notification settings - Fork 1.3k
✨ Read your own write client design #3473
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| 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 | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
| committed by the server at the time the read happened | ||
|
sbueringer marked this conversation as 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: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there anything special about the following cases that should be called out?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think so, in both of these the request is somewhat special, but the response is not and the response is what we use to determine currentness
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was wondering if it's an issue if we don't have the Name in the request to block correctly (I didn't take a closer look at the implementation yet) |
||
| * 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 | ||
| * Waiting for the read to make it back into the cache may entail setting up an informer, which takes | ||
| significant time | ||
|
|
||
| ## 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
sbueringer marked this conversation as 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` | ||
|
sbueringer marked this conversation as resolved.
|
||
| * Fail writes that use optimistic locking clientside - This could be done in the future, but is initially out of scope | ||
|
|
||
| ## Implementation | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it make sense to short-circuit some of the code if the write request didn't change the RV? I think there are many cases where that can happen:
None of these are ideal, but stuff like this happens
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, but how do we determine that? IIRC the kube-apiserver only sometimes supresses no-op updates?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
As far as I'm aware any cases where the kube-apiserver increments the RV without a change is a bug (and a bunch of them have been fixed in the past). (Example: repeated SSA calls with the same object should not increase the RV) But I think it's not something that we should try to figure out on client side before we do the write call. It should be easy to figure out after the call though by comparing before/after RV of the object. But I don't know if there is anything that we can optimize for that case after the call
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah we can try to do that, but both in the SSA case and the patch case it will most of the time not work I think, because these requests typically do not have an RV. The one thing I do not want to do is try to figure out based on the request body if it changed something, because that seems pretty unsafe, we don't know what mutation webhooks there are
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Good point. The caller of the CR methods usually has that data, but CR not.
Agree. Also conversion webhooks are fun ... |
||
|
|
||
| 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 provide the cache with either the returned resourceVersion | ||
|
sbueringer marked this conversation as resolved.
|
||
| or the gvk+objectkey+uid of an object if it was deleted from storage. The cache 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 | ||
| commits it, only when it tells us having committed it. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (comment belongs slightly above, but collides with other comments) Probably a stupid question, just want to double check. We can rely on events from a watch stream being ordered by RV, right? (otherwise we would potentially unblock to early) Similarly I got this from an LLM and I'm honestly not sure if this is an issue or not, it sounds to me like it might be. I unfortunately don't have the time before PTO to figure it out (feel free to ignore if it doesn't make sense to you)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cc @michaelasp regarding the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Based on what I learned in the meantime about some improvements in client-go I think we can, but would be good to get confirmation if our understanding is correct. |
||
|
|
||
| The implementation is gated behind a `ReadYourOwnWrite: &{Enabled: *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 Cache | ||
|
|
||
| * Add an internal-only `SetMinimumRVForGVKAndKey(gvk schema.GroupVersionKind, key client.ObjectKey, rv int64)` method. Once | ||
| called, all `Get` requests to the GVK+key and all `List` requests to the GVK will be blocked until the cache observed the | ||
| passed rv or the request times out. The rv is copied before waiting to avoid subsequent calls to `SetMinimumRVForGVKAndKey` | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if we should add an additional request-level timeout per default in the consistency client. I would not be surprised if a huge majority of controller-runtime users are not setting an overall Reconciliation or request timeout. IIRC we didn't set a ReconciliationTimeout per default when we introduced it to avoid breaking anyone.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can do that, we should probably have an options struct for the whole thing, so maybe use that to allow configuring a timeout for this specially and default that timeout if unset?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good. We can figure out exact details of how this can be configured on the implementation PR |
||
| blocking the request | ||
|
sbueringer marked this conversation as resolved.
|
||
| * Add an internal-only `AddRequiredDeleteForObject(obj client.Object) error` method. Once set, all `Get` requests to the GVK+key | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This hinges on the assumption that we are guaranteed to get a delete event that contains the object with at least namespace, name and UID even if we end up getting a tombstome from the cache - I think that is the case, but would be good to get confirmation There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe so as well, the RV is the main thing that cannot be guaranteed due to possibility of dropped events. We'd have to see what happens in the case that we drop watch events and we have to relist however, I wonder if anything strange occurs in the scenario that we delete -> recreate -> delete while our events get dropped. I don't think this would affect the internal impl though. |
||
| of object and all `List` requests to the GVK of object will be blocked until a delete event for GVK+UID of object was | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Would it make sense to "reduce" the parameter we take in this func from a full client.Object to: gvk, key, uid? (similar for RemoveRequiredDeleteForObject)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Lets do whichever is easiest to implement and then update the doc based on that |
||
| observed OR the requests context times our OR `RemoveRequiredDeleteForObject` is called for the same object. The object(s) | ||
| whose deletion are awaited are copied before waiting to avoid subsequent `AddRequiredDeleteForObject` calls to block existing | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The whole having to observe the delete event business is quite finicky, it would be a lot easier if deletes would always provide an rv, but according to @liggitt that is problematic: kubernetes/kubernetes#134937 (comment) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I wish there was a better way to deal with delete events. Might be good to discuss if there's anything we can do upstream to make this behavior work more nicely. |
||
| `Get`/`List` calls further. It will error if there is no existing started and synced informer for the passed object, as | ||
| otherwise we can not observe the Delete event which will end up blocking all subsequent reads | ||
| * Add an internal-only `RemoveRequiredDeleteForObject(obj client.Object) error` which makes it not block reads for the passed | ||
| objects GVK+UID anymore. This is required because it is possible that a delete event arrives in the cache before the Delete | ||
| call finishes. This forces us to call `AddRequiredDeleteForObject` before executing the Delete call and hence we need to | ||
| reverse that again if the call fails or the Object had a finalizer | ||
| * Expose a metric counting the number of cache wait timeouts | ||
|
|
||
| ### Changes to the Client | ||
|
|
||
| Add a new `readYourOwnWriteClient` wrapper to the client, which will wrap any new client that is constructed with | ||
| `options.Cache.ReadYourOwnWrite: &{Enabled: new(true)}`. This wrapper maintains a map of locks for gvk+key. It will wrap all | ||
| mutating operations and in their beginning acquire the lock for the requests object. If the request succeeds, it | ||
| will call the caches `SetMinimumRVForGVKAndKey` before returning. For `Delete`, it will additionally call | ||
| `AddRequiredDeleteForObject` before executing the call and if the call fails or the response contains an object, it | ||
| will then call `RemoveRequiredDeleteForObject`. | ||
| The wrapper also wraps all reading operations and block them until the current lock holder for the GVK+key in the | ||
| case of `Get` or all current lock holders for GVK in the case of `List` release their lock or the requests context | ||
| expires. | ||
| The wrapper also has an internal wait time out that defaults to five seconds but can be adjusted through the ReadYourOwnWrite | ||
| options. Both the passed contexts timeout and the internal timeout will be respected and if either is hit, a sentinel | ||
| error that users can check for is returned. | ||
|
|
||
| Add a new `DisableReadYourOwnWriteConsistency` option that can be used for either `Get` or `List` and disables the | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we also need an option to avoid blocking Reads on the Write methods? (Specifically wondering what happens out of the box for a Write that never shows up in the cache because the object doesn't match the selectors)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So an opt out for this on the mutating methods of the client? |
||
| above mentioned checks. This allows to disable the functionality for objects that are not cached. | ||
|
|
||
|
|
||
| ### 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 | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
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.
There was a problem hiding this comment.
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!)
There was a problem hiding this comment.
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")
There was a problem hiding this comment.
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:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 otherw want to respond as well)