Invocation context variables#187
Conversation
This new module uses context variables to provide cancellation and logging to action invocations. It will replace the various dependencies `InvocationID`, `InvocationLogger`, and `CancelHook`.
The actions module now uses the new cancellation/logging code. Said code is available via top-level imports. I'm currently still generating invocation IDs using the dependency. This will need to stay until we remove the dependency, as without it the other dependencies (`CancelHook` and `InvocationLogger`) will break.
I've not made a dedicated page (yet) but have added this to the conceptual docs on actions and concurrency. Test code achieves 100% coverage of the new module from `test_invocation_contexts`.
Code testing dependencies is now moved into a submodule, pending their removal. The tests for cancellation and logging are duplicated: the old copies are in the submodule, but I have also migrated them to the new API in the main tests folder.
Barecheck - Code coverage reportTotal: 94.53%Your code coverage diff: 0.72% ▴ Uncovered files and lines |
|
After reading the logging docs I think our current approach of creating a new logger name for every invocation is explicitly discouraged (because loggers don't get garbage collected). I think a good solution would be to use a filter to add context information to the This would result in a property A side effect of this approach is that I no longer think it's reasonable to raise an exception if the logger is used without the invocation context variable being set: exceptions in logging code are usually unhelpful. If the logger is used from outside an action, the log message should still be logged, but without the invocation context data. In the future, it would be nice to think more about how to relay progress to a user/client. I think our current use of a logger for this has some advantages, but also some drawbacks. |
|
After discussing with @julianstirling and @bprobert97 I think logs should go via @julianstirling was also strongly in favour of adding a second function with a descriptive name, rather than |
In response to feedback, I've added `raise_if_cancelled()` to replace `cancellable_sleep(None)`. In response to the same feedback, I now handle the error if no invocation ID is available, so we simply perform a regular time.sleep. I've also deleted a couple of defunct print statements.
This commit adds a logger for every Thing instance, and a custom log handler and filter that inject the invocation ID into LogRecord objects. This means we can still filter out invocation logs as we did before, but we no longer need to make a new logger for each invocation. This more or less follows the example given in the Logging Cookbook for adding context.
For ease of migration, I've fixed the old InvocationLogger dependency. This uses a hard-coded Thing name, but will work well enough to enable a smooth migration to the new syntax.
The test for ThreadWithInvocationID occasionally failed, because the thread was cancelled before it started running, and the CancelEvent was destroyed (and reset). I now hold a reference to the CancelEvent for the lifetime of the Thread, which means this is no longer possible.
This adds full unit test coverage, in addition to the more functional testing in `test_action_logging`.
This marks a few more methods of ThingServer as private, and accepts a dict of Things to be created during initialisation. In order to do this cleanly, I have now formalised a schema for config files, using a Pydantic model. Tests (and no doubt fixes) will come in the next commit.
The config modules now do all the required validation, including Thing names, ensuring we expand classes into full `ThingConfig` objects, and supplying default values for args, kwargs, etc. I've exposed ThingConfig and ThingServerConfig at module level as they are likely useful in a number of scenarios, particularly writing tests. It is possible to supply a dict whenever a ThingConfig is required - I'll include this in the tests, but I don't want to recommend it - it's better to use the model as it makes it harder to miss fields.
There was duplicated validation code in ThingServer, which I've removed, in favour of using the ThingServerConfig model. I've also split up the code adding Things, so now we: 1. Create Things (and supply a ThingServerInterface) 2. Make connections between Things. 3. Add Things to the API. Step (3) may move out of __init__at some point in the future.
If the server is started from the command line, we print out any validation errors, then exit: a stack trace is not helpful. This also fixes an issue where ValidationError would not serialise properly, which stopped the test code working (it used multiprocessing).
I've removed ThingServer.add_thing in favour of passing a dictionary to ThingServer.__init__ and the test code is updated to reflect that. As a rule, when thing instances were required, I would: 1. Create the server 2. Assign `my_thing = server.things["my_thing"]` 3. Assert `assert isinstance(my_thing, MyThing)` That was enough for `pyright` (and I guess mypy) to correctly infer the type in my test code. It's slightly more verbose, but means we can use the same code in test and production, rather than needing a different way to create/add things in test code.
The name `thing_connection` confused people. `thing_slot` suggests we are marking the attribute and its value will be supplied later, which is exactly right. This commit makes that swap. I've searched/replaced every instance of ThingConnection and thing_connection: we should check the docs build OK, this is perhaps most easily done in CI.
This has been removed as its functionality is provided by `pydantic.ImportString`. It is no longer used for the fallback server: we gain nothing from the dynamic import. In the future, if we want to make it configurable, we could use `ImportString` there too.
Now that it's imported statically, `mypy` spots typing errors with the fallback server. These are now annotated correctly.
I've reworked core concepts and removed DirectThingClient, replacing it with a new "structure" page that I think preserves most of the still-useful content.
I've added an explanation comment with a link to the open pydantic issue.
julianstirling
left a comment
There was a problem hiding this comment.
I think I understand this a bit more than the last two.
I think the upshot of this reviewing is that it is very hard to keep 4 complex PRs that interact in my head. I think it would be good once we have merged the 3 later PRs into the base one, to do a final review of #183
I've expanded this docstring as suggested.
I've also fixed the import of `os` - I was previously importing `os.path` but using `os`, now I import `os` directly.
I've imported symbols directly and eliminated `from labthings_fastapi import <module> as <shortname>`. This ought to make the tests a bit more readable.
I've deleted a chunk of text that was in a documentation file, but didn't show (it was deliberately hidden). This may or may not get added back in somewhere else in due course, but I think it's confusing to leave it in its current state.
This was a utility function for testing, but it wasn't clear or helpful. I've replaced the use of this function with more direct tests.
The module under test was renamed, so I'm renaming the test module to match.
Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com>
Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com>
Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com>
I've removed a test that mixed old and new syntax (the code it tests is tested elsewhere), and added a README to explain why we have a folder of almost-duplicated tests.
Drop dependencies feedback
This pull request introduces two new top-level functions:
lt.cancellable_sleep(interval)is equivalent totime.sleepbut may raiseInvocationCancelledErrorto stop the action.lt.get_invocation_logger()supplies alogging.Loggerobject that captures logs specific to the current invocation.Together, these two functions remove the need for the
InvocationLoggerandCancelHookdependencies, completing the third phase of #182 .I have also added
lt.ThreadWithInvocationID(name might want to change) which is a subclass ofthreading.Threadthat supplies an invocation ID. This allows an action to be run in a background thread without errors because it's missing an invocation ID context. It also allows the thread to be cancelled.This is mostly implemented with one new module,
invocation_contexts. I've movedCancelEventfrom the invocation dependencies module into the new module, and the dependencies now useinvocation_contextsso they still work.The one place where dependencies are still used is in generating invocation IDs for actions that are invoked over HTTP. Because of the way FastAPI's dependency mechanism works, I can't get rid of this without breaking both of the dependencies mentioned above. I intend to leave them in for now to facilitate migration, but the whole module will be deleted before too long.
I have written new, bottom-up unit tests in
test_invocation_contexts(with 100% coverage of the new module) and have also migratedtest_action_cancellationandtest_action_loggingto use the new API. The old tests are preserved in a submodule, along with other dependency-related tests.I've deliberately not exposed all the functions at top-level in the module: I think the two functions and one class I've exposed are the API we want people to use. I should make
fake_action_contextvisible, most likely in a test module, so one could sayfrom labthings_fastapi.testing import fake_action_context. That would be a natural home forcreate_thing_without_serverand maybe a few other things.