Skip to main content
Earlier versions of this chapter published link.created with the pubsub worker. If your project still has it, see Migrate from iii-stream and pubsub.
In Chapter 3 every redirect triggers link::record_click directly, so the database write runs on the redirect’s hot path and a slow write slows the redirect. In this chapter you move that work onto a queue so redirects return immediately, then publish link events on durable topics that independent subscribers receive: a Python analytics worker and a cache refresher, both decoupled from the link worker.

Add the workers

This chapter uses queue and a new Python analytics worker. Uncomment the Ch. 4 block in worker-compose.yaml:
worker-compose.yaml
Also uncomment the analytics database in the Ch. 3 database block. analytics is a second database: it goes under databases:, at the same indentation as primary:, not inside primary: or its pool:. The whole block then reads:
worker-compose.yaml
If database::query db=analytics later answers UNKNOWN_DB, the analytics entry is nested at the wrong level.

Make redirects fast with a queue

A queue holds work that is accepted now and run later. The clicks queue you uncommented above is the one link::record_click uses.
Queue names are references to the queue and do not place any restrictions on what can be put into a given queue.
You already wrote link::record_click in Chapter 3, where http::redirect triggers it directly. Nothing about that function needs to change to make it queuable. You only change how it’s triggered with a TriggerAction. First import TriggerAction into link/src/index.ts:
src/index.ts
Then add an action to the existing link::record_click call in http::redirect so the queue worker enqueues it instead of running it inline:
src/index.ts
The redirect now returns as soon as the click is accepted onto the queue. link::record_click drains the queue in the background, with retries and a dead-letter queue if a write keeps failing.

Broadcast events with durable topics

A queue delivers each message to one consumer. When several unrelated parts of the system need to react to the same event, publish it on a topic instead. The queue worker serves topics too: iii::durable::publish sends a message to a topic, and every function bound to that topic with a durable:subscriber trigger receives its own copy.
Topic messages are durable: each subscriber’s copy is kept until it is handled, retried when the handler fails, and moved to a dead-letter queue when it keeps failing. Delivery is at least once, so make subscribers safe to run twice for the same message.For notifications that nobody needs to replay, such as a live click counter, a worker can declare its own trigger type instead. You’ll build one in Chapter 5.
Here we’ll publish an event when a link is created, and another when a link is updated. Later we’ll make an analytics worker that collects data from these events. We don’t have link updating functionality yet, so we’ll add that and an HTTP endpoint for it too. Publish an event whenever a link is created. Replace link::create with this version: it keeps the creation time in a created_at variable, writes it with the row, and after state::set publishes it on the link.created topic along with the code and URL. A subscriber then knows when the link was created, even when its copy arrives later. A taken code throws before the publish, so it publishes nothing:
src/index.ts
Now add an update path to the link worker so a link’s target can change, and announce it. First, the domain function: it updates the database row and publishes a link.updated event on its own topic, the same way. Place it at the end of link/src/index.ts:
src/index.ts
Then connect link::update with an HTTP handler that validates input and calls the domain function:
src/index.ts
And finally the trigger that binds http::update to PUT /links/:code:
src/index.ts
This last bit of code isn’t about topics, but you’ll need it for trying out your new feature at the end of the chapter.

Add reactive state: Keep the cache correct without coupling

link::update changes the database but not the state cache, so a query could serve stale data. Rather than handle refreshing the state cache inside link::update, subscribe to the link.updated topic with a durable:subscriber trigger:
Why durable matters here. This cache refresher must receive every update: a dropped event would leave the cache pointing at a stale URL. A durable topic keeps the message until the subscriber handles it, and retries it when the handler fails. Use a durable topic whenever a missed event would corrupt state.
src/index.ts

Create an analytics worker in Python

Queues and events are useful within a single worker but also between workers. Thus far we’ve been writing all of our code in TypeScript. However workers are not restricted to specific languages or runtimes. So this time we’ll create an analytics worker in Python to count links.

Adding a second worker

We already referenced the analytics worker in worker-compose.yaml, now let’s give it some code. The analytics directory holds a Python worker, with an analytics/src/main.py entrypoint and an iii.worker.yaml manifest of its own:
analytics/iii.worker.yaml
The scaffold ships analytics/src/main.py with this code commented out: uncomment its Ch. 4 block (or replace the file with the code below). It binds a durable:subscriber trigger to the link.created topic and records every new short link under the UTC day of the event’s created_at. Delivery is at least once, so the same event can arrive twice. Instead of adding 1 to a counter, the worker keeps one row per link in link_events, keyed by code, and writes it with a single INSERT ... ON CONFLICT(code) DO NOTHING: a redelivered event finds its row and changes nothing. The per-day counts come from daily_link_counts, a view that counts those rows by day:
Analytics keeps its rows in its own database, so the link worker never has to know it exists. That is the analytics entry you uncommented at the start of the chapter, alongside the primary one from Chapter 3. Restart Compose so it picks up the change:

See it work

Create five links, follow one a few times, and change its target:
The view counts each new link once, under the day it was created:
Now follow one of them a few times (each click goes through the queue instead of a blocking write), then change its target. PUT /links/:code publishes link.updated durably and the subscriber refreshes the cache, so link::resolve returns the new URL right away:

Conclusion

Redirects no longer wait on a database write: click rows ride a queue, drained in the background. Link events fan out over durable topics to a Python analytics counter and a cache refresher, and the link worker does not know either of them exists. Next, in Ch. 5: Stream live clicks, you broadcast every click in real time from a dedicated click-streamer worker.