microsoft / microsoft/bocpy

Structured concurrency for bocpy: scoped `behaviors()` + `Terminator`

Open
#47 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

design
Dominant language
Python
Stars
184
Forks
9
PR merge metrics
No merged PRs in 30d

Description

Problem

bocpy today gives you @when(*cowns) and a single process-global rundown: wait() blocks until everything in the process has quiesced. That coarse rendezvous is fine for scripts and demos, but it has two sharp limits:

  1. You can't wait for just your work. There's no way to say "block until this chunk of behaviors finishes" without also waiting for every unrelated behavior the same process happens to be running.
  2. You can't wrap bocpy code in a synchronous API. Exposing behaviors and cowns to bocpy-aware consumers works beautifully. But if you want to hand a plain, synchronous function to a non-bocpy caller -- one that internally schedules behaviors and returns a finished result -- your only rendezvous is wait() / quiesce(), both of which drain the entire process, not just your call's work. So you can't cleanly build a blocking facade over bocpy internals.

We want structured concurrency: a first-class way to scope a unit of work, wait for exactly that work (including anything it transitively spawns), and get a result back -- without disturbing the rest of the process.

Proposed surface

A behaviors() scope
from bocpy import Cown, when, behaviors

c = Cown(0)

with behaviors() as scope:
    @when(c)
    def increment(c):
        c.value += 1

    @when(c)                # transitively belongs to `scope`
    def announce(c):
        print(f"c is {c.value}")

# The `with` block exits only once both behaviors have finished.
# Behaviors running elsewhere in the process keep going undisturbed.

Work scheduled from inside the scope -- even a deep chain of @when callbacks hopping across worker sub-interpreters -- belongs to the same scope, so the block correctly waits for all transitive work.

An explicit Terminator

The scope handle is a first-class object you can also build and pass by hand:

from bocpy import Terminator, when

t = Terminator()

@when(c, terminator=t)
def isolated(c):
    ...

assert t.wait(timeout=2.0)
A result value out of the scope

Pass the scope as one of a behavior's cowns and assign scope.value:

with behaviors() as scope:
    @when(a, b, scope)
    def summarise(a, b, scope):
        scope.value = a.value + b.value

print(scope.value)          # None if nothing wrote it

scope is acquired like any other cown, so the write is race-free and committed by the time the block exits. Single slot, last-writer-wins; the same discipline makes reductions (scope.value = (scope.value or 0) + k) safe.

Optional per-scope snapshots
with behaviors(stats=True, noticeboard=True) as scope:
    do_pipeline()

scope.stats        # scheduler-stats delta for just this scope's window
scope.noticeboard  # noticeboard snapshot taken at quiescence

What stays the same

  • @when, Cown, whencall, send/receive, and the noticeboard keep their exact signatures and semantics.
  • Omitting terminator= / not opening a scope -> today's process-global behavior.
  • The public C ABI is untouched; downstream C extensions need no changes.

Guard rails (behavior changes to be aware of)

  • Calling wait() / quiesce() inside a with behaviors(): block raises instead of silently deadlocking.
  • A user Terminator that is still alive blocks the next runtime start().
  • A behavior body declaring a parameter literally named terminator raises at decoration time (reserved name).

Feedback welcome

  • Naming: behaviors() vs. something like scope() / nursery()? scope.value vs. scope.result?
  • Should stats / noticeboard snapshots ship in the first cut, or land as a follow-up?
  • Anything in the guard rails above that would surprise you in real code?

The design is written up in full in PLAN.md on the branch -- this issue is the short version to drive discussion while the work is in flight.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with PLAN.md on the branch, then trace the existing @when, wait(), and quiesce() behavior described in the issue. The proposed work spans behaviors() scopes, Terminator propagation, result values, and optional snapshots across worker sub-interpreters. Done means the agreed design is implemented without changing existing APIs, with the guard rails and transitive waiting behavior covered.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
distributed-systems
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.