Storage¶
Where updates wait between “received from the server” and “handled”, plus the failed updates and the pinned keys. The guide is Storage and failed updates.
- class MemoryStorage(*, max_failed=1000)¶
Keeps the updates in memory: the default storage.
Everything is lost when the process ends, so
Storage.durableisFalse: the bot confirms an update to the server only once its handlers have finished. A crash means the server sends some updates again, never that one is lost. The price is speed: long polling waits for a whole batch to be handled before it asks for more, and a webhook handles one update at a time, because the server waits for each answer.It sees only its own process. Two processes that poll the same bot are not detected (the bot only logs a warning when polls keep coming back empty), and the pins last only as long as the process. A bot stopped and started again in the same process finds its pending and failed updates and its pins where it left them.
- max_failed¶
How many failed updates the list keeps. When one more fails, the one with the lowest
updateIdis dropped.- Type:
Sets up an empty storage.
- Parameters:
max_failed (int) – How many failed updates to keep, 1000 by default.
- open(bot_id)¶
Binds the storage to a bot.
For the bot it was opened for before, it keeps everything it holds. For another bot it starts again from nothing, rather than mix two queues.
- Parameters:
bot_id (int) – The bot’s user ID, read from its token.
- try_lock()¶
Takes the right to run the bot’s handlers, if no bot that uses this object holds it.
The right lives in this object, so another process, or another
MemoryStorage, can’t see it. While it is taken the answer isFalse, even to the bot that took it.- Returns:
Trueif the right was free and is now taken,Falseif it was taken already.- Return type:
- unlock()¶
Frees the right taken by
try_lock, whoever took it.The right is not tied to a bot: a bot that never got it frees it too when it stops.
- accepted_max()¶
Reads the high-water mark, as
Storage.accepted_maxdescribes.- Returns:
The highest
updateIdaccepted, orNonebefore the first one.- Return type:
int | None
- accept(updates)¶
Keeps the updates it has not seen before, as
Storage.acceptdescribes.
- pending()¶
Lists the pending updates: accepted, and neither finished nor failed.
- begin(update_id)¶
Counts one more handler run of a pending update, as
Storage.begindescribes.
- finish(update_id)¶
Forgets a pending update: its handlers are done.
- Parameters:
update_id (int) – The update that is done.
- fail(update_id, error)¶
Moves a pending update to the failed list, with the error and the time.
Then, if the list holds more than
max_failedupdates, the oldest are dropped.- Parameters:
update_id (int) – The update that failed.
error (str) – The text described in
FailedUpdate.error.
- failed(limit=100)¶
Lists the failed updates, oldest first.
- Parameters:
limit (int) – The most to return.
- Returns:
Up to
limitfailed updates, inupdateIdorder.- Return type:
- requeue_failed(update_ids=None)¶
Moves failed updates back to the pending list, to run again.
- discard_failed(update_ids=None)¶
Deletes failed updates.
- get_pin(user_id)¶
Reads the key fingerprint pinned for a person.
- class SQLiteStorage(path, *, synchronous='FULL', timeout=10.0, max_failed=10_000)¶
Keeps the updates in a SQLite database file, so they survive restarts and crashes.
Storage.durableisTrue: the bot confirms an update to the server as soon as it is written here, without waiting for its handlers. A crash before they finish loses nothing: the update is still in the file and runs at the next start.Bot(storage="bot.db")is the same asBot(storage=SQLiteStorage("bot.db")).How it works:
The database is in WAL mode, with
synchronous=FULLby default: an update is confirmed to the server as soon as it is written, so a write that a power cut could undo would be an update lost.Writes of several statements run in one
BEGIN IMMEDIATEtransaction. Worker processes that share the file wait for each other there, so two of them never accept the same update.An operating system lock on a file next to the database,
<path>.<bot id>.lock(for examplebot.db.42.lock), lets one process at a time run a bot’s handlers. The system releases it when the process dies, even in a crash. A second process that polls the same bot getsPollingConflictError; webhook workers share the file, and the one that holds the lock runs the handlers.One file can hold several bots: each bot’s rows are kept apart by its ID, and each bot has its own lock file.
The failed list keeps the newest
max_failedupdates per bot, each error cut to 4000 characters.The methods that read or write updates and pins raise
StorageErrorwhile the storage is not open: beforeopen, or afterclose.
On Linux and macOS the database and the lock file are created readable by their owner only (
0600), and opening an existing database that other users can read logs a warning. Keep the file on a local disk: SQLite locks are not reliable on network shares (NFS, SMB).- max_failed¶
How many failed updates the list keeps per bot. When one more fails, the one with the lowest
updateIdis dropped.- Type:
Warning
Updates are stored as the server sent them. From encrypted chats they are armored OpenPGP messages, and nothing decrypted is ever written; from readable chats they are plain text. Protect the file and its backups like the messages it holds. To copy it while the bot runs, use SQLite’s own backup (
sqlite3 bot.db ".backup copy.db").Sets up the storage. Nothing touches the file until the bot starts and calls
open.- Parameters:
path (str | PathLike[str]) – The database file. It is created, with its folder, if it does not exist.
synchronous (str) – SQLite’s
synchronoussetting:"FULL"(the default),"EXTRA"or"NORMAL", in upper or lower case. Keep"FULL": with"NORMAL"a power cut can lose the last update the server was told is safe.timeout (float) – How many seconds to wait when another process holds the database’s write lock.
max_failed (int) – How many failed updates to keep per bot, 10 000 by default.
- Raises:
ValueError –
synchronousis not one of the three values.
- open(bot_id)¶
Opens the database file for a bot, and creates it if it does not exist.
A new file is created with its folder, readable by its owner only on Linux and macOS. Opening an existing file that other users can read logs a warning. The connection uses WAL mode and the
synchronoussetting given to the constructor. Opened again for another bot, the storage releases the first bot’s lock and works on the new bot’s rows.- Parameters:
bot_id (int) – The bot’s user ID, read from its token.
- Raises:
StorageError – The file can’t be opened as a SQLite database.
OSError – The file or its folder can’t be created.
- try_lock()¶
Takes the operating system lock on
<path>.<bot id>.lock.The lock file is created next to the database if needed, and the process ID is written in it for
lock_holder. Asking again while holding the lock returnsTrue. TwoSQLiteStorageobjects on the same file conflict even within one process.- Returns:
Trueif this storage holds the lock now,Falseif someone else holds it.- Raises:
StorageError – The storage was never opened.
- Return type:
- unlock()¶
Releases the file lock, if this storage holds it. The storage stays open.
- lock_holder()¶
Reads the process ID written in the lock file, for error messages.
A process writes its ID there when it takes the lock, and the ID stays after the lock is released: it names the current holder only while the lock is taken.
- require_lock()¶
Takes the lock, or raises an error that says which process has it.
The bot calls it when it starts long polling with this storage.
- Raises:
PollingConflictError – Another process, or another
SQLiteStorageon the same file, holds the lock. The message names the database file and, when it is known, the process ID.StorageError – The storage was never opened.
- accepted_max()¶
Reads the high-water mark kept in the file, as
Storage.accepted_maxdescribes.- Returns:
The highest
updateIdaccepted, orNonebefore the first one.- Return type:
int | None
- accept(updates)¶
Writes the updates it has not seen before, in one
BEGIN IMMEDIATEtransaction.Worker processes that share the file wait for each other here, so two of them never accept the same update. The rest is as
Storage.acceptdescribes.- Parameters:
updates (Sequence[Mapping[str, Any]]) – The updates, as the server sent them.
- Returns:
The new updates, sorted by
updateId.- Raises:
StorageError – SQLite could not write them, one has no integer
updateId, or the storage is not open. Nothing is written.- Return type:
- pending()¶
Lists the pending updates: accepted, and neither finished nor failed.
- count_pending()¶
Counts the pending updates with one query, without reading them.
- Returns:
How many updates are accepted and neither finished nor failed.
- Return type:
- pending_ids()¶
Lists the IDs of the pending updates, oldest first, without reading the updates.
- load(update_ids)¶
Reads the pending updates with the given IDs.
- begin(update_id)¶
Counts one more handler run of a pending update, in the file.
The count is kept with the update, so it survives a crash: see
Storage.begin.
- finish(update_id)¶
Deletes a pending update: its handlers are done.
- Parameters:
update_id (int) – The update that is done.
- fail(update_id, error)¶
Moves a pending update to the failed list, with the error cut to 4000 characters.
In the same transaction, the oldest failed updates beyond
max_failedare deleted.- Parameters:
update_id (int) – The update that failed.
error (str) – The text described in
FailedUpdate.error.
- Raises:
StorageError – SQLite could not write it. Nothing changes.
- failed(limit=100)¶
Lists the failed updates, oldest first.
- Parameters:
limit (int) – The most to return.
- Returns:
Up to
limitfailed updates, inupdateIdorder.- Return type:
- requeue_failed(update_ids=None)¶
Moves failed updates back to the pending list, in one transaction.
- discard_failed(update_ids=None)¶
Deletes failed updates from the file.
- get_pin(user_id)¶
Reads the key fingerprint pinned for a person.
- class Storage¶
The interface every storage implements: subclass it to keep updates somewhere else.
The bot hands each update it receives to its storage before a handler sees it, and asks the storage what is left to do at every start.
MemoryStorageandSQLiteStorageimplement it. To keep updates in PostgreSQL or Redis, setdurableand write the abstract methods below: each one says what it must do and when the bot calls it.count_pending,pending_idsandloadhave defaults built onpending; override them when loading every pending update at once is slow.Updates are plain mappings of the server’s JSON. The key that matters here is
updateId, a number that grows with each new update of the bot.Rules for every method:
It is synchronous and thread safe. The bot calls it from its event loop and from worker threads, sometimes at the same time. With
durableFalsethe calls for each update run on the event loop itself, so they must be quick and must not block.It works on the bot given to
openonly. One store may hold several bots, kept apart by their ID.It raises
StorageErrorwhen the store fails. The bot recovers where it can: a batch thatacceptcould not store is fetched again by long polling, or answered503on a webhook so that the server sends it again, and an update whose run could not be counted or recorded stays pending and runs again later.
When the bot starts, it calls
open,try_lockand, for long polling,accepted_max. While it runs, it callsaccept,pending_ids,load,begin, andfinishorfail. When it stops, it callsunlockandclose. The other methods serveBotmethods such asBot.failed_updates, and key pinning.- durable¶
Trueonly ifaccepthas saved the updates for good when it returns. The bot then confirms them to the server right away, before their handlers run, and makes the calls for each update from worker threads. WithFalse, the default, it confirms an update only after its handlers finished, as withMemoryStorage. Set it on your class.- Type:
Example
A storage of your own, given to the bot like the built-in ones:
from pgpcbot import Bot, Storage class PostgresStorage(Storage): durable = True # accept() commits before it returns def open(self, bot_id): ... # ... and every other abstract method bot = Bot(storage=PostgresStorage())
- abstractmethod open(bot_id)¶
Prepares the storage for one bot.
Called at every start, before the bot uses the storage for anything else. Everything after it (the high-water mark, the pending and failed updates, the pins, the lock) belongs to this bot only: a store shared by several bots keeps them apart by this ID. After
close, the bot may call it again when it starts again.Bot.failed_updates,Bot.retry_failed,Bot.discard_failedandBot.trust_keycall the storage directly, so on a bot that is not running they reach a storage that is not open.SQLiteStoragethen raisesStorageError.- Parameters:
bot_id (int) – The bot’s user ID, read from its token.
- Raises:
StorageError – The store can’t be opened. The bot does not start.
- abstractmethod close()¶
Releases what the storage holds: connections, open files, the lock.
Called when the bot stops, after
unlock. Its errors are ignored.
- abstractmethod try_lock()¶
Takes the right to run this bot’s handlers, if nobody else holds it.
Only one process at a time may run a bot’s handlers, and this is how the bot makes sure. Called at start, after
open. Long polling does not start without the right: the bot raisesPollingConflictError. A webhook process that does not get it keeps accepting deliveries (with a durable storage it only stores them, for the process that holds the right) and asks again every 5 seconds.The right must end by itself when the process that holds it dies, even in a crash, or the bot could never start again after one. An operating system file lock does that.
- Returns:
Trueif the caller holds the right now,Falseif someone else has it.- Return type:
- abstractmethod unlock()¶
Gives back the right taken by
try_lock.Called when the bot stops, before
close, even if the right was never taken: then it must do nothing. Its errors are ignored.
- abstractmethod accepted_max()¶
Reads the high-water mark: the highest
updateIdever accepted for this bot.The mark never goes down, not even when the updates are finished, failed or discarded. Long polling reads it at start: with a durable storage, its first request confirms to the server every update up to the mark.
- abstractmethod accept(updates)¶
Stores the updates it has not seen before and returns them, oldest first.
Called with every batch of a long poll and every webhook delivery. An update is new when its
updateIdis aboveaccepted_max; anything at or below the mark is a repeat and is skipped, as is a second copy of an ID within the batch. The batch can come in any order.It must be atomic: either every new update is stored and the mark moves to the highest of them, or nothing changes and it raises. With
Storage.durableTrue, the updates must be saved for good when it returns, because the bot tells the server it has them right after.Only
updateIdis sure to be there. Withskip_pending=Truethe bot stores{"updateId": n, "kind": "skipped"}for each update it throws away, and finishes them at once, so that a late repeat is still recognised.- Parameters:
updates (Sequence[Mapping[str, Any]]) – The updates, as the server sent them.
- Returns:
The new updates, sorted by
updateId. These are the ones the handlers will see.- Raises:
StorageError – The updates could not be stored, or one has no integer
updateId. A long poll then does not confirm the batch and fetches it again; a webhook answers503. Any other exception stops a polling bot.- Return type:
- abstractmethod pending()¶
Lists the pending updates: accepted, and neither finished nor failed.
The bot does not call it directly: the defaults of
pending_ids,loadandcount_pendingbuild on it.
- abstractmethod begin(update_id)¶
Counts one more handler run of a pending update.
Called right before the handlers of an update run. The count is how the bot spots an update that kills the process every time: after
max_handler_runsruns that never finished, it moves the update to the failed list instead of running it again. A durable storage must keep the count with the update, so that it survives a crash. Afterrequeue_failedthe count starts again from zero.
- abstractmethod finish(update_id)¶
Forgets a pending update: its handlers are done.
Called when the handlers return, or when an error handler took their error. An ID that is not pending must be ignored. If it raises, the update stays pending and runs again later: the bot handles updates at least once.
- Parameters:
update_id (int) – The update that is done.
- abstractmethod fail(update_id, error)¶
Moves a pending update to the failed list, with the error.
Called when the handlers raised and no error handler took the error, and when an update ran
max_handler_runstimes without ever finishing (seebegin). The update leaves the pending list, andfailedreturns it with the error and the time. An ID that is not pending must be ignored. Anyone who can write to the bot can make updates fail, so keep the list bounded: the built-in storages keep the newestmax_failed.- Parameters:
update_id (int) – The update that failed.
error (str) – The text described in
FailedUpdate.error.
- abstractmethod failed(limit=100)¶
Lists the failed updates, oldest first.
Bot.failed_updatescalls it.- Parameters:
limit (int) – The most to return.
- Returns:
Up to
limitfailed updates, inupdateIdorder.- Return type:
- abstractmethod requeue_failed(update_ids=None)¶
Moves failed updates back to the pending list, to run again.
Bot.retry_failedcalls it, and a running bot then picks them up at once; otherwise they run at the next start. Their run count starts again from zero.
- abstractmethod discard_failed(update_ids=None)¶
Deletes failed updates for good.
Bot.discard_failedcalls it.
- abstractmethod get_pin(user_id)¶
Reads the key fingerprint pinned for a person.
With
pin_keys=Truethe bot calls it each time it checks a person’s key: before it seals a message to them, and when it checks the signature of a message they sent.
- abstractmethod set_pin(user_id, fingerprint)¶
Pins a key fingerprint for a person, replacing any earlier pin.
Called with
pin_keys=Truethe first time the bot sees a person’s key, and byBot.trust_key. The built-in storages keep it in upper case; the bot compares fingerprints without regard to case.
- count_pending()¶
Counts the pending updates.
The bot itself does not need it: it is there for your own checks, such as a report at start.
- Returns:
How many updates are accepted and neither finished nor failed.
- Return type:
- pending_ids()¶
Lists the IDs of the pending updates, oldest first.
The bot calls it when it looks for work: at start if it got the right from
try_lock, or later when it gets it; afterrequeue_failed; and, in a webhook process with a durable storage, every 2 seconds while it holds the right, to pick up what other worker processes stored.
- load(update_ids)¶
Reads the pending updates with the given IDs.
Called right after
pending_ids, with the IDs that are not running yet, as many as the bot has room for.
- class FailedUpdate(update_id, payload, error, failed_at)¶
An update whose handlers failed, kept with the error.
Bot.failed_updatesreturns them, oldest first. A storage of your own builds them in itsStorage.failed: the fields are also the constructor’s arguments, in this order.- payload¶
The update as the server sent it. From an encrypted chat it holds the armored message: nothing decrypted is kept.
- error¶
What went wrong: the exception’s type and message, and the file and line where it was raised, such as
"KeyError: 'city' at bot.py:31". For an update from an encrypted chat the message is left out, because a handler’s exception could quote the decrypted text; the messages of pgpcbot’s own exceptions are kept. An update set aside because the process kept stopping while it ran says"gave up: ...".- Type: