Skip to content

Writing your own store

A store is six methods. Write them against what you already run, and verify_store tells you whether risa can rely on them.

The six methods

risa.Store is a protocol, and the reference has each method in full.

Method What it has to do
get_versioned(key) Return the bytes last written, unchanged, with the entry's version, as a risa.VersionedEntry, or None if the entry is missing or expired
put(key, value, *, ttl) Write whether or not an entry exists, and change the version
put_if_version(key, value, *, expected, ttl) Write only if the entry is at version expected, in one atomic step, and return the new version. Otherwise write nothing and return None
delete(key) Remove the entry, if there is one
touch(key, *, ttl) Restart the entry's lifetime, leave its data and version alone, and leave a missing key missing
lock(key, *, timeout=...) Return an async context manager that holds the key exclusively and leaves other keys free. It raises risa.LockTimeoutError after waiting timeout seconds

ttl is in seconds, fractions included. None means no expiry and clears any earlier one.

A missing entry is never an error. Let every other failure raise: risa reports it as StoreUnavailableError, so an outage cannot pass for an expired view.

A minimal store

DictStore keeps its entries in a dict. put_if_version decides whose write lands when two clicks race:

async def put_if_version(
    self,
    key: str,
    value: bytes,
    *,
    expected: int,
    ttl: float | None,
) -> int | None:
    entry = self._live(key)
    if entry is None or entry.version != expected:
        return None
    written = self._new_entry(value, ttl)
    self._entries[key] = written
    return written.version

No await sits between the compare and the write here, so nothing can run between them. A real backend needs one step of its own for this, as RedisStore has a Lua script and PostgresStore one UPDATE.

The lock is one asyncio.Lock per key:

@contextlib.asynccontextmanager
async def lock(
    self,
    key: str,
    *,
    timeout: float = risa.DEFAULT_LOCK_TIMEOUT,
) -> collections.abc.AsyncGenerator[None]:
    lock = self._locks.setdefault(key, asyncio.Lock())
    try:
        async with asyncio.timeout(timeout):
            await lock.acquire()
    except TimeoutError as exc:
        raise risa.LockTimeoutError(key, timeout) from exc
    try:
        yield
    finally:
        lock.release()

risa calls lock without await, as asynccontextmanager allows. A client takes the store like any other:

client = risa.client_from_app(bot, store=DictStore())
client.add_view(Tally)
Full file
"""A store of your own, and the test that holds it to what risa relies on."""

from __future__ import annotations

import asyncio
import contextlib
import itertools
import os
import time
import typing

import hikari
import msgspec

import risa
from risa import ui
from risa.store.testing import verify_store

if typing.TYPE_CHECKING:
    import collections.abc


class _Entry(msgspec.Struct):
    data: bytes
    version: int
    expires_at: float | None


class DictStore(risa.Store):
    def __init__(self) -> None:
        self._entries: dict[str, _Entry] = {}
        self._locks: dict[str, asyncio.Lock] = {}
        self._versions = itertools.count(1)

    async def get_versioned(self, key: str) -> risa.VersionedEntry | None:
        entry = self._live(key)
        if entry is None:
            return None
        return risa.VersionedEntry(data=entry.data, version=entry.version)

    async def put(self, key: str, value: bytes, *, ttl: float | None) -> None:
        self._entries[key] = self._new_entry(value, ttl)

    async def put_if_version(
        self,
        key: str,
        value: bytes,
        *,
        expected: int,
        ttl: float | None,
    ) -> int | None:
        entry = self._live(key)
        if entry is None or entry.version != expected:
            return None
        written = self._new_entry(value, ttl)
        self._entries[key] = written
        return written.version

    async def delete(self, key: str) -> None:
        self._entries.pop(key, None)

    async def touch(self, key: str, *, ttl: float) -> None:
        entry = self._live(key)
        if entry is not None:
            entry.expires_at = time.monotonic() + ttl

    @contextlib.asynccontextmanager
    async def lock(
        self,
        key: str,
        *,
        timeout: float = risa.DEFAULT_LOCK_TIMEOUT,
    ) -> collections.abc.AsyncGenerator[None]:
        lock = self._locks.setdefault(key, asyncio.Lock())
        try:
            async with asyncio.timeout(timeout):
                await lock.acquire()
        except TimeoutError as exc:
            raise risa.LockTimeoutError(key, timeout) from exc
        try:
            yield
        finally:
            lock.release()

    def _live(self, key: str) -> _Entry | None:
        entry = self._entries.get(key)
        if entry is None or entry.expires_at is None:
            return entry
        if entry.expires_at > time.monotonic():
            return entry
        del self._entries[key]
        return None

    def _new_entry(self, value: bytes, ttl: float | None) -> _Entry:
        expires_at = None if ttl is None else time.monotonic() + ttl
        return _Entry(value, next(self._versions), expires_at)


async def test_dict_store() -> None:
    await verify_store(DictStore())


@risa.register(name="tally", state=risa.InStore(ttl=3600))
class Tally(risa.View):
    count: int = 0

    def render(self) -> ui.Layout:
        return ui.Row(ui.Button(self.add, label=f"Count: {self.count}"))

    @risa.handler
    async def add(self, ctx: risa.ComponentContext) -> None:
        self.count += 1
        await ctx.rerender()


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=DictStore())
    client.add_view(Tally)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!tally":
            rendered = await client.build(Tally())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()

Test it

Add verify_store to your tests, with a store connected to a real instance of your backend.

async def test_dict_store() -> None:
    await verify_store(DictStore())

This one needs pytest-asyncio with asyncio_mode = "auto". In a script, asyncio.run(verify_store(DictStore())) does the same. A failed check makes it raise StoreConformanceError, which lists every failure.

Try it.

Take or entry.version != expected out of put_if_version. verify_store now names a_stale_cas_is_refused and racing_cas_writes_have_one_winner.

Stores that processes share

verify_store runs in one process, so a lock that excludes only its own process passes. For several processes, lock has to exclude all of them. Without that, two clicks run at once, and one of them gets StateConflictError.

DictStore is a teaching example, for one process only. It gets no startup warning, and it keeps every lock and every expired entry nobody reads, so a bot that runs on it grows until it restarts.