Serve a blackboard over HTTP¶
Agents that run as their own services read and write over HTTP. The operations
they need are the operations Control already has, so the library states which
path carries each operation and answers it; your service keeps its
own HTTP server, its framework, and its authentication.
BoardService.handle takes a method, a path, a decoded body, and the query
parameters, and returns a status, headers, and a body. It imports no web
framework and opens no socket.
Mounting it¶
BoardService turns one request into one answer. It takes the store, so every
replica answers a read for any board the store holds, and a callable that finds
the Control a request names, so a write reaches the callables that board
was created with.
service = BoardService(control_for=runs.get, store=store, prefix="/v1")
Writes still need the run, and answer 404 without one. A board that the store never held answers 404 for reads and for writes alike.
blackboard.reader_for(store, board_id) builds the same reader that those
reads are answered from, for a caller that wants one of its own without a run.
It
has the four read operations and nothing else.
Finding the control a request names¶
on_open and on_closed on create_model are how runs
fills and empties:
def opened(model):
runs[model.board_id] = model.control
model = create_model(
...,
board_id=board_id,
on_open=opened,
on_closed=lambda outcome: runs.pop(board_id, None),
)
on_open is given the model and reads the board identifier off it.
on_closed is given the outcome, which names no board, so the identifier
comes from the call that opened the run.
on_open runs before the first agent is woken, which matters because waking
an agent runs its callback on this thread: without that ordering, an agent that
reads back through this service meets 404 for the board that is being created.
One route, whatever the framework¶
from fastapi import FastAPI, Request as Incoming
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse, Response as Bare
from blackboard import Control
from blackboard.server import BoardService, Request
runs: dict[str, Control] = {}
service = BoardService(control_for=runs.get, prefix="/v1")
app = FastAPI()
@app.api_route("/v1/{rest:path}", methods=["GET", "POST", "PUT"])
async def blackboard(rest: str, incoming: Incoming):
# handle reaches the store, which blocks. On a thread, so it does not
# block the event loop and every other request on this worker with it.
answer = await run_in_threadpool(
service.handle,
Request(
method=incoming.method,
path=incoming.url.path,
body=await _json(incoming),
query=dict(incoming.query_params),
),
)
if answer.body is None:
return Bare(status_code=answer.status, headers=dict(answer.headers))
return JSONResponse(
answer.body, status_code=answer.status, headers=dict(answer.headers)
)
async def _json(incoming: Incoming) -> object:
if not await incoming.body():
return None
return await incoming.json()
from flask import Flask, jsonify, request
from blackboard import Control
from blackboard.server import BoardService, Request
runs: dict[str, Control] = {}
service = BoardService(control_for=runs.get, prefix="/v1")
app = Flask(__name__)
@app.route("/v1/<path:rest>", methods=["GET", "POST", "PUT"])
def blackboard(rest: str):
answer = service.handle(
Request(
method=request.method,
path=request.path,
body=request.get_json(silent=True),
query=request.args.to_dict(),
)
)
body = jsonify(answer.body) if answer.body is not None else ""
return body, answer.status, dict(answer.headers)
handle raises nothing you have to catch. A board that this service does not
hold, a region that nobody declared, a body that will not decode, and a run
that
has closed all come back as a status and a body.
The operations¶
| Method | Path | Answers |
|---|---|---|
GET |
/boards/{board_id}/regions |
Every declared region and its kind |
GET |
/boards/{board_id}/levels/{level} |
A page of that level |
GET |
/boards/{board_id}/premises/{premise} |
Its current value and version |
GET |
/boards/{board_id}/changes |
A page of every write, in order |
POST |
/boards/{board_id}/levels/{level} |
The sequence the write took |
PUT |
/boards/{board_id}/premises/{premise} |
The version the set produced |
POST |
/boards/{board_id}/acknowledgements |
Nothing |
The paths above are blackboard.wire.OPERATIONS, and the client and the
service build them from those objects rather than from a string of their own.
Mount the prefix that you gave BoardService; the rest of each path is the
library's.
Creating a board is not one of them¶
There are seven, and none creates a board. create_model is a function you
call, not an operation an agent calls, and it could not be one: its arguments
include the admission rule and the termination predicate, and a request body
carries no callable.
So a service that creates boards on request mounts an endpoint of its own,
receives whatever its callers send, and calls create_model with the rules
taken from its own code:
@app.post("/incidents")
def open_incident(body: dict) -> dict:
model = create_model(
board_id=body["incident_id"],
store=store,
regions=REGIONS_FOR[body["kind"]],
premises={"severity": body["severity"]},
agents=[
Agent(
name=a["name"],
subscribes_to=a["subscribes_to"],
address=a["address"],
)
for a in body["agents"]
],
limits=LIMITS,
admission_rule=no_duplicate_findings,
termination_predicate=until_a_cause_is_agreed,
reach=notifier.reach,
)
return {"board_id": model.board_id}
The agents can come from the request because what an agent is made of is
data: its name, what wakes it, what it may write to, and where it is reached.
All four are written to the run, so the replica that took this request is not
the one that has to serve the next. The callable is the one part that cannot
arrive this way, which is why reach is given here and comes from the
process.
The regions, the rules and the limits come from the application, whether hardcoded, chosen by a template per kind of work, or looked up. Which of those is right is the application's decision and no part of this library.
The two reads that return a page take limit and from_sequence as query
parameters. A read that names no limit answers with
blackboard.wire.DEFAULT_LIMIT rows, which is 100. A limit above
blackboard.wire.MAX_LIMIT, which is 1000, is capped at that maximum silently.
A page
says has_more when it stopped early, and the reader continues from one past
the last sequence it received. A sequence number is the cursor because an
offset shifts when a concurrent write lands.
A read in process takes limit=None and means unbounded, because it is not
paying for a page. A read over HTTP is a page, with or without a size from the
caller.
What a request body carries¶
| Operation | Body | Required |
|---|---|---|
POST .../levels/{level} |
WriteRequest |
writer, content |
PUT .../premises/{premise} |
SetPremiseRequest |
writer, value, expected_version |
POST .../acknowledgements |
AckRequest |
agent, notification_id |
content and value are required because a body without the one its operation
takes would otherwise store null, and setting a premise to null wakes every
subscriber to it.
WriteRequest.level and SetPremiseRequest.premise are optional and are
ignored where the path names the region, which it always does on these
routes. They exist so that a service mounting a single route of its own still
receives a body that says what it is.
What the status codes mean¶
| Status | Meaning | Body |
|---|---|---|
| 200 | A read, or a write this key had already made | What the read asked for, or a WrittenBody with repeated set |
| 201 | A write reached the board for the first time | WrittenBody |
| 204 | An acknowledgment was recorded | No body |
| 400 | The body or a query parameter could not be read | ErrorBody, bad_body or bad_query |
| 404 | No such route, board, or region, a region of the other kind, a premise nothing has written, or a notification never issued to that agent | ErrorBody, one of no_such_route, unknown_board, unknown_region, wrong_region_kind, unset_premise, unknown_notification |
| 405 | That path takes a different method | ErrorBody, wrong_method, with an Allow header |
| 409 | A premise moved on, or an idempotency key named another region | ConflictBody, or ErrorBody naming idempotency_key_reused |
| 410 | The run has closed and takes no more writes | ErrorBody, run_closed |
| 422 | The control component refused the write | RejectedBody, whose cause is admission or not_permitted |
Every 4xx here is an answer that the blackboard decided rather than a fault in the transfer, so repeating the request is not what resolves it. Only a 5xx and a failure to connect are worth another attempt.
An ErrorBody carries error and detail. error is the stable name to
branch on. detail is written for a person reading a log and changes between
versions.
409 carries two bodies. A premise write whose expected version is not the
current one answers a ConflictBody, which names the version that the premise
now holds, and the caller reads the premise and decides again against it. A
write
whose idempotency key already named another region answers an ErrorBody
naming idempotency_key_reused, and sending it again answers the same. A
client tells the two apart by the presence of an error key in the body, which
is
what BoardClient does.
A region that nobody declared is 404 unknown_region, and a level operation on
a
premise, or a premise operation on a level, is 404 wrong_region_kind. The
region names come from the application's own declarations rather than from
anything this run decided, so a request naming one that does not exist is
answered the same way on every operation, reads and writes alike. In process
the same write raises UndeclaredRegionError rather than returning a
rejection. The 422 carries what the run decided about a write it understood:
the admission rule returned Reject, or the writer is a registered agent
whose writes_to does not name that level.
Writing once¶
A write body may carry an idempotency_key. The blackboard writes one key
once: a key that it has already written answers 200 with the first write's
sequence and repeated, and adds nothing, whereas a first write answers 201. A
key that already named another region is refused 409 with error set to
idempotency_key_reused, and in process the same write raises
IdempotencyKeyError.
Nothing in the service does that work. The key goes to the control component and from there to the store, where the row and its key are written together. Storage covers what a store guarantees.
The path names the region¶
A write is addressed to /boards/{id}/levels/{level}, and that is the level
it lands in. A body naming a different one changes nothing, so a caller
cannot write somewhere it did not address, and a gateway policy written
against the path holds.
Authentication¶
The library has no authentication. The service is behind your gateway, the
gateway
authenticates the caller, and each operation has a path and a method for a
policy to be written against. A read-only agent is given the four GET paths
and nothing else.
handle does not check that the writer in a body is the caller. Do that in
the route, before calling in, from whatever the gateway put on the request.
Board identifiers¶
handle matches the path a segment at a time and decodes each variable once,
so an identifier holding a slash arrives whole as long as your framework
hands over the path it received. Some frameworks decode the path first. A UUID
travels through every framework unchanged, and is the identifier to use.
Notifying the other way¶
This page covers what an agent sends. Notify agents over
HTTP covers what the blackboard sends, and that direction
needs the notifier extra. blackboard.server needs no extra: it is in the
base install
and depends on nothing.