36 KiB
Память, хранение и восстановление Shacraft Core
Статус: план новой реализации с принятыми решениями D008–D009. Этот файл задаёт инварианты и критерии проверки; он не утверждает, что перечисленные механизмы уже реализованы. Публичный API находится в CONTRACT.md, принятые решения — в DECISIONS.md. Дополнительные предложения отдельно перечислены в конце.
1. Главная цель и граница обещаний
Главная цель ядра — ограничить серверную RAM при большом числе независимых матчей на одинаковых картах. Неизменённые блоки общего шаблона хранятся один раз. Матч владеет только собственными изменениями. Данные неактивных областей и история операций остаются на диске.
Нельзя заранее обещать процент экономии относительно Paper или конкретное число игроков на гигабайт. Экономия зависит от разнообразия блоков, числа одновременно активных секций, степени изменения карт, сущностей, клиентской видимости и нагрузки. Успех подтверждают воспроизводимые измерения вместе с функциональными ограничениями реализации.
Бюджет процесса включает больше, чем блоки: кэш секций, временные декодированные секции, индекс хранилища, реестр блоков, метаданные миров, историю, очереди сети, игроков и сущности. Ограничение одного кэша не означает ограничения всей RAM.
2. Адресация и формат секции
Единица хранения — секция 16 × 16 × 16, то есть 4096 ячеек. Координаты мира остаются знаковыми i32 из контракта. Для каждой оси использовать div_euclid(16) и rem_euclid(16): блок -1 попадает в секцию -1, локальную координату 15. Индекс ячейки: x + 16*z + 256*y. Этот порядок фиксируется версией формата.
В первом этапе секция выбирает компактную uniform/palette кодировку:
Uniform(BlockId): все 4096 блоков одинаковы; массив индексов отсутствует.Paletted: уникальные глобальныеBlockIdи плотно упакованные индексы палитры. ДляP > 1требуетсяceil(log2(P))бит на ячейку.
Будущее расширение Dense может хранить 4096 глобальных u32, если палитра вместе с индексами занимает больше места. Это требует явной версии/тега кодировки и пока не входит в принятый минимальный контракт.
Без заголовков и выравнивания палитра занимает 4*P + 4096*ceil(log2(P))/8 байт. При двух состояниях это 520 байт, при 16 — 2112, при 256 — 5120. При 4096 уникальных состояниях получается 22528 байт, поэтому прямой массив в 16384 байта выгоднее. Это расчёт полезных данных, а не RSS и не размер объекта Rust.
При чтении одного блока не нужно распаковывать всю секцию. Изменение использует временный массив на 4096 значений и затем заново выбирает кодировку. Палитра после редактирования должна удалять неиспользуемые состояния. Дисковое сжатие можно добавить после замеров; оно не заменяет ограничение кэшей.
Декодер проверяет версию, длины, размер палитры, диапазон каждого индекса и существование глобальных BlockId. Повреждённая секция возвращает ошибку с её идентификатором; подмена повреждения воздухом запрещена. Кодировка имеет фиксированный порядок байтов и контроль целостности. Контрольная сумма обнаруживает повреждение, но не является защитой от намеренной подмены.
3. Общий шаблон и независимые миры
Шаблон — неизменяемый снимок состояния мира, а не ссылка на его текущее изменяемое состояние. Внутренние идентификаторы мира, снимка и секции отличаются от пользовательских имён. WorldInfo.template может показывать имя источника, но внутренние данные обязаны хранить точный snapshot_id и ревизию источника.
Мир содержит ссылку на базовый снимок и собственную таблицу замен секций. Чтение ищет замену мира, затем секцию в снимке, затем возвращает воздух. Явная замена на полностью воздушную секцию обязательна: отсутствие замены означает наследование, поэтому удаление всех блоков шаблона нельзя представлять отсутствующей записью.
При первом изменении секции из шаблона создаётся новая секция мира. Сама секция шаблона никогда не изменяется. Одинаковые ссылки на неизменяемые секции могут использовать один объект кэша. Возврат секции к точному содержимому шаблона позволяет удалить замену после проверки равенства.
Практическая схема MVP:
- Неизменяемые секции хранятся по идентификаторам на диске.
- Снимок содержит дисковый индекс
координата секции → идентификатор секции. - Мир содержит
snapshot_id, текущую ревизию и дисковый индекс собственных замен. - При
create_world(template=source)снимок берётся строго из одной зафиксированной ревизии источника. Для неизменившегося источника ранее созданный снимок можно переиспользовать. - Если нового снимка ещё нет, индекс эффективных секций копируется потоково по ссылкам, внутри согласованной транзакции. Сами блоки и содержимое секций не копируются в RAM или на диск повторно.
Создание нового снимка таким способом может потребовать времени и места, пропорциональных числу ссылок на секции. Обещать O(1) для первого fork нельзя. Более сложный постоянный индекс с разделяемыми страницами возможен позже, если измерения покажут необходимость. Не следует заменять эту схему бесконечной цепочкой родительских миров: глубокая цепочка ухудшает чтения и усложняет сборку мусора.
Проверка изоляции: изменить источник после fork, изменить один из двух дочерних миров, перезапустить процесс и убедиться, что три состояния различаются именно ожидаемым образом. Снимок остаётся доступным, даже если источник сброшен или впоследствии удалён.
4. Ограничение памяти
cache_sections ограничивает число сохранённых в кэше неизменяемых секций. Ноль явно отклоняется при открытии, как принято в CONTRACT. Для первой реализации используется LRU декодированных секций: массив на 4096 u32 имеет известный размер 16384 байта полезной нагрузки, а объекты и индекс LRU учитываются дополнительно. Хранение палитр непосредственно в кэше — последующая оптимизация; компактная дисковая кодировка сама по себе не уменьшает декодированный кэш.
Дополнительно контролировать байты кэша: учитывать буфер секции, палитру и метаданные записи, показывать их отдельно от полезной нагрузки. Ключ кэша — идентификатор неизменяемой секции. Кэш только по имени мира и координате легко оставляет устаревшие данные после reset.
Временные буферы транзакции не попадают в бесконечный «грязный» кэш. Обработка секций идёт ограниченными порциями; завершённые записи передаются дисковому транзакционному механизму. Полученный через API Vec<BlockChange> уже занимает RAM, поэтому потоковая внутренняя запись не отменяет ограничения размера запроса.
Историю операций, дедупликацию и индекс секций нельзя загружать целиком при старте. Используется дисковый SQL-индекс с ограниченным кэшем страниц SQLite. Настроить и измерять бюджет страниц и временные данные. Выбор библиотеки сам по себе не доказывает соблюдение бюджета.
Метаданные не бесплатны. registry() -> &[String] из контракта предполагает реестр в RAM; list_worlds() создаёт полный список. Для MVP задать явные ограничения на число миров и состояний и длину строк, отразить их в документации. При выходе за эти границы потребуется API с постраничной выдачей.
Принятые ограничения:
- одна операция редактирования — не более 32768 уникальных позиций;
read_region— максимум 262144 ячейки объёма, как в контракте; считать произведение через проверяемую широкую арифметику до выделения памяти;- имя мира соответствует
[A-Za-z0-9_-]{1,64}; координаты каждой оси находятся в диапазоне[-30000000,30000000]; - сервер дополнительно ограничивает размер JSON до десериализации, частоту операций, сетевые очереди и число одновременных запросов;
- максимальное количество сущностей и объём очереди изменений задаются отдельно от кэша блоков.
Для ещё не зафиксированного ограничения operation_id предлагается от 1 до 128 байт UTF-8. Точные пределы реестра и остальных очередей задаются в реализации и документируются. Размеры должны быть проверены на реальном workload. Ограничение read_region относится к объёму области, а не только к числу возвращённых непустых блоков.
5. Дисковая история и атомарность
Минимальные логические сущности хранилища: реестр блоков, миры, снимки, ссылки снимков на секции, замены секций миров, неизменяемые секции, операции и изменения операций. История хранит старые и новые состояния затронутых ячеек, тип операции, ревизии, идентификатор и отпечаток нормализованного запроса. История нужна для undo и идемпотентных повторов; обычный журнал сообщений сервера её не заменяет.
Дедупликация находится на диске с уникальным ключом (world_id, operation_id). Не держать все идентификаторы операций в HashMap. Для поиска отменяемой операции и последующих изменений ячеек нужны дисковые индексы, а не чтение всего журнала на каждый undo.
Одна успешная мутация атомарно фиксирует:
- Новые секции и изменения ссылок мира.
- Новую ревизию, если состояние мира изменилось.
- Запись операции, её отпечаток и точный результат для повторной выдачи.
- Данные истории, необходимые для безопасного undo.
Подтверждение успеха клиенту отправляется после долговечной фиксации всех четырёх частей. Ошибка записи не должна оставлять новую ревизию с прежними блоками или блоки без записи дедупликации. Выделение нового BlockId также долговечно: переоткрытие хранилища не перенумеровывает реестр; air всегда имеет ID 0.
По D008 используется SQLite с WAL и synchronous=FULL; собственный WAL не реализуется. Секции остаются собственным версионированным бинарным форматом внутри транзакционного хранилища. Проверить фактическое применение настроек соединения. Не приравнивать запись в буфер ОС к долговечному commit. Гарантии долговечности предполагают исправную файловую систему и устройство, корректно исполняющее запросы синхронизации.
На одном пути хранилища допускается один владелец записи. &mut WorldStore защищает только конкретный объект Rust: второй процесс или второй независимо открытый объект отклоняется эксклюзивной advisory-блокировкой файла. Блокировка сохраняется весь срок жизни WorldStore; сам факт существования lock-файла не означает занятую блокировку. Второе открытие не должно молча порождать две независимые картины метаданных.
6. Ревизии и идемпотентность
Ревизия — монотонный номер состояния одного мира. Хотя API использует u64, хранение ограничено неотрицательным диапазоном SQLite i64: следующая ревизия выше i64::MAX возвращает явную ошибку. Оборот к нулю запрещён. Reset не возвращает ревизию к нулю и не позволяет старому запросу случайно пройти проверку нового состояния.
Порядок обработки edit и undo:
- Проверить размеры, формат идентификаторов и нормализовать запрос. Повторяющиеся позиции в
changesрекомендуется отвергать, чтобы не зависеть от порядка дублей. - Найти
(world_id, operation_id)в долговечной таблице. При совпадающем отпечатке вернуть сохранённыйEditResult, установивreplayed=true. Это выполняется до сравнения с текущей ревизией: нормальный повтор после потерянного ответа должен работать. - При существующем идентификаторе с другим содержимым вернуть конфликт идемпотентности и ничего не менять.
- Для новой операции проверить
expected_revisionв той же транзакции, что и изменение. При несовпадении вернуть конфликт с текущей ревизией. - Проверить все позиции и
BlockId, применить и долговечно зафиксировать результат, затем отправить ответ и событие изменения.
Отпечаток включает метод и все его значимые аргументы, включая expected_revision; порядок уникальных позиций канонизируется. Область уникальности идентификатора — стабильный внутренний ID мира, а не имя, которое в будущем может быть переиспользовано.
Для операции, реально не изменяющей блоки, предлагается сохранять идемпотентный результат с changed=0 без увеличения ревизии. Пустые и повторные запросы не должны бесконечно увеличивать номер состояния. Такая операция всё же требует долговечной записи дедупликации.
Повтор возвращает ревизию исходной операции, которая может быть меньше текущей. Клиент не должен откатывать свою текущую ревизию по такому ответу. Ошибка после commit, но до доставки ответа, означает неопределённость для вызывающего кода: безопасный повтор использует тот же идентификатор и те же аргументы.
Автоматическое удаление истории меняет гарантию дедупликации. До появления явной политики хранения ID и операций история долговечно сохраняется. Уменьшать её срок незаметно нельзя; ограничение RAM достигается дисковым хранением, а не забыванием уже подтверждённых запросов.
7. Безопасный undo
Undo — новая атомарная операция с собственной ревизией и operation_id; старый журнал не переписывается. Цель обязана принадлежать тому же миру и содержать реально применённые изменения.
Принятое консервативное правило MVP (D009): отмена допустима, только если текущая ревизия мира совпадает с результирующей ревизией целевой правки. Последующая правка даже другой ячейки блокирует отмену. Одного сравнения текущего BlockId с after недостаточно: последовательность «камень → воздух → камень» возвращает тот же блок, но означает чужую более позднюю работу. Выборочная отмена непересекающихся изменений — последующее расширение с отдельными дисковыми индексами происхождения изменений.
При конфликте хотя бы одной ячейки отмена целиком отклоняется с описанием конфликта. Частичная отмена не является поведением по умолчанию. Новая отмена уже отменённой операции получает конфликт; точный повтор того же undo возвращает сохранённый результат. Отмену самого undo можно добавить отдельно, после определения семантики; MVP должен явно сообщать об отсутствии поддержки.
Reset ставит барьер истории для отмен: undo операций до reset отклоняется. Это удобно выразить монотонной epoch мира, записанной вместе с операциями. Дедупликационные записи прежней эпохи сохраняются: повтор старой операции возвращает старый результат, но не применяет её заново.
8. Fork, reset и жизненный цикл
Fork пинует снимок определённой ревизии источника в одной согласованной операции. Не делать последовательность «узнать ревизию → читать секции без защиты → создать мир»: между действиями источник может измениться. История источника не становится историей дочернего мира; ревизия нового мира может начинаться с нуля при сохранённой ссылке на ревизию снимка.
Reset возвращает мир к его закреплённому шаблону, а мир без шаблона — к воздуху. Он атомарно удаляет собственные замены, увеличивает ревизию и эпоху, фиксирует операцию reset и инвалидирует соответствующие производные кэши. Снимки, закреплённые другими мирами, не меняются.
Сервер на reset должен уведомить клиентов о необходимости нового снимка/синхронизации. Событие с пустым changes само по себе не удалит уже отображаемые блоки. Сервер также должен согласовать перенос игроков, сущности и состояние арены; ядро блоков не может решать это за игровой слой.
Освобождение секций и снимков выполняется только после проверки долговечных ссылок из миров, снимков и нужной истории. Обход связей и удаление идут порциями. Нельзя удалять снимок только потому, что имя его источника больше не существует. До реализации проверенной сборки мусора безопаснее оставлять недостижимые данные на диске и показывать их объём в метриках.
9. Сбои и восстановление
После открытия хранилища проверить версию формата, согласованность метаданных и завершить восстановление до выдачи обслуживающих запросов. Незавершённая транзакция не видна. Завершённая и подтверждённая операция сохраняется после аварийного завершения процесса.
Восстановление WAL выполняет SQLite. Приложение не переписывает и не обрезает его самостоятельно. Ошибки целостности базы, неизвестная версия формата секции и неправильная контрольная сумма требуют явной ошибки и сохранения файлов для диагностики. Не «лечить» такие ошибки удалением данных или созданием пустого мира.
Checkpoint выполняется механизмом SQLite, чтобы авария оставляла согласованное состояние. Приложение не удаляет файлы WAL/SHM вручную. flush() возвращает ошибки синхронизации/checkpoint и не маскирует их; его точные гарантии должны быть совместимы с commit-before-ack, а не подменять его.
Обязательные сценарии проверок:
- остановка процесса до записи, посередине записи, после commit и до ответа;
- повтор операции после каждого такого сбоя;
- исчерпание дискового пространства и отказ записи/синхронизации;
- обрезанный хвост и повреждение середины журнала;
- падение во время snapshot, reset, регистрации блока и checkpoint;
- два открытия одного пути, отрицательные и граничные координаты;
- повтор ID с другим запросом, конфликт ревизии, undo с ABA и undo после reset;
- отказ открытия при кэше 0, вытеснение при 1 и небольшом обычном лимите, последующее переоткрытие.
Тест с завершением процесса проверяет process-crash recovery, но не имитирует достоверно отключение питания или поведение аппаратного кэша диска. Эти границы нужно указывать рядом с результатом.
10. Метрики и сравнение с Paper
stats() должен возвращать стабильные именованные поля с единицами измерения. Полезный минимальный набор: cache_sections, cache_limit_sections, cache_payload_bytes, cache_hits, cache_misses, cache_evictions, transient_peak_bytes, world_count, snapshot_count, registry_states, history_operations, storage_bytes, wal_bytes, dirty_overlay_sections, commit_latency_ms, recovery_duration_ms. Счётчик bytes обязан указывать, измеряется ли фактическое выделение или оценка полезной нагрузки.
Снаружи измерять RSS/PSS процесса, пиковую RAM, cgroup memory при наличии, CPU, чтение/запись диска, задержку тика и административных операций. Память файлового кэша ОС не следует автоматически объявлять «сэкономленной» или складывать с RSS без объяснения методики.
Нагрузочные сценарии:
- Один и тот же шаблон и 1, 10, 100 независимых миров без изменений.
- Те же миры с одинаковым фиксированным числом изменённых секций, затем с долей изменений 1%, 10% и 100%.
- Последовательное и случайное движение активной области через карту больше кэша.
- Длительное редактирование с растущей дисковой историей при постоянном активном наборе секций.
- Чередование fork/reset и перезапусков, проверка содержимого после каждого этапа.
Фиксировать seed, карту, число миров, игроков/ботов, дистанцию видимости, набор сущностей, длительность прогрева и замера. Сначала сравнивать варианты собственного ядра: прямой массив против палитры, копирование против общего шаблона, разные лимиты кэша. Это помогает связать эффект с конкретным решением.
При сравнении с Paper записать точные версии серверов, Minecraft, Java и Rust, параметры JVM, оборудование, ОС, плагины, способ создания/копирования миров и одинаковый сценарий игроков. Отдельно показывать режим хранения одинаковых блоков и полноценный игровой сценарий. Если Shacraft не выполняет освещение, AI, redstone, генерацию или другие функции сценария Paper, прямо перечислить различия: такой замер не доказывает превосходство при равной функциональности.
Публиковать исходные команды и сырые результаты, медиану и разброс нескольких прогонов, а также задержки и I/O рядом с RAM. Уменьшение памяти ценой неприемлемого дискового доступа или задержек — измеренный компромисс, а не автоматически успех.
11. Проверка контрактов перед следующими этапами
Часть первоначальных замечаний уже принята в CONTRACT.md и DECISIONS D008–D009; остальные относятся к будущим сетевым этапам:
- Уточнить snapshot/revision в семантике
template; полезно добавить их вWorldInfo. - Обновить управляющий HTTP-метод
world.reset, чтобы он тоже требовал уже принятые в Rust APIexpected_revisionиoperation_id. Для retry create/fork определить идемпотентность и ревизию источника. - Зафиксировать правила no-op и точные пределы строк/реестра; остальных принятых инвариантов это не отменяет.
- Ввести машиночитаемые коды ошибок: неизвестный мир/блок, конфликт ревизии, конфликт ID, конфликт undo, превышение лимита, повреждённое хранилище, отказ записи.
- Уточнить события reset/resync, согласованный снимок
welcomeи доставку изменений после его ревизии; иначе клиент может пропустить изменение между снимком и подпиской. - Для server inputs явно проверять конечность чисел, границы координат, размер сообщений, частоту запросов, дальность взаимодействия и права на каждый мир. Токен управления не должен попадать в публичные ответы или логи.
manifest_hashподтверждает только заявленную версию ресурсов. Он не доказывает отсутствие модификаций клиента; игровая проверка действий остаётся на сервере.
Порядок реализации: компактная секция и её проверки → долговечная атомарная операция и recovery → ревизии/дедупликация/undo → snapshot/fork/reset → ограниченный кэш и метрики → нагрузочные измерения. В каждом этапе сначала сохраняются корректность и восстановление, затем добавляется оптимизация.
Источники по долговечности
SQLite WAL и PRAGMA synchronous описывают синхронизацию WAL при каждом commit в режиме FULL. Это выбранная настройка; корректность нашей схемы и восстановления всё равно проверяется отдельно.