ESPN DeportesDuelo de jóvenes superestrellas: ¿Qué aportan Chourio y Caminero a la Serie Mundial 2026?ESPNCarrick unsure on future amid Man United pressurePunchFG releases N38.93bn to upgrade 166 tertiary institutionsThe Jerusalem PostAsher Grunis steps down from leading Submarine Affair commission due to health concernsCBS News10/25: Face The Nation20 Minuten«Knallte wie eine Explosion»: Fenster zerschmettert neben RestiZDF heuteAktuelle Pressemitteilungen des ZDFMyJoyOnlineBright Simons proposes $13m civil service AI assistant as alternative to Ghana’s $270m GhanaChat planComplete Sports2026 U-17 WWC: Flamingos To Face Mexico In Round Of 16ANSA SportRugby: Italia in raduno a Padova, tante le assenze per QuesadaBBC News BrasilVoto no exterior: veja os resultados por país e a apuração de Lula x Flávio no 2º turno das eleições 2026ColliderTreasure Island: 20 Years of Friendship Made Cruelty Harder | Set Stories
The Daily Newsstand · Free, Always
Sunday, October 25, 2026

Cross-Cleaner: стриминг JSON частями вместо загрузки всей базы очистки в память

Translate

Стриминговый разбор JSON в Cross-Cleaner на serde/serde_json: практическая реализация

Что уже есть в коде

Cross-Cleaner уже проделал большую часть работы по снижению памяти — но не на уровне парсинга, а на уровне представления данных:

  • Arc<str> вместо String для category, program, class, sub_category, files_to_remove, directories_to_remove — с дедупликацией через string_interner (intern_arc);

  • SharedPath — путь хранится как Arc<[Arc<str>]> из интернированных сегментов, что даёт разделение общих префиксов (AppData\Local\...) между десятками тысяч записей;

  • CleanerIndex и RegistryIndex — лёгкие проекции для сканирования категорий, где «неизвестные поля игнорируются serde и не аллоцируются».

Это уже сильно снижает установившийся размер базы в памяти. Но пик потребления при десериализации всё ещё определяется тем, как именно вызывается serde_json. Если база грузится через serde_json::from_reader::<_, Vec<CleanerData>>, то в момент разбора в памяти одновременно живут: буфер чтения, строящийся Vec, и промежуточные String внутри кастомного Deserialize for CleanerData (структура Helper содержит String, а не Arc<str>). Именно Helper — главный источник временных аллокаций.

Где именно теряется память при текущем Deserialize

Кастомный impl<'de> Deserialize<'de> for CleanerData делает:

rust

#[derive(Deserialize)]
struct Helper {
    path: String,
    category: String,
    program: String,
    class: String,
    sub_category: String,
    files_to_remove: Vec<String>,
    directories_to_remove: Vec<String>,
    // ...
}
let h = Helper::deserialize(deserializer)?;
// затем всё это переинтернивается в Arc<str>

То есть на каждую запись сначала аллоцируются String, а уже потом они конвертируются в Arc<str> через intern_arc. Пиковая память на запись ≈ размер всех её строк в виде String + размер интернированных Arc<str> + накладные расходы Vec. Для одной записи это немного, но при потоковом разборе важно, что Helper освобождается сразу после конвертации — а при from_reader в Vec<CleanerData> это происходит для каждой записи последовательно, так что пик по временным строкам не суммируется. Хорошая новость: сам по себе этот Deserialize уже не «загружает всё в строки разом».

Проблема остаётся в итоговом Vec<CleanerData>: все записи живут одновременно. Для базы очистки это, вероятно, приемлемо, если она нужна целиком для сканирования. Но если задача — например, построить только CleanerIndex или прогнать категорию, держать полный Vec<CleanerData> не нужно.

Что даёт StreamDeserializer в этой кодовой базе

serde_json::StreamDeserializer уместен в двух сценариях.

Сценарий 1: построение индекса без полной базы

Если база — JSON-массив CleanerData, а нужен только CleanerIndex (для сканирования категорий), можно не десериализовать CleanerData вовсе. CleanerIndex уже игнорирует лишние поля, и serde_json не будет их аллоцировать. При потоковом обходе элементов массива в памяти живёт один CleanerIndex за раз:

rust

use std::fs::File;
use std::io::BufReader;

fn build_index(path: &str) -> Result<Vec<CleanerIndex>, Box<dyn std::error::Error>> {
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    let mut index = Vec::new();

    for item in iter_json_array::<CleanerIndex, _>(reader) {
        index.push(item?); // в памяти — только CleanerIndex, без Helper и без полных данных
    }
    Ok(index)
}

Ключевой момент: CleanerIndex использует deserialize_shared_opt/deserialize_shared_str_default, то есть сразу интернирует в Arc<str>. Временные String живут только внутри одного вызова Deserialize и освобождаются до перехода к следующему элементу. По сравнению с Vec<CleanerData>, где каждая запись тащит files_to_remove, directories_to_remove и flags, это радикально меньше.

Сценарий 2: обработка базы по одной записи без накопления

Если CleanerData нужны, но не все сразу (например, фильтрация по ОС, по категории, немедленная регистрация в планировщике), потоковый обход позволяет не держать Vec:

rust

fn for_each_rule<F>(path: &str, mut f: F) -> Result<(), Box<dyn std::error::Error>>
where
    F: FnMut(CleanerData) -> Result<(), Box<dyn std::error::Error>>,
{
    let file = File::open(path)?;
    let reader = BufReader::new(file);
    for item in iter_json_array::<CleanerData, _>(reader) {
        f(item?)?; // item освобождается после вызова
    }
    Ok(())
}

Здесь Helper внутри Deserialize for CleanerData создаётся и уничтожается на каждой записи, а итоговый CleanerData живёт ровно один вызов f.

Обёртка iter_json_array под топ-левел массив

StreamDeserializer не умеет итерировать элементы массива напрямую, поэтому нужна тонкая обёртка. Она не держит данные — только читает байты и пропускает [, ,, ]. Минимальная версия:

rust

use serde::de::DeserializeOwned;
use serde_json::Deserializer;
use std::io::{self, Read};

fn skip_ws<R: Read>(reader: &mut R) -> io::Result<Option<u8>> {
    loop {
        let mut b = [0u8; 1];
        match reader.read(&mut b)? {
            0 => return Ok(None),
            _ if b[0].is_ascii_whitespace() => continue,
            _ => return Ok(Some(b[0])),
        }
    }
}

fn deserialize_one<T: DeserializeOwned, R: Read>(reader: &mut R) -> io::Result<T> {
    Deserializer::from_reader(reader)
        .into_iter::<T>()
        .next()
        .ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "premature EOF"))?
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}

pub fn iter_json_array<T, R>(mut reader: R) -> impl Iterator<Item = io::Result<T>>
where
    T: DeserializeOwned,
    R: Read,
{
    let mut started = false;
    let mut done = false;
    std::iter::from_fn(move || {
        if done {
            return None;
        }
        if !started {
            started = true;
            match skip_ws(&mut reader) {
                Ok(Some(b'[')) => {}
                Ok(_) => {
                    done = true;
                    return Some(Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "expected `[`",
                    )));
                }
                Err(e) => {
                    done = true;
                    return Some(Err(e));
                }
            }
            match skip_ws(&mut reader) {
                Ok(Some(b']')) => {
                    done = true;
                    return None;
                }
                Ok(Some(first)) => {
                    // «вернуть» первый байт в поток невозможно, поэтому
                    // используем Cursor с цепочкой
                    let chained = std::io::Cursor::new([first]).chain(reader);
                    let mut chained = chained;
                    return Some(deserialize_one(&mut chained));
                }
                Ok(None) => {
                    done = true;
                    return Some(Err(io::Error::new(
                        io::ErrorKind::UnexpectedEof,
                        "unterminated array",
                    )));
                }
                Err(e) => {
                    done = true;
                    return Some(Err(e));
                }
            }
        }
        match skip_ws(&mut reader) {
            Ok(Some(b',')) => Some(deserialize_one(&mut reader)),
            Ok(Some(b']')) => {
                done = true;
                None
            }
            Ok(_) => {
                done = true;
                Some(Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    "expected `,` or `]`",
                )))
            }
            Err(e) => {
                done = true;
                Some(Err(e))
            }
        }
    })
}

Важная деталь: deserialize_one создаёт StreamDeserializer на одном элементе и сразу его отбрасывает, поэтому внутренние буферы serde_json не накапливаются между элементами. Обёртка Cursor::new([first]).chain(reader) нужна только для первого элемента, чтобы не потерять уже прочитанный байт.

Почему это сочетается с текущим дизайном

Текущий код уже готов к потоковому использованию:

  1. CleanerIndex и RegistryIndex — готовые лёгкие типы. Их Deserialize использует deserialize_shared_opt, то есть сразу интернирует. Временные String живут в пределах одного элемента.

  2. deserialize_vec_arc — принимает Vec<String>, интернирует и возвращает Vec<Arc<str>>. Временные String для files_to_remove/directories_to_remove освобождаются сразу после интернирования, не дожидаясь конца базы.

  3. SharedPath::deserialize — читает String, разбивает на сегменты и интернирует каждый. Временная строка пути живёт только на время одного Deserialize.

  4. CleanerData::Deserialize через Helper — единственное место, где временные String живут чуть дольше, но всё равно в пределах одной записи.

То есть переход от from_reader::<_, Vec<CleanerData>> к iter_json_array::<CleanerData, _> не требует менять ни один Deserialize — он уже написан так, что временные аллокации локальны для записи.

Ограничения и подводные камни

  • StreamDeserializer и BufReader. serde_json не буферизует ввод. Без BufReader каждый read — это syscall, и потоковый разбор станет медленнее полного. BufReader обязателен.

  • Формат базы. Если база — не топ-левел массив, а, например, объект {"rules": [...]}, обёртку нужно адаптировать под префикс. Альтернатива — перевести базу на JSON Lines, тогда iter_json_array не нужен вовсе: BufReader::lines() + serde_json::from_str на строку.

  • CustomCleaner не сериализуется. Он определён в коде, а не в JSON, поэтому к нему потоковый разбор неприменим — там Vec<CustomCleaner> собирается из зарегистрированных функций.

  • CleanerDataRegistry (Windows) — обычный derive(Deserialize), без кастомного Helper. Его тоже можно гонять через iter_json_array, если база реестра — массив.

  • Порядок и агрегация. Если после разбора нужна сортировка или глобальная дедупликация по всей базе, потоковый обход сам по себе не поможет — придётся либо накапливать индекс (что и делает CleanerIndex), либо использовать внешнюю сортировку.

Ожидаемый эффект

Для базы очистки Cross-Cleaner переход на потоковый разбор даёт:

  • пик памяти при загрузке определяется одной записью (CleanerData + временный Helper), а не всей базой;

  • при построении индекса (CleanerIndex) в памяти остаются только SharedPath + три Arc<str> на запись — без files_to_remove, directories_to_remove, flags и без Helper;

  • возможность обрабатывать базы, превышающие доступную RAM, если обработка не требует держать все записи одновременно;

  • сохранение всех существующих оптимизаций (string_interner, SharedPath, Arc<str>) без изменений в Deserialize.

Заключение

В Cross-Cleaner снижение памяти уже достигнуто на уровне представления: интернирование строк и сегментированные пути. Следующий шаг — на уровне способа чтения: заменить serde_json::from_reader::<_, Vec<CleanerData>> на iter_json_array поверх StreamDeserializer (или на JSON Lines). Существующие Deserialize-реализации уже устроены так, что временные String локальны для одной записи, поэтому потоковый обход не требует их переписывать — достаточно обёртки, которая пропускает разделители массива и создаёт StreamDeserializer на каждый элемент отдельно.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.