Техническая статья

Бюджет ресурсов PDF-парсера в Delphi: PDFium Component

PDFium Component выставляет TPdfParserResourceBudget — единственную запись, которая ограничивает глубину рекурсии, число токенов, число объектов, декодированные байты, коэффициент расширения, аллокации и время по настенным часам для каждого PDF-парсера в библиотеке. ConfigurePdfParserResourceBudget выставляет её атомарно для процесса, а каждый парсинг копирует снапшот, чтобы пороги не могли сдвинуться в середине операции

Шесть парсеров, шесть приватных потолков

Ситуация, которая это вынудила, была заурядной и, вероятно, знакомой. У sparse-обхода xref был свой потолок объектов. У инспектора потоков — свой лимит декодированных байтов. У XMP один, у XFDF и FDF другой, у нормализации контента третий, у дефолтов preflight четвёртый. Каждое из этих чисел было защитимо само по себе, и ни одно не было достижимо снаружи, так что вызывающему, который хотел сказать «этот путь приёма обрабатывает анонимные загрузки, ничто здесь не может аллоцировать больше 64 МиБ или работать дольше четырёх секунд», некуда было это сказать. Вторая проблема была хуже, потому что молчала: там, где лимит жил в изменяемой глобальной переменной, парсинг, начавшийся под одной политикой, мог закончиться под другой. Один поток зовёт сеттер, пока другой на середине объектного потока, и первая половина обхода ограничена старым потолком, а вторая — новым. Ничего не падает, ничего не логируется, и потом вы не можете сказать, под какими лимитами операция на самом деле выполнялась, — а это ровно то свойство, ради которого лимиты и нужны

Что TPdfParserResourceBudget на самом деле ограничивает?

Семь измерений, выбранных так, что каждое ловит класс входных данных, которые пропускают остальные. MaxRecursionDepth по умолчанию 1024 и не даёт вложенным словарям и массивам превратить объектный синтаксис ISO 32000-1 §7.3 в переполнение стека. MaxTokens по умолчанию 10 000 000 и ограничивает лексическую работу независимо от размера файла. MaxObjects по умолчанию 4 000 000 и покрывает счётчики подсекций xref, значения /N объектных потоков и обходы графов. MaxDecodedBytes по умолчанию 256 МиБ, а MaxAllocationBytes — 512 МиБ, так что файл можно отвергнуть до аллокации, а не после неё. MaxExpansionRatio по умолчанию 1000.0 — и именно его недооценивают: фильтры потоков из ISO 32000-1 §7.4 — это компрессия общего назначения, несколько килобайт данных Flate могут легально развернуться в сотни мегабайт, а потолок коэффициента — стандартная защита от decompression bomb. Он ортогонален абсолютному лимиту байтов, потому что поток на 200 КиБ, желающий стать 200 МиБ, лежит под потолком 256 МиБ и при этом абсурден. DeadlineMilliseconds по умолчанию 0 — то есть дедлайна нет, и это измерение строго opt-in. Дефолты как набор намеренно щедрые — подобраны так, чтобы обычные деловые документы ничего не задевали, а не как ужесточённый профиль; если ваш вход приходит из публичной формы загрузки, вам нужно что-то ближе к цифрам ниже, вместе с правилами из закалки панели предпросмотра PDF в Delphi

Диаграмма семи измерений, которые ограничивает TPdfParserResourceBudget в PDFium Component: MaxRecursionDepth 1024, MaxTokens 10000000, MaxObjects 4000000, MaxDecodedBytes 256 МиБ, MaxAllocationBytes 512 МиБ, MaxExpansionRatio 1000.0 как защита от decompression bomb и opt-in лимит времени DeadlineMilliseconds по настенным часам, всё выставляется атомарно для процесса через ConfigurePdfParserResourceBudget
Семь измерений выбраны потому, что каждое ловит класс входных данных, которые пропускают остальные, а коэффициент расширения стоит между несколькими килобайтами данных Flate и сотнями мегабайт
uses
  FPdfPdfCommon;

procedure ApplyUntrustedIntakeBudget;
var
  Budget: TPdfParserResourceBudget;
begin
  Budget := TPdfParserResourceBudget.Default;
  // Дефолты оставлены: MaxRecursionDepth 1024, MaxTokens 10000000,
  // MaxObjects 4000000
  Budget.MaxDecodedBytes      := Int64(32) * 1024 * 1024;  // дефолт 256 МиБ
  Budget.MaxAllocationBytes   := Int64(64) * 1024 * 1024;  // дефолт 512 МиБ
  Budget.MaxExpansionRatio    := 50.0;                     // дефолт 1000.0
  Budget.DeadlineMilliseconds := 4000;                     // дефолт 0 = выкл
  ConfigurePdfParserResourceBudget(Budget);
end;

Почему снапшот вместо чтения глобала?

Потому что лимит, который может меняться, пока его соблюдают, — не лимит. Это ядро всей фичи и одна строка дисциплины в каждой точке входа: прочитайте политику один раз в начале операции в локальную TPdfParserResourceBudget, и пусть каждая контрольная точка сравнивает с этой локальной копией. GetPdfParserResourceBudget берёт критическую секцию и возвращает копию записи по значению, и после этого парсинг больше не трогает изменяемое глобальное состояние. Итог: вызов ConfigurePdfParserResourceBudget из другого потока влияет на следующую операцию, но никогда на ту, что в полёте. Параллельные парсинги работают под разными политиками, не мешая друг другу, операция, упавшая на лимите ресурсов, падала против единого связного набора порогов, и на горячем пути нет контеншена на локах, потому что лок берётся один раз на парсинг, а не один раз на токен

Диаграмма snapshot-политики в PDFium Component: процесс-широкая запись TPdfParserResourceBudget стоит за одной критической секцией, каждый парсинг вызывает GetPdfParserResourceBudget один раз ради копии по значению, каждая контрольная точка сравнивает с этим локальным снапшотом, а параллельный вызов ConfigurePdfParserResourceBudget из другого потока влияет только на следующую операцию, никогда на ту, что в полёте
Чтение политики один раз в локальную запись означает, что изменение конфигурации из другого потока влияет только на следующую операцию, а на горячем пути нет контеншена на локах
type
  TCustomScanState = record
    Budget: TPdfParserResourceBudget;
    StartTick: Cardinal;
    TokenCount: Int64;
    Depth: Integer;
  end;

procedure BeginScan(var S: TCustomScanState);
begin
  S.Budget := GetPdfParserResourceBudget;  // снапшот берётся один раз, заранее
  S.StartTick := PdfParserStartTick;
  S.TokenCount := 0;
  S.Depth := 0;
end;

function ScanCheckpoint(var S: TCustomScanState): Boolean;
begin
  Result := False;
  if PdfParserDeadlineReached(S.StartTick, S.Budget) then
    Exit;
  Inc(S.TokenCount);
  if S.TokenCount > S.Budget.MaxTokens then
    Exit;
  if S.Depth > S.Budget.MaxRecursionDepth then
    Exit;
  Result := True;
end;

Если вы пишете свой парсер поверх библиотеки — копируйте эту форму, а не зовите GetPdfParserResourceBudget внутри цикла токенов. Вызов на каждый токен корректен, но бессмыслен: вы платите критическую секцию на каждой итерации и возвращаете дрейф посреди операции, против которого снапшот и был

Почему потолок байтов не останавливает патологический файл?

Потому что размер и стоимость в PDF связаны лишь приблизительно. Файл на 400 КиБ с xref-потоком из ISO 32000-1 §7.5.8, чьи массивы /Index и /W описывают правдоподобное, но враждебное число записей, цепочка объектных потоков, возвращающаяся к тем же смещениям, или поток контента из операторов, каждый из которых стоит дороже, чем выглядит, — всё это спокойно уляжется под любыми вашими потолками байтов и объектов и всё равно займёт рабочий поток на минуты. Счётчики байтов ограничивают память; время они не ограничивают. Для этого и существуют PdfParserStartTick и PdfParserDeadlineReached. PdfParserStartTick захватывает значение тика в начале операции, а PdfParserDeadlineReached сравнивает прошедшее время с DeadlineMilliseconds, возвращая False всякий раз, когда дедлайн равен 0. Вычитание идёт в беззнаковой арифметике, так что переполнение счётчика тиков не даёт ложного таймаута. Парсинг xref, декодирование объектных потоков, нормализация токенов контента и читатели FDF и XFDF проверяют его в заголовках своих циклов, так что превысивший бюджет парсинг останавливается на контрольной точке, а не убивается снаружи

Две честные оговорки. Это время по настенным часам, а не CPU-время, так что машина под свопом или build-сервер под нагрузкой могут словить дедлайн, который тихая машина прошла бы, — это довод ставить его заметно выше вашего наблюдаемого худшего случая, а не вблизи медианы. И он выключен по умолчанию, потому что дедлайн, сработавший на легально большом документе в пакетной обработке конца месяца, — худший отказ, чем тот, что он предотвращает. Включайте его по каждому пути приёма отдельно, а не глобально, и сочетайте со структурными проверками из валидации xref-потоков

Бюджет — потолок, а не замена форматных лимитов

Каждый парсер держит свои форматно-специфичные константы и применяет и те, и другие. XFDF — самый ясный пример: читатель полей принуждает XfdfMaxTopLevelFields и константу MaxFieldNodes в 100000 наряду со значением снапшота, и остановить парсинг может любой из них

const
  XfdfMaxTopLevelFields = 100000;   // формат-локальная, действует всегда
...
  // Оба потолка живые; побеждает тот, что ниже
  if (FieldCount >= XfdfMaxTopLevelFields) or
     (FieldCount >= Budget.MaxObjects) then
    Exit(False);

Это намеренно, и это стоит усвоить до того, как вы пойдёте искать ручку, которой не существует. Глобальный бюджет в 4 000 000 объектов не даёт XFDF-файлу 4 000 000 полей, потому что 100000 полей уже далеко за пределами любого реального экспорта аннотаций, и поднятие глобального потолка не должно молча расширять форматно-специфичную проверку здравомыслия. Нормализация контента работает так же с другой стороны: TPdfContentNormalizeOptions.Default стартует со своих собственных лимитов на страницу и затем прижимает каждый из них вниз к снапшоту, так что MaxTokensPerPage может только понижаться бюджетом, но никогда не повышаться. Глобальная политика ужесточает; она никогда не ослабляет

Диаграмма того, как бюджет парсера действует как потолок в PDFium Component: читатель полей XFDF принуждает XfdfMaxTopLevelFields на 100000 наряду со значением снапшота MaxObjects, так что побеждает тот, что ниже, а TPdfContentNormalizeOptions прижимает MaxTokensPerPage вниз к бюджету, потому что глобальная политика ужесточает лимиты и никогда их не ослабляет
Глобальный бюджет в 4 000 000 объектов не даёт XFDF-файлу 4 000 000 полей, потому что каждый парсер держит свои форматно-специфичные константы и применяет оба потолка, побеждает нижний

Плохая конфигурация падает, когда вы её конфигурируете

ConfigurePdfParserResourceBudget валидирует до присвоения. Неположительное значение в любом из шести числовых измерений поднимает ошибку немедленно, а MaxDecodedBytes выше High(Integer) отвергается как превышение платформенных пределов, а не молча усекается где-то в глубине декодера. Это важнее, чем звучит: бюджет, собранный из конфиг-файла с отсутствующим ключом, даёт вам нули, а нулевой потолок объектов, принятый на старте, превращается в мистический сбой парсинга на первом документе через три часа, когда никто уже не подозревает конфиг. Та же забота относится к любому коду, временно ужесточающему бюджет, включая тесты. Политика — процесс-широкая, так что оставленный после фикстуры 4-секундный дедлайн отравляет каждый последующий тест; сохраняйте и восстанавливайте вокруг изменения

var
  Saved: TPdfParserResourceBudget;
begin
  Saved := GetPdfParserResourceBudget;
  try
    ApplyUntrustedIntakeBudget;
    ProcessInboundDocument(FileName);
  finally
    ConfigurePdfParserResourceBudget(Saved);
  end;
end;

ResetPdfParserResourceBudget — более грубая версия, восстанавливающая TPdfParserResourceBudget.Default как есть. Используйте её в teardown тестов, где нужен известный стейт, а в продакшене — save-and-restore, потому что вернуть нужно тот стейт, который хост-приложение сконфигурировало на старте

Где бюджет перестаёт помогать

Это единая процесс-широкая политика, и это её главное ограничение. Вы не можете дать одному рабочему потоку строгий профиль, а другому — разрешительный, потому что одна запись за одной критической секцией; если вам реально нужны per-tenant лимиты, рабочая форма — сериализовать каждый класс политик в собственный процесс или обрамлять работу save-and-restore, принимая, что обрамление безопасно, только когда больше никто не парсит параллельно. Она также ограничивает парсинг, а не рендеринг: память растеризации, рост кэша шрифтов и декодирование изображений лежат вне этих семи измерений и требуют собственных контролей. И бюджет говорит вам, что операция была слишком дорогой, но никогда — почему. Сработавший потолок — симптом, а стоящий за ним мальформированный массив /Index или безудержная цепочка фильтров всё равно диагностируется руками, поэтому путь декодирования из объектных потоков и декодирования с предсказателями стоит понять до того, как вы начнёте двигать цифры. Относитесь к бюджету как к вещи, которая не даёт плохому документу утащить процесс за собой, и держите логи достаточно хорошими, чтобы выяснить, чем документ занимался

Полная поверхность парсерного API, запись бюджета и сборка из исходников для Delphi, C++Builder и Lazarus — на странице продукта: PDFium Component