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:
- A gateway bot has several processes when you split its shards, with
shard_idsandshard_countonbot.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 bot | REST bot, several processes | |
|---|---|---|
InMessage | 7Both clicks count. | 6Both processes read 5 and write 6, so one click is lost, and nothing logs it. |
InStore on Redis or PostgreSQL | 7Both clicks count. | 7Both clicks count. The second waits for the first, across the processes. |
InStore on MemoryStore | 7, 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:
- 1In-process lockOther clicks on this record in this process wait.
- 2LeaseAn expiring claim in the store makes clicks in other processes wait.
- 3The handlerIt runs on the record it read, and the lease keeps renewing.
- 4Compare-and-set
rerender()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()