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,
                   receive, send, 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
            # over a message channel (the noticeboard is torn down by
            # wait(), so it cannot carry the result back to main).
            notice_write("done", True)
            send("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)

# Collect the first final answer produced by any worker, then drain.
answer = receive("answer")[1]
wait()
print("final answer:", answer)
# After wait(), the noticeboard is torn down; "partials" is no longer
# readable. Snapshot it from inside a behavior before wait() returns
# if you need to inspect it.

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 delivered over the message queue rather than the noticeboard, because wait() tears the noticeboard down before control returns to the main thread.

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

Reading the Noticeboard

From 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.

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.

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.

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.

Must be called from within a @when behavior. The first call within a behavior captures all entries under mutex and caches the data. Subsequent calls in the same behavior return a view of the same cached data.

The returned mapping is read-only.

Calling from outside a behavior (e.g. the main thread) will return a snapshot that is never refreshed for that thread.

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.

Must be called from within a @when behavior. Convenience wrapper that takes a snapshot and returns one value.

Calling from outside a behavior (e.g. the main thread) will return a snapshot that is never refreshed for that thread.

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.