Skip to content
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

Initial prototype for trio.Nursery per-task-scope-mangement via a user defined single-yield-generator function 😎 #363

Draft
wants to merge 8 commits into
base: ctx_cancel_semantics_and_overruns
Choose a base branch
from

Conversation

goodboy
Copy link
Owner

@goodboy goodboy commented May 17, 2023

As per some discussion in the trio gitter and starting something
toward #22 and supporting our debugger crash mode from within
a trio-compat task nursery.


Idea: a trio.Nursery API for optionally allowing a user to define a per-task manager

But why?

Well if you wanted any of the following (some of which are implemented
in the module script in this patch):

  • generally gain access to lower level trio.Nursery "internals" for any
    per-task oriented purpose.
  • some way to wrap your own "task handle" so you could do things like
    waiting on a task to finish and get its result.
  • allow hooking into each task's exit/teardown (phase) for the purposes
    of debugging crashes, (unexpected) cancels or just generally tracing
    the trio per task control flow.
  • enable more granular task supervision strategies like one_for_one
    from erlang/elixir and friends.

ToDo:

  • offer a task_manager context-manager-as-generator API such
    that the current TaskOutcome struct is not tied to the
    internals of the ScopePerTaskNursery.start_soon() implementation.

    • ideally the outcome: Outcome = yield (cs, ..) is delivered to
      the user defined mngr via a Outcome.send(mngr) call such that only
      raw trio (dependency) internal types are provided to the user for
      wrapping / adjusting.
      • NOTE: didn't use Outcome.send() bc it would send the underlying
        value, not the outcome instance; not sure if that's best?
  • way better naming since most of this was whipped up on the
    first try and getting good names is probably going to be tricky 😂

    • @task_manager is maybe better? @task_scope_manager?
    • ScopePerTaskNursery is terrible.. how about TaskScopedNursery?
      • now i'm thinking you don't need much at all, TaskManagerNursery?
    • TaskOutcome.unwrap() seems wrong, despite it being an example of
      a user defined handle.
      • @Fuyukai suggested .wait_for_result() which is a lot more
        pythonic in terms of plain-ol'-simple-semantics 🏄
      • maybe .unwind() is more descriptive? as in unwinding the outcome
        from the task would imply you both have to:
        • wait for the result or error from the underlying task
        • retrieve that outcome's value or raise it's error by unwrapping/boxing?
        • stack unwinding is normally synonymous with the end of
          a function call and the subsequent popping of the stack ..?
      • the nixed above ends up going down the rabbit hole of describing
        an imperative lang in terms of lower level fp abstractions which
        is not only confusing but a bit moot: we don't need to hear math
        about stuff we can't change in (python and) the semantics of
        a method..:joy:
  • test the waters in trio core for interest in this idea

    • had a decent discussion on it with the anyio author and njs but
      don't think they understood what i was proposing (hence this proto
      😂)
    • the main spot to start digging into how this might be implemented
      inside trio's scheduler would be about here:
      https://github.com/python-trio/trio/blob/master/trio/_core/_run.py#L1601
      since this is where the nursery-global cancel scope's CancelStatus
      is activated on the currently spawning task.
      • obviously it's going to require quite a bit of reworking to try
        and offer the generator-style @cancel_scope_manager thing
        because we'll need to call __enter__/__exit__ from two
        different Runner methods -> .spawn_impl() and .task_exited()
    • probably just eventually accept that no-one will comment on this
      PR (nor care?) and it'll get relegated to our trionics secret
      sauces pantry yet again 😂
  • figure out if it's possible to swap in this open_nursery()
    for all uses of trio.open_nursery() when our users enable debug mode
    😂

Hopefully much more to come!

return self.result


class TaskHandle(Struct):
Copy link
Owner Author

@goodboy goodboy May 17, 2023

Choose a reason for hiding this comment

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

TODO: show an second example where the cancel_scope_manager generator wraps the trio internals listed as attrs into this compound type and then that is what's returned from the .start_soon() call?


scope_manager: ContextManager | None = None

async def start_soon(
Copy link
Owner Author

Choose a reason for hiding this comment

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

I don't really know what the best API would be for doing this with .start()?

  1. you don't really need start since you can always implement it by getting the child task to Event.set(), or even further use this MaybeOutcome thing from above.
  2. how do you get the cancel_scope_manager yielded output in that case, since .start() would normally return whatever the child passed to status.started()?
  • i.e. you can't do started = await n.start(blah) and expect started to be the outcome, cs stuff
  1. maybe the child task could get access to it's own cancel scope "stuff" from somewhere and then choose to status.started((outcome, cs)) it back to the caller?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Huh, i guess we could just offer some kinda support for hooking into the started value returned by the task? that way the cancel_scope_manager could decide what to send back to the .start() caller with the default being the value that the child .started() ?

Copy link
Owner Author

Choose a reason for hiding this comment

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

huh, likely you could implement that as a double yield generator then?

I dunno what would be best for ensuring that other then decorator params and then async_fn checking inside .start() against that i guess?


val: str = 'yoyoyo'
val_outcome, cs = await sn.start_soon(sleep_then_return_val, val)
res = await val_outcome.unwrap()
Copy link
Owner Author

Choose a reason for hiding this comment

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

As already mentioned above in the comment on the method, probably a better name is warranted for this since you're actually first waiting for the outcome then unwrapping it's underlying value/result.

assert res == val
print(f'GOT EXPECTED TASK VALUE: {res}')

print('WAITING FOR CRASH..')
Copy link
Owner Author

Choose a reason for hiding this comment

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

scheduling sleep_then_err() results in an assertion error that is caught and handled in the pdbp REPL thanks to the cancel_scope_manager implementation which does this on std Exceptions 😎

Note, you could also catch trio.Cancelled or wtv else by changing the except line 🏄🏼


async def _start_wrapped_in_scope(
task_status: TaskStatus[
tuple[CancelScope, Task]
Copy link
Owner Author

Choose a reason for hiding this comment

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

wrong, this should be the type sig of whatever is yielded from the self.scope_manager obviously


# TODO: better way to concat the values delivered by the user
# provided `.scope_manager` and the outcome?
return tuple([maybe_outcome] + to_return)
Copy link
Owner Author

Choose a reason for hiding this comment

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

yeah as mentioned in the TODO, don't love this concat-ing of the results from the scope_manager(), but i think this goes away if we instead implement our own generator as @trio.cancel_scope_manager protocol since then the absolute value tuple yielded can be returned directly?

# TODO: this was working before?!
# nonlocal to_return

with cs:
Copy link
Owner Author

Choose a reason for hiding this comment

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

Ahh wait, right.. so we do need to activate this here or can it be done in the user's @task_scope_manager thing?

# task = new_tasks.pop()

n: Nursery = self._n
cs = CancelScope()
Copy link
Owner Author

Choose a reason for hiding this comment

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

Do we need this allocated here?
See comment(s) below.

@goodboy goodboy changed the title Initial prototype for a one-cancels-one style supervisor, nursery thing? Initial prototype for trio.Nursery per-task-scope-mangement via a user defined single-yield-generator function 😎 May 19, 2023
As was listed in the many todos, this changes the `.start_soon()` impl
to instead (manually) `.send()` into the user defined
`@task_scope_manager` an `Outcome` from the spawned task. In this case
the task manager wraps that in a user defined (and renamed)
`TaskOutcome` and delivers that + a containing `trio.CancelScope` to the
`.start_soon()` caller. Here the user defined `TaskOutcome` defines
a `.wait_for_result()` method that can be used to await the task's exit
and handle it's underlying returned value or raised error; the
implementation could be different and subject to the user's own whims.

Note that by default, if this was added to `trio`'s core, the
`@task_scope_manager` would simply be implemented as either a `None`
yielding single-yield-generator but more likely just entirely ignored
by the runtime (as in no manual task outcome collecting, generator
calling and sending is done at all) by default if the user does not provide
the `task_scope_manager` to the nursery at open time.
Turns out the nursery doesn't have to care about allocating a per task
`CancelScope` since the user can just do that in the
`@task_scope_manager` if desired B) So just mask all the nursery cs
allocating with the intention of removal.

Also add a test for per-task-cancellation by starting the crash task as
a `trio.sleep_forever()` but then cancel it via the user allocated cs
and ensure the crash propagates as expected 💥
- drop unneeded (and commented) internal cs allocating bits.
- bypass all task manager stuff if no generator is provided by the
  caller; i.e. just call `.start_soon()` as normal.
- fix `Generator` typing.
- add some prints around task manager.
- wrap in `TaskOutcome.lowlevel_task: Task`.
return self.result


class ScopePerTaskNursery(Struct):
Copy link
Owner Author

Choose a reason for hiding this comment

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

Heh, guess we don't need much of a special name now?

PerTaskHookableNursery 😂

*args,

name=None,
scope_manager: ContextManager | None = None,
Copy link
Owner Author

Choose a reason for hiding this comment

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

maybe task_manager is a better name?

name=None,
scope_manager: ContextManager | None = None,

) -> tuple[CancelScope, Task]:
Copy link
Owner Author

@goodboy goodboy May 19, 2023

Choose a reason for hiding this comment

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

needs to again be dynamically defined at runtime.. soo use the ParamSpec stuff?


await trio.sleep(0.6)
print(
'Cancelling and waiting on {err_outcome.lowlevel_task} '
Copy link
Owner Author

Choose a reason for hiding this comment

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

lul, missing f-string

@goodboy goodboy force-pushed the oco_supervisor_prototype branch 2 times, most recently from cda85a3 to b24f0d5 Compare May 19, 2023 19:33
# TODO: define a decorator to runtime type check that this a generator
# with a single yield that also delivers a value (of some std type) from
# the yield expression?
# @trio.task_scope_manager
Copy link
Owner Author

Choose a reason for hiding this comment

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

maybe just @trio.task_manager ?

def add_task_handle_and_crash_handling(
nursery: Nursery,

# TODO: is this the only way we can have a per-task scope
Copy link
Owner Author

Choose a reason for hiding this comment

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

no, we don't need the nursery to allocate any cancel scope per task interally!

need to remove this!

@goodboy goodboy force-pushed the oco_supervisor_prototype branch 3 times, most recently from aed71dc to 0025e09 Compare May 19, 2023 19:43
Allows dynamically importing `pdbp` when enabled and a way for
eventually linking with `tractor`'s own debug mode flag.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant