Skip to content

Cartographer: fix OOM when backing up servers with many attachments - #283

Open
RWolfyo wants to merge 5683 commits into
vertyco:mainfrom
RWolfyo:fix/cartographer-attachment-memory-cap
Open

Cartographer: fix OOM when backing up servers with many attachments#283
RWolfyo wants to merge 5683 commits into
vertyco:mainfrom
RWolfyo:fix/cartographer-attachment-memory-cap

Conversation

@RWolfyo

@RWolfyo RWolfyo commented Jul 29, 2026

Copy link
Copy Markdown

The problem

FileBackup.serialize downloads each attachment, base64-encodes it, and holds it on the model until the entire guild has been serialized. Peak memory therefore scales with a server's total media — about 4x it, since the raw bytes, the base64 bytes and the decoded str are all live at once, before model_dump_json builds the document on top.

The v2.2.0 check is per-file:

files=[await FileBackup.serialize(i) for i in message.attachments if i.size < max_size]

That rejects individual oversized files but places no ceiling on the total, so any number of small attachments passes straight through.

On my bot this produced a 1.9 GB backup document and a ~16 GB RSS peak against a 16 GB MemoryMax, and the OOM killer ended the process roughly every 10 minutes — last_backup never persisted, so every restart retried the same backup and died again. Restoring such a backup would have failed the same way, since restorelatest does model_validate_json(path.read_text()) on the whole file.

The change

Backups are written as .zipbackup.json plus an attachments/ folder. Attachments stream into the archive as they download; on restore they are read back one at a time. Memory stays flat regardless of media volume, and nothing is dropped: a large backup simply takes longer.

A backup is still a single file on disk, so the rotation, wipe, listing and size-reporting paths are untouched.

Backward compatible. load_backup picks the format by extension and FileBackup keeps filebytes for pre-2.3.0 backups. Existing backups load and restore unchanged; only new ones are zips.

The guild upload limit check is unchanged — same filesize_limit fallback, same comparison.

Second commit

Unrelated bug found while testing: channel.history(limit=...) returns newest-first and restore_channel_messages replays the list in order, so restored channels came back backwards. Messages are now stored oldest-first; the same most-recent N are still selected. Kept as a separate commit so it can be dropped independently.

Testing

Run against a live bot (Red 3.5, Python 3.11):

  • Backup + restore of a server with 52 MB of mixed attachments (images, mp4): 288 MB → 444 MB RSS, plateauing rather than climbing. All attachments restored intact.
  • Legacy .json backup restored successfully via the base64 fallback.
  • Message ordering verified chronological after the second commit.
  • Archive round-trip checked against unicode filenames, duplicate filenames, path-traversal-style names, and empty files.

Version

2.2.0 → 2.3.0 — minor rather than patch, since the on-disk backup format changes.

vertyco and others added 30 commits March 20, 2026 14:11
…support) (vertyco#269)

* Update quickpull command to stop reimplementing Downloader (+ 3.5.25 support)

* Fix formatting

* Whoops!
 - Update image URL pattern to allow query parameters
vertyco and others added 29 commits June 28, 2026 23:35
Add [p]levelset prestige emoji <level> <emoji> to update the emoji of an
existing prestige level in place, instead of having to remove and
re-create the level.
…tion

Lets the VrtUtils RPC bridge insert + schedule a task in-process (build
model, store, save, ensure_jobs) with duplicate guarding, so scheduled
commands can be added without the interactive menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enables out-of-process skill editing (read-before-write) via the RPC bridge;
rpc_list_skills intentionally omits body, so edits had no way to read current state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ENDPOINT uses named placeholder {guild_id} but three call sites passed
guild.id positionally, raising KeyError on member update/remove and
cleanup revert.
- Fix save_conf wiping live conversations in non-persistent mode
- Fix startup cleanup deleting guild config/embeddings during gateway degradation
- Fix phantom reminders re-firing after reload (deletion never persisted)
- Fix stale embedding migration re-dropping the live ChromaDB collection
- Fix listener crash on replies to deleted messages
- Fix unguarded auto-answer embedding call dropping messages on API errors
- Fix tool-result trim note char count and None-author log crash
- Realign MixinMeta signatures with implementations
- Token counting: single cached o200k_base encoding, no per-model tables,
  model param removed from all counting helpers
- Add gpt-5.6-sol/terra/luna (1.05M ctx, vision, tools, reasoning)
…o#281)

* Assistant - fix GPT-5.6 family reasoning_effort + tools error

The gpt-5.6-sol/terra/luna models reject function tools on Chat
Completions unless reasoning_effort is explicitly 'none'. Omitting
the param (as done for gpt-5.4/5.5) is not enough because 5.6's
default effort is non-none, so the API errored with:

  Function tools with reasoning_effort are not supported for
  gpt-5.6-luna in /v1/chat/completions. To use function tools, use
  /v1/responses or set reasoning_effort to 'none'.

Split 5.6 into its own branch and force reasoning_effort='none'
whenever tools are present; 5.4/5.5 keep the omit behavior.

* Assistant - route gpt-5.4/5.5/5.6 reasoning+tools through Responses API

These models can't combine a configurable reasoning_effort with function
tools on /v1/chat/completions. Add a thin, stateless adapter that routes
exactly those calls to /v1/responses and translates the request/response
back into Chat Completions shapes, so conversation storage, tool-call
parsing, and usage/cache accounting are untouched.

- responses.py: to_responses_input / to_responses_tools /
  responses_to_chat_completion translators (handles text, vision parts,
  tool calls, tool results, reasoning summaries, usage mapping).
- calls.py: needs_responses_api() predicate + request_responses_raw();
  request_chat_completion_raw delegates when reasoning+tools are both
  needed on the 5.4/5.5/5.6 family. Everything else stays on chat
  completions unchanged.
- Conversation store stays chat-format (source of truth); translation is
  per-call with store=False, so mid-convo model switching still works.

Non-tool turns, other models, and reasoning_effort=none are unaffected.

* Assistant - simplify tool_call dict access in responses adapter

messages is list[dict] by contract, so drop the misleading isinstance
guard that only covered one of three tc.get() call sites.

* Assistant - remove em-dashes from responses adapter docs

Plain-language cleanup of the module docstring and changelog entry.

* Assistant - remove em-dashes from touched files

Replace em-dashes with commas/colons/periods across calls.py error
strings, constants.py prompt text, and CHANGELOG entries.
Adds an opt-in heads-up DM (notify_users, default on) sent once as a user
nears (warn_days, default 3) or enters inactivity decay, reset when they
become active again. New admin cmds: [p]bankdecay notify / setwarndays.
Closes the recurring 'nobody told me' complaint on decay tickets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…decays

One-time notify_migrated flag marks all already-tracked users as warned on
first load, so enabling the heads-up DM doesn't blast the ~hundreds already
inactive/decaying. Active users reset on next activity; only decays that
begin after this ships will DM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
BankDecay - use server currency in decay DM, surface DM failures

BankDecay is a public cog but the inactivity decay warning DM hardcoded
Vertycos "VertCoin" title. Use the guilds own bank currency name so it
reads correctly on any server. When the warning DM fails (user has DMs
closed/blocked), log a warning and post a notice to the configured log
channel so staff can see the user never got warned.
@
The RPC handlers were inline in vrtutils.py and limited to rpc_master and
rpc_quickpull. Split them into rpc.py as an RPCMethods mixin and add the
moderation suite ops automation needs: warn, unwarn, get_warnings, get_cases,
timeout, untimeout, modnote.

The stock Warnings/Mod command callbacks need a Context built from a real
Message, which an RPC caller has no way to produce. The work underneath (config
write, modlog case, DM) needs no ctx, so these reimplement it directly: warnings
key off a generated snowflake so [p]unwarn still works on them, and cases file
through modlog.create_case, so [p]listcases sees them like any human action.

Native Discord timeouts have no stock casetype; the cog registers timeout and
untimeout on load (register_casetypes is idempotent).

Every mutating method takes an explicit moderator_id rather than inferring one,
so a case always attributes to whoever the caller names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Admins can set a wait after a ban before a first appeal (bancooldown, read from the target guild audit log) and a wait after a denial before re-appealing (reappealcooldown, when appeal_limit > 1). Both default disabled, take human durations, bypass admins, and show in [p]appeal view. Adds decided_at to submissions to track decision time. Version 0.3.0.
Fires member + payload (guild, channel, amount_spent, amount_awarded, valid_purchases, currency_name, purchases) after credit deposit so other cogs can react to claims. Restores the 0.3.0 change that was lost from the working tree before it was committed.
Manage PalWorld dedicated servers from Discord over the official REST API.
RCON is unsupported on purpose: Pocketpair deprecated it and the REST API
covers everything here.

- 30 second poll loop with join/leave and online/offline log embeds
- Live status panel (Components V2) with per-server counts and a 12 hour
  player graph rebuilt from stored sessions, no snapshot table
- Per Discord server graph timezone, IANA names, label tracks DST
- Player database: identity, name history, IP history, sessions, playtime
- Lookups: palstats, paltop, palstatus, palplayers, paltools findplayer
- Staff controls: announce, kick, ban, unban, save, confirmed shutdown
- paltools settings reads the /settings endpoint, headline values in an
  embed with the full payload attached as JSON
- Interactive server manager for adding, editing, testing and removing
- Backup and restore to move a setup between bots or databases, remapping
  every row id so a dump can land anywhere
- findplayer only reveals IPs with logips on, a mod or higher asking, and
  a channel @everyone cannot read
The join/leave embeds are replaced with one arktools-style line per event,
batched into a single message per poll tick:

  🟢 `Name, Account, userId` joined **Server** (Lvl 21, 78ms)
  🔴 `Name, Account, userId` left **Server** (2m)

Player IPs are dropped from the log entirely rather than gated behind
logips, which now covers findplayer alone. Addresses are still recorded.

Since names now reach Discord as message content rather than embed text,
mentions are suppressed on send and backticks are stripped from the
identity, so a crafted name cannot ping the guild or break out of the
code span.
… memory

Every message attachment was downloaded, base64-encoded and kept on the
model until the whole guild had been serialized, so peak memory scaled
with the total size of a server's media - roughly 4x it, before the JSON
document was built on top.

The v2.2.0 guard only rejects individual files over the guild's upload
limit; it puts no ceiling on the total. A server with a busy media
channel produced a 1.9GB backup document and a ~16GB RSS peak, ended by
the host's OOM killer. Restoring had the same problem in reverse, since
the whole file was read into a string before being parsed.

Backups are now zip archives: backup.json plus an attachments/ folder.
Attachments stream into the archive as they download and are read back
out one at a time, so memory stays flat regardless of how much media a
server has. Nothing is skipped - a large backup just takes longer. The
archives are also smaller, since base64 inflated every file by a third
and the JSON is now compressed.

Backups written before this change still load and restore: load_backup
picks the format by extension and FileBackup keeps the base64 field.

Measured on a server with 52MB of attachments: 288MB -> 444MB RSS across
a full backup and restore, where the previous code climbed until killed.
channel.history(limit=...) yields newest-first, and
restore_channel_messages replays the stored list in order, so a restored
channel was rebuilt backwards.

Store messages oldest-first instead. The same most-recent N messages are
still the ones backed up; only the order they are written in changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants