Async mediator

The mediator/async package (@mediator_async) is the async form of the mediator. A handler is an async function, so it can wait or do I/O through moonbitlang/async. The shape is the same as the core: typed values, and the compiler is the registry. There is no text key and no runtime lookup.

Import the package with an explicit alias, because its last path segment is async, the same as moonbitlang/async:

import {
  "moonrockz/moon-lander/mediator/async" @mediator_async,
  "moonbitlang/async",
}

In your package, moonbitlang/async keeps its default alias @async. The examples on this page run as tests of the package itself, where async is the name of the package, so they import moonbitlang/async as @aio. Write @async.sleep where an example has @aio.sleep.

Target Supported Note
native, js Yes
wasm Yes The host must supply the moonbitlang/async event-loop imports. moonrun supplies them for tests
wasm-gc No moonbitlang/async has no event loop there. Use the sync @mediator

Each mbt check block on this page runs as a test with mise run test on wasm, js, and native.

Send a request

@mediator_async.sender takes one async handler and gives a Sender[C, Req, Res] value. The handler takes the context value and the request, can wait, and gives the reply. Sender::send waits for the reply.

Type parameter Meaning
C The context value that the caller gives to each send
Req The request type. A request of another type does not compile
Res The reply type of the handler
///|
struct Ping(String)

///|
async test "send a ping to a handler that waits" {
  let ping : @mediator_async.Sender[Unit, Ping, String] = @mediator_async.sender((
    _,
    req,
  ) => {
    @aio.sleep(10)
    let Ping(text) = req
    "Pong: " + text
  })
  assert_eq(ping.send((), Ping("Hello")), "Pong: Hello")
}

A sender calls its one handler one time for each send. An error that the handler raises passes out of send unchanged. The mediator does not turn an error into a reply.

Middleware

An async middleware wraps the next step of a send. It gets next, the context value, and the request. It can wait, run code before and after next, change the request or the reply, give a reply without a call to next, or catch the error of next. The helper names and the order rule are the same as in the core: Sender::use_ gives a new sender with one more middleware, and the first middleware that you attach is the outermost step.

Constructor Runs
@mediator_async.middleware(wrap) The async wrap(next, ctx, req), which gives the reply
@mediator_async.before(hook) The async hook(ctx, req) before the next step
@mediator_async.after(hook) The async hook(ctx, req, reply) after the next step gives a reply
@mediator_async.finally_(hook) The sync hook(ctx, req) after the next step: after a reply, after an error, and during a cancellation

The finally_ hook is a sync function, so it runs in a defer also when the caller cancels the send. A core @mediator.Middleware does not fit an async sender, because its next step is sync.

///|
async test "log middleware around an async send" {
  let log = []
  let ping : @mediator_async.Sender[Unit, Ping, String] = @mediator_async.sender((
    _,
    req,
  ) => {
    @aio.sleep(10)
    log.push("handler")
    let Ping(text) = req
    "Pong: " + text
  })
  let logged = ping
    .use_(
      @mediator_async.middleware((next, ctx, req) => {
        log.push("log in")
        let reply = next(ctx, req)
        log.push("log out")
        reply
      }),
    )
    .use_(@mediator_async.after((_, _, reply) => log.push("reply " + reply)))
    .use_(@mediator_async.finally_((_, _) => log.push("finally")))
  assert_eq(logged.send((), Ping("Hello")), "Pong: Hello")
  assert_eq(log, [
    "log in", "handler", "finally", "reply Pong: Hello", "log out",
  ])
}

Publish an event

@mediator_async.topic takes the first async event handler and gives a Topic[C, Ev] value. Topic::add gives a new topic with one more handler at the end, and Topic::use_ gives a new topic with one more middleware around the whole publish. A topic has two publish operations. Both follow the Foundatio.Mediator contract of the core: every handler runs, and a failing handler does not stop the others.

Operation Handlers run Foundatio model
publish One at a time, in add order. Each handler starts after the previous one ends ForeachAwait
publish_concurrent All at the same time, in one task group. The publish waits for all of them TaskWhenAll

When one or more handlers raise, both operations raise one @mediator.PublishFailed(handlers~, failures~) after the last handler ends. The failures are in add order, also when the handlers of a concurrent publish end in another order. A topic middleware gets that one error from its next and can catch it.

No publish returns before its handlers end. There is no fire-and-forget publish, because a handler error must not be lost and moonbitlang/async lets no task outlive its task group. An application that wants background work starts its own task group.

///|
struct Joined(String)

///|
suberror AuditError {
  AuditClosed(String)
}

///|
async test "a concurrent publish runs every handler and lists the failure" {
  let log = []
  let joined : @mediator_async.Topic[Unit, Joined] = @mediator_async.topic((
    _,
    ev,
  ) => {
    @aio.sleep(50)
    let Joined(name) = ev
    log.push("welcome " + name)
  })
  let all = joined.add((_, ev) => {
    @aio.sleep(10)
    let Joined(name) = ev
    raise AuditClosed(name)
  })
  try all.publish_concurrent((), Joined("Ann")) catch {
    @mediator.PublishFailed(handlers~, failures~) as error => {
      assert_eq(handlers, 2)
      assert_eq(failures.length(), 1)
      assert_eq(failures[0].position, 2)
      assert_true(failures[0].error is AuditClosed("Ann"))
      assert_eq(
        error.message(),
        "Publish failed: 1 of 2 handlers raised an error (position 2)",
      )
    }
    other => fail("publish raised another error: \{other}")
  } noraise {
    _ => fail("publish did not raise")
  }
  assert_eq(log, ["welcome Ann"])
}

Cancellation and timeouts

A caller cancels a send or a publish through moonbitlang/async, for example with with_timeout_opt around the call, or when the task group of the caller ends. The mediator never catches, wraps, or counts a cancellation:

Call When the caller cancels it
send The handler stops. The caller gets no reply and no mediator error
publish The running handler stops and the remaining handlers do not start
publish_concurrent Every running handler stops

A cancellation is never a failure in PublishFailed. catch cannot capture a cancellation, so a middleware that catches every error does not stop it. The failures that a sequential publish held before the cancellation are not reported. A caller that needs them publishes without a cancellation scope around the publish. The finally_ hook runs also during a cancellation. A handler that reacts to its own cancellation with a protected cleanup, and raises an ordinary error from it, gives that error from send, as a direct call of the handler does. A publish does not start the next handler after a cancellation and does not raise PublishFailed for it.

A time limit that a handler sets for itself, for example with with_timeout, raises an ordinary error. send passes it out, and a publish lists it as a failure and runs the other handlers.

@mediator_async.timeout(after_ms) is a middleware that gives the next step after_ms milliseconds. When the step does not end in time, the middleware cancels it and raises @mediator.TimedOut(after_ms~). Its message is Request timed out after <after_ms> ms and holds no request values.

///|
async test "a timeout stops a slow send, and a caller cancellation is not an error" {
  let log = []
  let slow : @mediator_async.Sender[Unit, Ping, String] = @mediator_async.sender((
    _,
    req,
  ) => {
    @aio.sleep(1000)
    log.push("handler ended")
    let Ping(text) = req
    "Pong: " + text
  })
  let limited = slow.use_(@mediator_async.timeout(50))
  try limited.send((), Ping("Hello")) catch {
    @mediator.TimedOut(_) as error =>
      assert_eq(error.message(), "Request timed out after 50 ms")
    other => fail("send raised another error: \{other}")
  } noraise {
    reply => fail("send gave a reply: \{reply}")
  }
  let reply = @aio.with_timeout_opt(50, () => slow.send((), Ping("Hello")))
  assert_eq(reply, None)
  assert_eq(log, [])
}

The context value and the core mediator

Each send and each publish takes a context value of type C. The mediator gives that exact value to each middleware and to each handler, also after a wait, and does not keep it after the call. A consumer service, such as the Morphir workbench or moonrockz/smithy, puts its services in a record and gives that record as the context.

The async package reuses the core error type @mediator.MediatorError, and it works with core values in two ways:

You have Use Result
One sync handler A one-line lambda: @mediator_async.sender((c, r) => handle(c, r)) An async sender. A named sync function does not fit an async function type, but the lambda does
A core @mediator.Sender @mediator_async.Sender::from_sync(sender) An async sender that runs the core middleware and handler, with the same reply and error
A core @mediator.Topic @mediator_async.Topic::from_sync(topic) An async topic whose publish runs the core publish and passes the core PublishFailed out unchanged

After add on a wrapped core topic, the core publish counts as one handler at position 1, and its error is the failure at that position. A core @mediator.Middleware does not fit an async sender; use the async helpers. There is no way to call an async sender from sync code.

///|
struct Store {
  users : Array[String]
}

///|
struct Services {
  store : Store
  greeting : String
}

///|
struct Register(String)

///|
/// A sync handler that the service already has.
fn greet(services : Services, req : Register) -> String {
  let Register(name) = req
  services.greeting + " " + name
}

///|
async test "a service registers a user and greets through the core sender" {
  let greeter : @mediator.Sender[Services, Register, String] = @mediator.sender((
    services,
    req,
  ) => greet(services, req))
  let greet_async = @mediator_async.Sender::from_sync(greeter)
  let register : @mediator_async.Sender[Services, Register, String] = @mediator_async.sender((
    services,
    req,
  ) => {
    @aio.sleep(10)
    let Register(name) = req
    services.store.users.push(name)
    greet_async.send(services, req)
  })
  let direct : @mediator_async.Sender[Services, Register, String] = @mediator_async.sender((
    services,
    req,
  ) => greet(services, req))
  let services = { store: { users: [] }, greeting: "Welcome" }
  assert_eq(register.send(services, Register("Ann")), "Welcome Ann")
  assert_eq(direct.send(services, Register("Bo")), "Welcome Bo")
  assert_eq(services.store.users, ["Ann"])
}

View source on GitHub