Skip to content

Stores and TTL

A store gives the poll room for every voter and a lifetime. This chapter moves the poll into one, first in memory and then in Redis.

Moving the poll

risa.InStore on @risa.register moves the view, and it takes ttl, in seconds. The handlers stay as they were. Polls already sent cannot follow, so version goes up, as Changing a view safely explains.

@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])

The thirteen voters that raised an error now fit:

13 voters, in a store

Risa BotAPPToday at 12:00

Pizza on Friday?

Yes: 8No: 5

The buttons hold a random key to one record in the store, so the voters' ids stay out of the message, and a vote leaves the buttons' ids as they are:

What the buttons hold

question = 'Pizza on Friday?', yes = 0, no = 0, voters = []

Yes: 01aKxPkh7b=!3b60si2qqVv8J#m}_5
No: 01aKxPkh7SU!3b60si2qqVv8J#m}_5

question = 'Pizza on Friday?', yes = 1, no = 0, voters = [1298471056230400001]

Yes: 11aKxPkh7b=!3b60si2qqVv8J#m}_5
No: 01aKxPkh7SU!3b60si2qqVv8J#m}_5

Yellow routes the click to its view and handler. Pink is the key to the state in the store.

MemoryStore, for trying things out

A client gets its store from store=. risa.MemoryStore needs nothing installed.

client = risa.client_from_app(bot, store=risa.MemoryStore())
client.add_view(Poll)

add_view warns you about it:

In your log at startup

view poll keeps its state in store 'default', which is a MemoryStore: the state lives in this process only, so it is lost on every restart, and on a REST bot with more than one process most clicks will not find it - use it for development only

A bot with several stores passes stores={"votes": store}, and the view names one with risa.InStore(ttl=WEEK, store="votes").

add_view raises ViewDeclarationError at startup if the client has no store for the view. A client made with use_global=True skips add_view and finds out late: client.build raises, and a click on a poll already sent fails, with an ERROR in your log.

ttl

ttl is how many seconds a record lives untouched. Every click restarts the clock, even one that changes nothing, so only a poll nobody clicks for a week expires. ttl=None keeps a record forever, including one that a deleted message, a view you ended or a failed send leaves behind.

The message stays as it is when the time runs out, and the next click runs on_state_missing.

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

A click after the record expired

Risa BotAPPToday at 12:00

This poll has expired.

Without this method the click fails in Discord, and risa logs a warning. ctx has no view here, so ctx.respond and a ctx.edit with nothing to click work, and ctx.rerender() raises NoViewError, as a ctx.edit with a button does.

Full file
"""The poll in a store, a `MemoryStore` for trying things out."""

from __future__ import annotations

import os

import hikari
import msgspec

import risa
from risa import ui

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 await self._first_vote(ctx):
            self.yes += 1
            await ctx.rerender()

    @risa.handler
    async def vote_no(self, ctx: risa.ComponentContext) -> None:
        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."))


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )

    client = risa.client_from_app(bot, store=risa.MemoryStore())
    client.add_view(Poll)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!poll":
            rendered = await client.build(Poll(question="Pizza on Friday?"))
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

Redis and PostgreSQL

These two keep the state through a restart, and all processes of a bot can share them. Each lives in a module of its own and needs its extra.

Store Extra It takes
risa.store.redis.RedisStore redis A redis.asyncio.Redis
risa.store.postgres.PostgresStore postgres An asyncpg pool

The connection stays yours, so the bot closes it when it stops. An asyncpg pool opens inside the running event loop, so make the pool and the client in a hikari.StartingEvent listener.

connection = redis.asyncio.Redis(host="localhost")
client = risa.client_from_app(bot, store=RedisStore(connection))
client.add_view(Poll)

@bot.listen()
async def on_stopped(_: hikari.StoppedEvent) -> None:
    await connection.aclose()

Try it.

Vote, stop the bot and start it again. A second vote from you gets "You already voted.", because the record is still there.

Run Redis with maxmemory-policy noeviction. Any other policy may evict a record before it expires, which ends that poll, and RedisStore warns about those on first use, if the server lets it read the setting. Turn on AOF as well (appendonly yes, appendfsync everysec), because RDB snapshots alone lose recent votes in a crash.

PostgreSQL needs no cleanup job: PostgresStore creates its tables on first use and deletes expired rows itself.

Full file
"""The poll in Redis, where it survives a restart. Needs `hikari-risa[redis]`."""

from __future__ import annotations

import os

import hikari
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 await self._first_vote(ctx):
            self.yes += 1
            await ctx.rerender()

    @risa.handler
    async def vote_no(self, ctx: risa.ComponentContext) -> None:
        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."))


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )

    connection = redis.asyncio.Redis(host="localhost")
    client = risa.client_from_app(bot, store=RedisStore(connection))
    client.add_view(Poll)

    @bot.listen()
    async def on_stopped(_: hikari.StoppedEvent) -> None:
        await connection.aclose()

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!poll":
            rendered = await client.build(Poll(question="Pizza on Friday?"))
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()