-
Notifications
You must be signed in to change notification settings - Fork 70
Make all "constructors" take a client optional arg
#3669
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?
Conversation
…s communicate 'has not been hydrated' before they try to resolve a client if we know they aren't lazy For example, a Cls.from_name().update_autoscaler() is ok since Cls.from_name is lazy, but @app.cls().update_autoscaler() should not be considered lazy
`.hydrate()` on them would previously not work, but this was mostly hidden by the fact that few people do eager hydration of them and the Obj/Methods that are created 'upstream' of it are all declared as lazy which ignores downstream settings
client optional argclient optional arg
|
|
||
| def test_unhydrated(): | ||
| def test_unhydrated(set_env_client): | ||
| # TODO: get rid of set_env_client here. |
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.
One of the few places I had to keep the cursed env_client injections. It's a bit of an odd case and I have some ideas for how to fix it if really want to, but shouldn't be a huge deal since it's only for a particular error case
| await resolver.load(d) | ||
| resolver = Resolver() | ||
|
|
||
| parent_metadata = LoadContext(client=client, environment_name=env) |
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.
Why is this "parent metadata"? What's the parent here?
| q = _Queue.from_name(name, environment_name=env, create_if_missing=True) | ||
| client = await _Client.from_env() | ||
| resolver = Resolver(client=client) | ||
| await resolver.load(q) | ||
| resolver = Resolver() | ||
| parent_metadata = LoadContext(client=client, environment_name=env) | ||
| await resolver.load(q, parent_metadata) |
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.
BTW feels like we could be using modal.Queue.objects.create() here?
| def from_local( | ||
| info: FunctionInfo, | ||
| app, | ||
| app: Optional["modal.app._App"], # App here should only be None in case of Image.run_function |
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.
😵💫
| client: Optional[_Client] = None, | ||
| environment_name: Optional[str] = None, | ||
| app_id: Optional[str] = None, | ||
| _apply_defaults: bool = True, |
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.
Why do we have a "private" parameter to this (already private?) object
|
|
||
| # Bound methods should *reference* their parent Cls's LoadContext | ||
| # so that it can be modified in place on the parent and be reflected in the method | ||
| print("METHOD METADATA", method_name, cls._load_context) |
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.
Hello
|
|
||
| assert resolver.app_id | ||
| async def _preload( | ||
| self: _Function, resolver: Resolver, load_context: LoadContext, existing_object_id: Optional[str] |
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.
Does _preload need the resolve anymore?
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.
Probably not in practice, but technically something that preloads should be able to preload other objects
| # Client is special - needed to be set just before the app is "hydrated" or running at the latest | ||
| # Guaranteed to be set for running apps, but also needed to actually *hydrate* the app and make it running | ||
| self._client = None | ||
| self._load_context = LoadContext() # Initialize empty LoadContext |
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.
For consistency?
| self._load_context = LoadContext() # Initialize empty LoadContext | |
| self._load_context = LoadContext.empty() # Initialize empty LoadContext |
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.
👍
| parent-child relationships (e.g., App -> Function, Cls -> Obj -> bound methods). | ||
| """ | ||
|
|
||
| _client: Optional[_Client] = None |
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.
At a glance, it looks like LoadContext is used as a "builder" to combine contexts. Can we have LoadContext.apply_defaults return a ResolvedContext, where the parameters are not None:
@dataclass
class ResolvedContext:
client: _Client
environment_name: str
app_id: Optiona[str]I would even rename LoadContext into ContextBuilder. With ResolvedContext, we can be sure that the client is defined with the type system.
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.
Yeah that could be nice!
One argument against would be if we think ahead to when the Resolver doesn't exist anymore and all contexts are just propagated from their "parents" explicitly (instead of using deps). In that scenario, I don't think we want to apply_default() anymore, and instead just apply each property as it's needed from within the loader (if it's not already set on the load_context_overrides.
Making each property resolve individually would fix the case where we have loaders that may raise exceptions immediately without actually needing a functional context (like calling methods directly on un-instantiated classes). Maybe we should actually already make that change, e.g. having LoadContext have async def get_client() -> Client etc. so that loaders use those (even though we currently have to make the resolver apply the defaults first due to deps...)
| resolver = Resolver(c) | ||
| await resolver.load(typing.cast(_Object, self)) | ||
| # Set the client on LoadContext before loading | ||
| parent_metadata = LoadContext(client=client) |
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.
Nit:
| parent_metadata = LoadContext(client=client) | |
| parent_context = LoadContext(client=client) |
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.
Nice catch! I initially called the object LoadMetadata, but must have missed this rename
| c = client if client is not None else await _Client.from_env() | ||
| resolver = Resolver(c) | ||
| # Set the client on LoadContext before loading | ||
| parent_metadata = LoadContext(client=client) |
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.
| parent_metadata = LoadContext(client=client) | |
| parent_context = LoadContext(client=client) |
Goals of this patch:
.from_name(),.from_id()etc. user-facing "constructors" to take an optionalclientobjectset_env_clientfixtures in tests - all test code should be functioning by passing inClientobjects explicitly through various meansCloses SDK-632
Why this is hard
ClsandFunctioncan either be hydrated as part of anapp.deploy(client=...)/run(client=...)or lazilyFunctionobject is created, since the App isn't "live" at decoration timeclient, e.g.Cls.with_options(). This clone can also be created beforeclienthas been set on its parent, and once the client is set on the parent it should transfer to the clones as well 😵Client(and environment etc.) to the independent "dependency" (e.g. aSandbox.create()with a set ofSecrets as deps.Client. If it gets a client from its parent it should not create afrom_envclient unnecessarilyBonus goal
clientabove applies toenvironment_nameas well where it's applicable, and to some degreeapp_id- these are also load-time metadata that can either be explicitly injected or inferred from parents or the runtime.Change summary
LoadContexttype and concept, that encapsulates optional values ofclient,environment_nameandapp_idthat were previously part of theResolverclient,environment_nameandapp_idfields fromResolvermodal._Objectsvia aobject._load_contextfield on unhydrated construction (typically from providedclientandenvironment_namevalues in.from_*constructors.Appalso gets an internalLoadContext(initially empty onApp()construction)_load()loader method on_Objectnow receives both aResolverand aLoadContextobject and load-code should useload_context.clientetc. for rpcs and to set the_Object._clientwhen hydrating.The new "loader function" signature is:
The
LoadContextpassed is guaranteed to contain a non-optionalClientandenvironment_name, and anapp_idif one exists.Resolverinstance that is now passed to loaders only serves to load other instances explicitly via.load(other_obj).Resolver.load()call will now take both an object and a "parent LoadContext". The Resolver then "merges" into each loaded Object's passed parent load context with whatever the object has optionally specified as overrides inself._load_context. This makes sure that we prioritize the context overrides of each object, but allow the "parent" context to propagate to any child that doesn't have overrides. Most of the dependency loading is still handled automatically via "deps" passed to theObject._from_loader()contstructor.Slightly hacky:
LoadContextsharing forCls/Function"clones"While normally we give each object its own LoadContext overrides instance, in the case of
Cls(and a futureFunction.with_options()) we need to make sure that all related entities share the same LoadContext reference.clientwould be injected atCls.from_name(client=...), but the "leaf" _Object (aFunctionrepresenting a method on the class) has no such override mechanims in itself. The methodFunctionis however top level/"root" object when resolving dependencies during lazy loading, so in order for it to get theClsreuse its load context from theClsitself when creating its methods, we make sure that whatever client specified as an override on Cls is also used when loading the methodFunction.app.runupdates this load context in place, effectively propagating any load_context overrides from app launch to the lazy-loaded "Method" objects that might get referenced by users. This fixes the messy case of:Compatibility checklist
Check these boxes or delete any item (or this section) if not relevant for this PR.
Note on protobuf: protobuf message changes in one place may have impact to
multiple entities (client, server, worker, database). See points above.
Release checklist
If you intend for this commit to trigger a full release to PyPI, please ensure that the following steps have been taken:
modal_version/__init__.py) has been updated with the next logical versionChangelog
Note
Add LoadContext and make all from_* constructors accept an optional client, moving client/env/app_id from Resolver to a propagated load context.
LoadContext(client, environment_name, app_id) and propagate through all object loads.Resolver; it now merges parent and per-object load contexts and applies defaults._Object._from_loader/hydrate/loader signatures to acceptLoadContext; hydrate usesResolver.load(self, parent_load_context).load_context.client.Appholds a mutable_load_context; runner sets client/env/app_id and passes it toResolverfor all loads.Function,Cls,Dict,Queue,Environment,Image,Mount,NetworkFileSystem,Proxy,Secret,Volume,Sandbox,SandboxSnapshot,FunctionCallupdated to useLoadContextand acceptclient(and env) infrom_name/from_id.Cls.with_*share parentLoadContextby reference.LoadContext(client/env) and callResolver.load(...)without embedding client.set_env_clientusage; passclientexplicitly; update expectations accordingly.Written by Cursor Bugbot for commit fe10c23. This will update automatically on new commits. Configure here.