Skip to content

Conversation

@rwb27
Copy link
Collaborator

@rwb27 rwb27 commented Oct 9, 2025

This pull request introduces two new top-level functions:

  • lt.cancellable_sleep(interval) is equivalent to time.sleep but may raise InvocationCancelledError to stop the action.
  • lt.get_invocation_logger() supplies a logging.Logger object that captures logs specific to the current invocation.

Together, these two functions remove the need for the InvocationLogger and CancelHook dependencies, completing the third phase of #182 .

import labthings_fastapi as lt

class Counter(lt.Thing):
    """An example counter Thing."""

    index: int = lt.property(default=0)

    @lt.thing_action
    def count(self, n: int, dt: float) -> None:
        "Count slowly and log about it."
        logger = lt.get_invocation_logger()
        for i in range(n):
            lt.cancellable_sleep(dt)  # If the action is cancelled, an exception is raised here to terminate it
            self.index += 1
            logger.info(f"Counted to {self.index}")  # This appears if the action is polled

I have also added lt.ThreadWithInvocationID (name might want to change) which is a subclass of threading.Thread that 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 moved CancelEvent from the invocation dependencies module into the new module, and the dependencies now use invocation_contexts so 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 migrated test_action_cancellation and test_action_logging to 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_context visible, most likely in a test module, so one could say from labthings_fastapi.testing import fake_action_context. That would be a natural home for create_thing_without_server and maybe a few other things.

rwb27 added 6 commits October 9, 2025 23:15
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
Copy link

barecheck bot commented Oct 9, 2025

Barecheck - Code coverage report

Total: 94.17%

Your code coverage diff: 0.36% ▴

Uncovered files and lines
FileLines
src/labthings_fastapi/actions/__init__.py145-146, 438-439, 567-568, 581, 606-607
src/labthings_fastapi/descriptors/action.py126, 130, 303-304, 307, 310, 359

@rwb27
Copy link
Collaborator Author

rwb27 commented Oct 22, 2025

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 LogRecord and a custom handler to append logs to the right invocation object, if needed.

This would result in a property Thing.logger that returns a logger named for a particular Thing instance, so instead of needing an invocation logger that's specific to one invocation, we'd simply call self.logger.info("message").

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.

@rwb27
Copy link
Collaborator Author

rwb27 commented Oct 22, 2025

After discussing with @julianstirling and @bprobert97 I think logs should go via Thing.logger as described above, and the jury's out on whether we should make cancellable_sleep be a method of Thing or a module-level method. If it's a module-level method, the conclusion was that it shouldn't raise an error if it was called outside of an invocation thread, so that code can be tested without overriding things.

@julianstirling was also strongly in favour of adding a second function with a descriptive name, rather than cancellable_sleep(None) to check for cancellation without waiting.

rwb27 added 5 commits October 22, 2025 22:26
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`.
Copy link
Contributor

@julianstirling julianstirling left a comment

Choose a reason for hiding this comment

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

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

logger = logging.getLogger(f"labthings_fastapi.actions.{id}")
logger.setLevel(logging.INFO)
return logger
return THING_LOGGER.getChild("OLD_DEPENDENCY_LOGGER")
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume this would have meant that invocation loggers the old way wouldn't work? But this is superseded I think by #191

Comment on lines +13 to +14
from labthings_fastapi import invocation_contexts as ic
from labthings_fastapi import exceptions as exc
Copy link
Contributor

Choose a reason for hiding this comment

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

This is unhelpful when reading tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants