Topics and subscriptions
This guide reads one topic from several parts of a service, through subscriptions the service creates and subscriptions operations own.
A publisher sends to a topic. A subscription is a resource of its own, attached to one topic, and it keeps every message published to the topic after it was created. Each subscription of a topic receives every message. The consumers of one subscription share its messages. A service always reads a subscription.
The example is an orders service: billing, shipping, an audit and a report each read the topic orders.
One subscription per consumer
- Macros
- Manual
/// Billing's own subscription on `orders`: it receives every order.
#[subscriber(GooglePubSub::new("orders-billing").create_with_topic("orders"))]
async fn bill(order: &Order) -> HandlerOutcome {
tracing::info!(order.id, order.amount_cents, "order billed");
HandlerOutcome::ack()
}
/// Shipping's own subscription on the same topic: it receives every order too.
#[subscriber(GooglePubSub::new("orders-shipping").create_with_topic("orders"))]
async fn ship(order: &Order) -> HandlerOutcome {
tracing::info!(order.id, "order shipped");
HandlerOutcome::ack()
}
/// Bills an order; mounted on billing's own subscription, it receives every order.
struct Bill;
impl Handle<Order> for Bill {
fn handle(
&self,
order: &Order,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(order.id, order.amount_cents, "order billed");
ready(Ok(()))
}
}
/// Ships an order; mounted on shipping's own subscription, it receives every order too.
struct Ship;
impl Handle<Order> for Ship {
fn handle(
&self,
order: &Order,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(order.id, "order shipped");
ready(Ok(()))
}
}
bill and ship each have a subscription of their own on orders, so each receives every order.
A new consumer of the topic is a new subscription; nothing changes for the publisher.
A subscription receives only what is published after it exists, so a consumer added later does not see the orders before it.
GooglePubSub names the subscription by its short id in the broker's project, or by its full resource name, projects/{project}/subscriptions/{id}.
create_with_topic makes the service own its topology: at startup it creates the topic and the subscription where they are missing, and leaves them alone where they exist.
A subscription it creates has message ordering on, which Ordering keys uses.
Subscriptions operations own
In production the topology is often infrastructure, created by Terraform or gcloud before the service runs.
The service then only names what it reads.
- Macros
- Manual
/// A subscription operations created on `orders`; the service only reads it.
#[subscriber("orders-audit")]
async fn audit(order: &Order) -> HandlerOutcome {
tracing::info!(order.id, "order audited");
HandlerOutcome::ack()
}
/// A subscription whose name the deployment decides, mounted with `.name(..)` below.
#[subscriber(GooglePubSub)]
async fn report(order: &Order) -> HandlerOutcome {
tracing::info!(order.id, "order reported");
HandlerOutcome::ack()
}
/// Audits an order; mounted on a subscription operations created.
struct Audit;
impl Handle<Order> for Audit {
fn handle(
&self,
order: &Order,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(order.id, "order audited");
ready(Ok(()))
}
}
/// Reports an order; mounted on the subscription the deployment names.
struct Report;
impl Handle<Order> for Report {
fn handle(
&self,
order: &Order,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(order.id, "order reported");
ready(Ok(()))
}
}
#[subscriber("orders-audit")] reads the subscription orders-audit as it is.
Nothing is created at startup, and a subscription that is not there fails on its stream with PubSubError::Receive.
#[subscriber(GooglePubSub)] fixes the kind and leaves the name open.
The mount site names the subscription with .name(..), so the deployment decides it, here from REPORTS_SUBSCRIPTION.
In the manual form the descriptor is built where the handler is mounted, so it takes the name directly.
- Macros
- Manual
#[ruststream::app]
fn app() -> impl App {
let reports = env::var("REPORTS_SUBSCRIPTION").unwrap_or_else(|_| "orders-reports".to_owned());
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
PubSubBroker::new("my-project").emulator("localhost:8085"),
|b| {
b.include(bill);
b.include(ship);
b.include(audit);
b.include(report.name(reports));
},
)
}
fn app() -> impl App {
let reports = env::var("REPORTS_SUBSCRIPTION").unwrap_or_else(|_| "orders-reports".to_owned());
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
PubSubBroker::new("my-project").emulator("localhost:8085"),
|b| {
b.include(
subscriber(
GooglePubSub::new("orders-billing").create_with_topic("orders"),
Bill,
)
.build(),
);
b.include(
subscriber(
GooglePubSub::new("orders-shipping").create_with_topic("orders"),
Ship,
)
.build(),
);
b.include(subscriber("orders-audit", Audit).build());
b.include(subscriber(GooglePubSub::new(reports), Report).build());
},
)
}
fn main() -> ExitCode {
run_main(app)
}
Against the emulator, create the two subscriptions before the service starts, the way operations would:
for subscription in orders-audit orders-reports; do
curl -X PUT localhost:8085/v1/projects/my-project/subscriptions/$subscription \
-H 'content-type: application/json' \
-d '{"topic": "projects/my-project/topics/orders"}'
done
The topic orders exists once the service has started at least once, because billing and shipping create it.
Several replicas of the service
Every replica of the service opens a streaming pull on the same subscriptions.
Pub/Sub hands each message of a subscription to one of them, so the replicas share the work.
Scaling out is starting more replicas; no setting changes.
A message one replica gives back with retry may come back to another.
Inside one replica, a subscription handles one message at a time.
workers(n) on the #[subscriber] attribute handles up to n at once; Ordering keys shows the keyed form.
Testing
The tests in the example file publish an order to orders and check that billing and shipping both handled it.
In process, a subscription the service only names reads the topic of its own name, so the test of orders-audit and orders-reports publishes there.
Testing a Pub/Sub service covers what the in-process Pub/Sub models.