Skip to content

Several processes

Run more than one process, and two clicks on one message can land in two places. This chapter shows when that happens, what it does to your views, and how to keep every click.

A bot in one process needs nothing from here. With several processes, put every InStore view on Redis or PostgreSQL. On a REST bot with several processes, move every view that cannot lose a click to such a store as well.

Where a click lands

Two people click the same message. Which process runs each click depends on the kind of bot:

Gateway bot Click 1 Click 2 The guild's shard decides Process 1 Both clicks Process 2 No click All clicks on one message reach one process, so they can wait for each other there.
REST bot, several processes Click 1 Click 2 The load balancer decides Process 1 Click 1 Process 2 Click 2 Each click can reach another process, so two can run at the same moment.
  • A gateway bot has several processes when you split its shards, with shard_ids and shard_count on bot.run. A message's guild decides its shard, by Discord's formula, and a DM goes to shard 0. That process need not be the one that sent the message.
  • A REST bot has several when you run several copies behind a load balancer, which picks a copy for each click.

What two clicks do to a view

Two people click Yes at the same moment on a poll at 5, so the right result is 7:

Gateway botREST bot, several processes
InMessage7Both clicks count.6Both processes read 5 and write 6, so one click is lost, and nothing logs it.
InStore on Redis or PostgreSQL7Both clicks count.7Both clicks count. The second waits for the first, across the processes.
InStore on MemoryStore7, until a restartBoth clicks count, and a restart loses the poll.Most clicks failOnly the process that sent the poll has its record. Elsewhere on_state_missing runs.

A click that finds no record runs on_state_missing. A REST bot with one process behaves like the gateway column. A gateway bot with split shards can miss a MemoryStore record too, when one process sends a message into a guild another process serves, and add_view warns about a MemoryStore at startup.

In a message, one more thing holds on every bot: a vote changes the ids, so a click that reaches Discord after the first edit fails there, and the person has to click again. In a store the ids stay.

What to set up

A view in the message needs no store. A view in a store needs RedisStore or PostgresStore, on one server for all processes, or your own. Every process runs the same setup.

connection = redis.asyncio.Redis(host=os.environ["REDIS_HOST"])
client = risa.client_from_lightbulb(
    lightbulb_client,
    store=RedisStore(connection),
    auto_defer=risa.AutoDefer.UPDATE,
)
client.add_view(Poll)

How clicks take turns

On Redis or PostgreSQL, clicks on one view run one after another, across processes too. Each goes through these steps:

  1. 1In-process lockOther clicks on this record in this process wait.
  2. 2LeaseAn expiring claim in the store makes clicks in other processes wait.
  3. 3The handlerIt runs on the record it read, and the lease keeps renewing.
  4. 4Compare-and-setrerender() writes only if the record is still at the version it read.

A process that crashes during step 3 blocks that view until its lease runs out, 5 seconds by default, or the lease= you give RedisStore or PostgresStore.

A click waits up to 10 seconds, then fails with an ERROR in your log. Discord's three seconds keep running meanwhile, which is why the client above sets auto-defer: it answers in time.

InMessage on a REST bot

An InMessage view has only step 1, so nothing spans the processes. Two clicks at the same moment read the same state: both raise a count from 5 to 6, and both edits say 6. The last edit wins, one click is lost, and your log shows nothing. If a view cannot lose a click, move it to a store.

When a handler loses the race

A lease can run out under a handler that runs past max_hold, 60 seconds by default, or in a process that stalls, and a Redis failover can lose one. The next click then runs beside the first, and step 4 lets only one write through. The other's ctx.rerender() raises StateConflictError. By then self holds the state that won, so the handler can apply its change once more.

@risa.handler
async def vote_yes(self, ctx: risa.ComponentContext) -> None:
    if not await self._first_vote(ctx):
        return
    self.yes += 1
    try:
        await ctx.rerender()
    except risa.StateConflictError:
        if await self._first_vote(ctx):
            self.yes += 1
            await ctx.rerender()

_first_vote runs again on the record that won, so someone whose vote is already in gets "You already voted."

If a process with other fields won, say during a deploy, this process cannot read the record. ctx.rerender() then raises StateUnreadableError, which this except lets through: the handler fails, risa logs it, and the vote is lost. Discord shows the click as failed, unless auto-defer answered it first.

The retry is one attempt. If it conflicts too, rerender() raises again, risa logs the error and that click fails.

Without the except, the handler fails: risa logs the error, Discord shows the click as failed unless auto-defer answered it first, and the message shows what the winner wrote. A handler that changes a field and never calls rerender() cannot catch anything, so risa logs a WARNING and drops the change.

Full file
"""The poll from chapter 09 on a REST bot.

Needs `hikari-risa[redis]`, `hikari[server]` and hikari-lightbulb.
"""

from __future__ import annotations

import os

import hikari
import lightbulb
import msgspec
import redis.asyncio

import risa
from risa import ui
from risa.store.redis import RedisStore

WEEK = 7 * 24 * 60 * 60


@risa.register(name="poll", version=2, state=risa.InStore(ttl=WEEK))
class Poll(risa.View):
    question: str
    yes: int = 0
    no: int = 0
    voters: list[int] = msgspec.field(default_factory=list[int])

    def render(self) -> ui.Layout:
        return ui.Container(
            ui.TextDisplay(f"## {self.question}"),
            ui.Row(
                ui.Button(self.vote_yes, label=f"Yes: {self.yes}"),
                ui.Button(self.vote_no, label=f"No: {self.no}"),
            ),
        )

    @risa.handler
    async def vote_yes(self, ctx: risa.ComponentContext) -> None:
        if not await self._first_vote(ctx):
            return
        self.yes += 1
        try:
            await ctx.rerender()
        except risa.StateConflictError:
            if await self._first_vote(ctx):
                self.yes += 1
                await ctx.rerender()

    @risa.handler
    async def vote_no(self, ctx: risa.ComponentContext) -> None:
        if not await self._first_vote(ctx):
            return
        self.no += 1
        try:
            await ctx.rerender()
        except risa.StateConflictError:
            if await self._first_vote(ctx):
                self.no += 1
                await ctx.rerender()

    async def _first_vote(self, ctx: risa.ComponentContext) -> bool:
        if ctx.user.id in self.voters:
            await ctx.respond("You already voted.", ephemeral=True)
            return False
        self.voters.append(ctx.user.id)
        return True

    @classmethod
    async def on_state_missing(cls, ctx: risa.ComponentContext) -> None:
        await ctx.edit(ui.TextDisplay("This poll has expired."))


class PollCommand(
    lightbulb.SlashCommand,
    name="poll",
    description="Post a poll.",
):
    @lightbulb.invoke
    async def invoke(self, ctx: lightbulb.Context, client: risa.Client) -> None:
        poll = Poll(question="Pizza on Friday?")
        await ctx.respond(**await client.build(poll))


def main() -> None:
    bot = hikari.RESTBot(
        os.environ["DISCORD_TOKEN"],
        hikari.TokenType.BOT,
        public_key=os.environ["DISCORD_PUBLIC_KEY"],
    )
    lightbulb_client = lightbulb.client_from_app(bot)
    bot.add_startup_callback(lightbulb_client.start)

    connection = redis.asyncio.Redis(host=os.environ["REDIS_HOST"])
    client = risa.client_from_lightbulb(
        lightbulb_client,
        store=RedisStore(connection),
        auto_defer=risa.AutoDefer.UPDATE,
    )
    client.add_view(Poll)

    lightbulb_client.register(PollCommand)

    async def on_shutdown(_: hikari.RESTBot) -> None:
        await connection.aclose()

    bot.add_shutdown_callback(on_shutdown)
    bot.run(port=int(os.environ["PORT"]))


if __name__ == "__main__":
    main()