Noticeboard

The noticeboard is a global key-value store (up to 64 entries) designed for cross-behavior data sharing that does not warrant a dedicated Cown. It is eventually consistent: writes are fire-and-forget, and readers see a snapshot that may lag behind the latest committed state.

When to Use the Noticeboard

Use the noticeboard when:

  • Multiple behaviors need to observe shared configuration or summary data without taking exclusive ownership.

  • You want to broadcast a value (e.g., “stop” flag, running totals, discovered results) that many independent behaviors can poll.

  • The data does not need strict read-your-writes ordering between behaviors.

If you need strict sequencing or exclusive access, use a Cown instead.

Consistency Model

All mutations (notice_write, notice_update, notice_delete) are serialized through a dedicated noticeboard thread. The calling behavior (or thread) hands off the mutation and returns immediately — this is the “fire-and-forget” property.

Readers call noticeboard() or notice_read() to take a snapshot that is cached for the lifetime of the behavior. The snapshot is consistent (all entries come from the same committed version), but may not reflect writes that were posted after the snapshot was taken — or even writes posted before it, if the noticeboard thread has not yet committed them.

Important

The noticeboard is not a synchronization channel. Do not rely on a subsequent behavior seeing a prior behavior’s write just because the two are chained through a cown. If you need read-your-writes ordering, model the shared state as a Cown instead.

Worked Example: Early Termination

The prime_factor example uses the noticeboard to coordinate early termination across parallel worker behaviors. A simplified version of the pattern:

Note

expensive_computation, is_final_answer and get_work below are placeholders for application logic; substitute your own when adapting the example.

from functools import partial
from bocpy import (Cown, notice_read, notice_update, notice_write,
                   wait, when)


def append_result(existing, new_item):
    """Append new_item to the shared partials list (used with notice_update)."""
    return existing + [new_item]


class WorkerState:
    def __init__(self, worker_id, items):
        self.worker_id = worker_id
        self.items = items


def process_batch(state: Cown[WorkerState]):
    @when(state)
    def _(state):
        # Check if another worker already signalled completion.
        if notice_read("done", False):
            return

        item = state.value.items.pop(0)
        result = expensive_computation(item)

        if is_final_answer(result):
            # Signal all other workers to stop and publish the answer
            # to the noticeboard. wait(noticeboard=True) will lift the
            # final state back across runtime shutdown for us.
            notice_write("done", True)
            notice_write("answer", result)
        else:
            # Record the partial result for diagnostics, then continue.
            notice_update("partials", partial(append_result, new_item=result),
                          default=[])
            if state.value.items:
                process_batch(state)


# Launch parallel workers.
workers = [Cown(WorkerState(i, get_work(i))) for i in range(4)]
for w in workers:
    process_batch(w)

# Drain the runtime and lift the final noticeboard state back. See
# "Reading the Final State at Shutdown" below for the API contract.
final = wait(noticeboard=True)
print("final answer:", final.get("answer"))
print("partials:    ", final.get("partials", []))

Key points:

  • notice_write("done", True) is non-blocking — the worker doesn’t wait for the write to commit.

  • Other workers poll notice_read("done", False) at the start of each batch. They will eventually see the flag and stop.

  • notice_update("partials", append_result, ...) shows the read-modify- write pattern: append_result is run atomically against the current list, so concurrent appends from different workers don’t lose entries.

  • The final answer is lifted back through wait() with noticeboard=Truewait() snapshots the noticeboard between joining the noticeboard thread and clearing the C-side entries, so the caller gets a plain dict of the last committed state.

  • The pattern is cooperative: there is no hard cancellation. Workers stop at the next polling point.

Reading the Final State at Shutdown

The noticeboard is torn down at the end of wait(): the dedicated mutator thread is joined and the C-side entries are freed before control returns to the main thread. To carry data back across that boundary, pass noticeboard=True:

from bocpy import wait

snap = wait(noticeboard=True)        # plain dict[str, Any]
print(snap.get("answer"))

The snapshot is taken on the main thread between joining the noticeboard thread and clearing the entries, so every mutation enqueued by a behavior that completed before wait() returns is visible.

The combined form returns a WaitResult NamedTuple carrying the scheduler-stats snapshot alongside the noticeboard:

result = wait(stats=True, noticeboard=True)
print(result.noticeboard.get("answer"))
print(result.stats[0]["popped_local"])

Edge cases:

  • If the runtime was never started (or already torn down), the noticeboard snapshot is the empty dict {} rather than None.

  • If a key your code expects might not have been written before quiescence, use snap.get(key) (or check key in snap) rather than indexing — wait() quiesces as soon as every behavior completes, with no guarantee any particular write happened.

  • The returned dict is a plain mutable dict; mutating it locally does not affect the (now-freed) noticeboard.

Reading the State Between Rounds

When you want a noticeboard snapshot at a synchronization point without tearing the runtime down — e.g. a parallel search that inspects its best-so-far state between rounds and then keeps working — use quiesce() with noticeboard=True:

from bocpy import quiesce

snap = quiesce(noticeboard=True)  # plain dict[str, Any]
print("best so far:", snap.get("best"))
# ... next batch of @when calls runs immediately ...

quiesce() blocks until every in-flight behavior completes, captures the snapshot the same way wait() does (by cycling the dedicated mutator thread, which guarantees every prior notice_write / notice_update / notice_delete has been committed before the read), and then leaves the workers and the noticeboard thread running. The combined stats=True, noticeboard=True form returns a WaitResult just like wait().

Reading the Noticeboard

The noticeboard is a behavior-scope read surface. Inside a behavior, call noticeboard() to get a read-only mapping of all entries, or notice_read() for a single key:

from bocpy import noticeboard, notice_read, when, Cown

c = Cown(None)

@when(c)
def _(c):
    # Full snapshot
    snap = noticeboard()
    for key, value in snap.items():
        print(f"{key} = {value}")

    # Single key with a default
    threshold = notice_read("threshold", 0.5)

The snapshot is taken once per behavior and cached – multiple calls to noticeboard() or notice_read() within the same behavior return data from the same point in time.

Cowns embedded in a noticeboard entry remain valid for the lifetime of the entry; they survive as long as the entry has not been overwritten or deleted, regardless of how many readers have observed the entry.

Warning

Calling noticeboard() or notice_read() from the main thread outside a behavior is undefined behavior. The only supported ways to read the noticeboard from the main thread are wait() with noticeboard=True (see “Reading the Final State at Shutdown” above) and quiesce() with noticeboard=True (see “Reading the State Between Rounds” above). Seeding the noticeboard with notice_write() from the main thread before scheduling behaviors is fine and is the recommended pattern for installing read-mostly configuration.

Writing and Updating

notice_write() sets a key unconditionally:

from bocpy import notice_write

notice_write("config.max_retries", 3)
notice_write("status", "running")

notice_update() performs an atomic read-modify-write. The function fn receives the current value (or default if the key is absent) and returns the new value:

from functools import partial
from operator import add
from bocpy import notice_update

# Increment a counter
notice_update("counter", partial(add, 1), default=0)

# Append to a list
def append_item(lst, item):
    return lst + [item]

notice_update("results", partial(append_item, item="found!"), default=[])

Warning

fn must be picklable — lambdas and closures are not. Use functools.partial with module-level functions, or operator functions.

If fn returns REMOVED, the entry is deleted:

from bocpy import notice_update, REMOVED

def clear_if_empty(value):
    return REMOVED if not value else value

notice_update("buffer", clear_if_empty, default=[])

Deleting Entries

notice_delete() removes a single key (no-op if absent):

from bocpy import notice_delete

notice_delete("temporary_flag")

notice_sync (Testing Only)

notice_sync() blocks until every mutation the calling thread has posted so far has been committed by the noticeboard thread. It exists to make the noticeboard’s eventual consistency tractable for tests — a test can write a value, call notice_sync(), and then assert that a subsequently scheduled behavior observes the write — not as a primitive for application code.

Warning

Outside of tests, reaching for notice_sync is almost always an anti-pattern. The guarantee it provides is much weaker than it looks:

  • It only orders the calling thread’s prior writes against the next per-behavior snapshot taken on any thread. Snapshots are captured once per behavior, so a behavior already executing when notice_sync returns will keep seeing its existing snapshot.

  • It does not refresh the calling behavior’s own snapshot — you cannot notice_sync and then notice_read to see your write.

  • It establishes no happens-before relationship between unrelated behaviors and is not a substitute for cown-mediated ordering.

If application code needs read-your-writes ordering, model the shared state as a Cown. If you find yourself wanting notice_sync outside a test, that is a strong signal the noticeboard is the wrong primitive for the problem.

API Reference

bocpy.notice_write(key, value)

Write a value to the noticeboard.

The write is fire-and-forget: the value is serialized immediately and handed to a dedicated noticeboard thread, which applies it under mutex. notice_write returns as soon as the message is enqueued.

No ordering guarantee. A subsequent behavior — even one that chains directly off the writer through a shared cown — is not guaranteed to observe this write. The noticeboard mutator runs on its own thread and may not have processed the message by the time the next behavior reads. Treat the noticeboard as eventually consistent shared state, never as a synchronization channel between behaviors. Use cowns or send/receive for that.

The noticeboard supports up to 64 distinct keys. Writes beyond the limit are not applied; the noticeboard thread catches the resulting error and logs a warning. No exception propagates to the caller.

Values may embed Cown references; the noticeboard keeps each embedded cown alive for as long as the entry remains in the noticeboard.

Parameters:
  • key (str) – The noticeboard key (max 63 UTF-8 bytes).

  • value (Any) – The value to store.

Return type:

None

bocpy.notice_update(key, fn, default=None)

Atomically update a noticeboard entry.

Reads the current value for key (or default if absent), applies fn to it, and writes the result back. The read-modify-write is atomic because the single-threaded noticeboard mutator performs all three steps without interleaving.

Like notice_write(), the call is fire-and-forget and carries no ordering guarantee with respect to other behaviors. The update is processed on the noticeboard thread; subsequent behaviors may or may not observe the result.

Both fn and default must be picklable — they are serialized and sent to the noticeboard thread via the message queue. Lambdas and closures are not picklable; use functools.partial with a module-level function or an operator function instead:

import operator
from functools import partial
notice_update("total", partial(operator.add, 5), default=0)
notice_update("best", partial(max, 42), default=float("-inf"))

If fn raises, the key retains its previous value and a warning is logged by the noticeboard thread.

Important: fn runs synchronously on the single-threaded noticeboard mutator. It must be fast, pure (no side effects), and must not call any bocpy API (notice_write, send, when, etc.). A blocking or expensive fn will stall every other noticeboard mutation.

If fn returns the REMOVED sentinel, the entry is deleted from the noticeboard instead of being updated.

The value returned by fn may embed Cown references; the noticeboard retains them until the entry is overwritten or deleted, identical to notice_write().

Warning

fn and default are pickled and sent to the noticeboard thread for execution. Anyone with permission to call this function can therefore cause arbitrary Python code to run on the noticeboard thread, which holds the privileged noticeboard-mutator role. In the current threat model bocpy treats all code running in the runtime (primary and sub-interpreters) as equally trusted, so this is no worse than any other cross-interpreter message. If you need to run untrusted behavior code, restrict what can reach boc_noticeboard and audit callers of notice_update().

Parameters:
  • key (str) – The noticeboard key (max 63 UTF-8 bytes).

  • fn (Callable[[Any], Any]) – A picklable callable taking the current value, returning the new.

  • default (Any) – Value used when key does not yet exist.

Return type:

None

bocpy.notice_delete(key)

Delete a single noticeboard entry.

The deletion is fire-and-forget: the request is sent to the noticeboard thread, which removes the entry under mutex. If the key does not exist, the operation is a no-op. Like notice_write(), this carries no ordering guarantee with respect to other behaviors.

Alternatively, use notice_update with a function that returns REMOVED to conditionally delete an entry based on its current value.

Parameters:

key (str) – The noticeboard key to delete (max 63 UTF-8 bytes).

Return type:

None

bocpy.noticeboard()

Return a cached snapshot of the noticeboard.

The noticeboard is a behavior-scope read surface. The supported use is from inside a @when body: the first call captures all entries under mutex and caches them, and every subsequent call in the same behavior returns the same cached view.

The returned mapping is read-only.

The only supported way to read the noticeboard from the main thread is to ask wait() for it via wait(noticeboard=True) (or wait(stats=True, noticeboard=True)); that snapshot is taken on the main thread between joining the noticeboard mutator thread and clearing the C-side entries.

Calling noticeboard() or notice_read() from any other main-thread context (outside a behavior, outside wait(noticeboard=True)) is undefined behavior: the cached proxy is never re-anchored on a behavior boundary, so subsequent calls may observe either a stale snapshot or partially-applied writes.

Seeding the noticeboard with notice_write() from the main thread before scheduling behaviors is fine and is the recommended pattern for installing read-mostly configuration.

Returns:

A read-only mapping of keys to their stored values.

Return type:

Mapping[str, Any]

bocpy.notice_read(key, default=None)

Read a single key from the noticeboard.

Convenience wrapper over noticeboard() that takes a snapshot and returns one value. The same supported-usage contract applies: call from inside a @when behavior, or read the final state on main via wait(noticeboard=True). Calling notice_read() from any other main-thread context is undefined behavior.

Parameters:
  • key (str) – The noticeboard key to read.

  • default (Any) – Value returned when key is absent.

Returns:

The stored value, or default if the key does not exist.

Return type:

Any

bocpy.notice_sync(timeout=30.0)

Block until the caller’s prior noticeboard mutations are committed.

Because notice_write(), notice_update(), and notice_delete() are fire-and-forget, a behavior that wants read-your-writes ordering against a subsequent behavior must call notice_sync() after its writes. The call posts a sentinel onto the boc_noticeboard tag (which is FIFO per producer) and blocks until the noticeboard thread has drained that sentinel. By the time this returns, every write/update/delete posted from the calling thread before the sentinel has been applied to the noticeboard.

The barrier carries no ordering guarantee with respect to writes posted from other threads or behaviors interleaved with the caller’s; it only flushes the caller’s own queued mutations.

Parameters:

timeout (Optional[float]) – Maximum seconds to wait. None waits forever. Defaults to 30 seconds.

Raises:
  • TimeoutError – If the noticeboard thread does not drain the caller’s sentinel within timeout seconds.

  • RuntimeError – If the runtime is not started.

Return type:

None

bocpy.REMOVED = REMOVED

Sentinel returned by notice_update fn to delete the entry.