Artigo Técnico

Budget de recursos do parser de PDF com PDFium Component

O PDFium Component expõe TPdfParserResourceBudget, um único record que limita profundidade de recursão, contagem de tokens, contagem de objetos, bytes decodificados, razão de expansão, alocação e um prazo de wall-clock para todo parser de PDF da biblioteca. O ConfigurePdfParserResourceBudget o define atomicamente para o processo, e cada parse copia um snapshot para que os thresholds não mudem no meio da operação

Seis parsers, seis tetos privados

A situação que forçou isso era mundana e provavelmente familiar. A travessia de xref sparse tinha seu próprio teto de objetos. O inspetor de streams tinha seu próprio limite de bytes decodificados. XMP tinha um, XFDF e FDF tinham outro, a normalização de conteúdo tinha um terceiro, e os defaults de preflight um quarto. Cada um desses números era defensável por si só e nenhum deles era alcançável de fora, então um caller que quisesse dizer "esse caminho de intake lida com uploads anônimos, nada aqui pode alocar mais de 64 MiB ou rodar mais de quatro segundos" não tinha onde dizer. O segundo problema era pior porque era silencioso: onde um limite vivia num global mutável, um parse que começou sob uma política podia terminar sob outra. Uma thread chama um setter enquanto outra está no meio de um object stream, e a primeira metade daquela travessia é limitada pelo teto antigo enquanto a segunda metade é limitada pelo novo. Nada quebra, nada loga, e depois você não consegue dizer sob quais limites a operação de fato rodou, que é exatamente a propriedade para a qual você queria os limites

O que o TPdfParserResourceBudget de fato limita?

Sete dimensões, escolhidas porque cada uma pega uma classe de input que as outras deixam passar. O MaxRecursionDepth tem default de 1024 e impede que dicionários e arrays aninhados transformem a sintaxe de objeto da ISO 32000-1 §7.3 num stack overflow. O MaxTokens tem default de 10.000.000 e limita o trabalho de lexing independente do tamanho do arquivo. O MaxObjects tem default de 4.000.000 e cobre contagens de subseção de cross-reference, valores /N de object stream e travessias de grafo. O MaxDecodedBytes tem default de 256 MiB e o MaxAllocationBytes de 512 MiB, então um arquivo pode ser rejeitado antes da alocação em vez de depois. O MaxExpansionRatio tem default de 1000.0 e é o que as pessoas subestimam: os filtros de stream da ISO 32000-1 §7.4 são compressão de propósito geral, uns poucos kilobytes de dados Flate podem legitimamente expandir para centenas de megabytes, e um teto de razão é a defesa padrão contra bomba de descompressão. Ele é ortogonal ao limite absoluto de bytes, porque um stream de 200 KiB que quer virar 200 MiB está abaixo do teto de 256 MiB e mesmo assim é absurdo. O DeadlineMilliseconds tem default de 0, ou seja, sem prazo, então essa dimensão é estritamente opt-in. Os defaults como conjunto são deliberadamente generosos, dimensionados para não derrubar nada em documentos de negócio comuns em vez de serem um perfil endurecido; se seu input vem de um formulário público de upload, você quer algo mais perto dos números abaixo, junto com as regras descritas em endurecendo um painel de preview de PDF em Delphi

Diagrama das sete dimensões que o TPdfParserResourceBudget limita no PDFium Component: MaxRecursionDepth 1024, MaxTokens 10000000, MaxObjects 4000000, MaxDecodedBytes 256 MiB, MaxAllocationBytes 512 MiB, MaxExpansionRatio 1000.0 como defesa contra bomba de descompressão, e o limite de wall-clock opt-in DeadlineMilliseconds, tudo definido atomicamente para o processo pelo ConfigurePdfParserResourceBudget
As sete dimensões são escolhidas porque cada uma pega uma classe de input que as outras deixam passar, com a razão de expansão no meio do caminho entre uns poucos kilobytes de dados Flate e centenas de megabytes
uses
  FPdfPdfCommon;

procedure ApplyUntrustedIntakeBudget;
var
  Budget: TPdfParserResourceBudget;
begin
  Budget := TPdfParserResourceBudget.Default;
  // Defaults kept: MaxRecursionDepth 1024, MaxTokens 10000000,
  // MaxObjects 4000000
  Budget.MaxDecodedBytes      := Int64(32) * 1024 * 1024;  // default 256 MiB
  Budget.MaxAllocationBytes   := Int64(64) * 1024 * 1024;  // default 512 MiB
  Budget.MaxExpansionRatio    := 50.0;                     // default 1000.0
  Budget.DeadlineMilliseconds := 4000;                     // default 0 = off
  ConfigurePdfParserResourceBudget(Budget);
end;

Por que tirar um snapshot em vez de ler o global?

Porque um limite que pode mudar enquanto está sendo aplicado não é um limite. Esse é o ponto central de design de toda a feature, e é uma linha de disciplina em todo entry point: leia a política uma vez no começo da operação num TPdfParserResourceBudget local, e depois faça todo checkpoint comparar contra essa cópia local. O GetPdfParserResourceBudget pega a critical section e devolve uma cópia por valor do record, e depois disso o parse nunca mais toca estado global mutável. O resultado é que uma chamada ao ConfigurePdfParserResourceBudget de outra thread afeta a próxima operação, nunca a que está em voo. Parses concorrentes rodam sob políticas diferentes sem interferir, uma operação que falhou num limite de recurso falhou contra um conjunto único e coerente de thresholds, e não há lock contention no hot path porque o lock é pego uma vez por parse, não uma vez por token

Diagrama da política de snapshot no PDFium Component: o record TPdfParserResourceBudget do processo fica atrás de uma critical section, cada parse chama GetPdfParserResourceBudget uma vez para uma cópia por valor, todo checkpoint compara contra esse snapshot local, e uma chamada concorrente a ConfigurePdfParserResourceBudget de outra thread afeta só a próxima operação, nunca a que está em voo
Ler a política uma vez num record local significa que uma mudança de configuração de outra thread afeta só a próxima operação, e não há lock contention no hot path
type
  TCustomScanState = record
    Budget: TPdfParserResourceBudget;
    StartTick: Cardinal;
    TokenCount: Int64;
    Depth: Integer;
  end;

procedure BeginScan(var S: TCustomScanState);
begin
  S.Budget := GetPdfParserResourceBudget;  // snapshot tirado uma única vez, antecipadamente
  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;

Se você escreve seu próprio parser em cima da biblioteca, copie essa forma em vez de chamar GetPdfParserResourceBudget dentro do seu loop de tokens. Chamar por token é correto, mas inútil: você paga uma critical section a cada iteração e reintroduz o drift de meio de operação que o snapshot existia para impedir

Por que um teto de bytes não para um arquivo patológico?

Porque tamanho e custo têm relação só frouxa em PDF. Um arquivo de 400 KiB com um stream de cross-reference da ISO 32000-1 §7.5.8 cujos arrays /Index e /W descrevem uma contagem de entradas plausível mas hostil, uma cadeia de object streams que revisita os mesmos offsets, ou um content stream de operadores que cada um custa mais do que parece, fica confortavelmente abaixo de todo teto de bytes e objetos que você definir e ainda ocupa uma worker thread por minutos. Contagem de bytes limita memória; não limita tempo. É para isso que existem PdfParserStartTick e PdfParserDeadlineReached. O PdfParserStartTick captura um valor de tick no começo da operação e o PdfParserDeadlineReached compara o tempo decorrido contra o DeadlineMilliseconds, devolvendo False sempre que o prazo é 0. A subtração roda em aritmética sem sinal, então um rollover do contador de ticks não produz um timeout espúrio. Parsing de cross-reference, decodificação de object stream, normalização de tokens de conteúdo e os readers de FDF e XFDF verificam isso no topo dos loops, então um parse estourado para num checkpoint em vez de ser morto de fora

Duas ressalvas honestas. É tempo de wall-clock, não de CPU, então uma máquina que troca em disco ou um build server sob carga pode disparar um prazo que uma máquina quieta passaria, o que defende definir bem acima do seu pior caso observado em vez de perto da mediana. E ele vem desligado por padrão, porque um prazo que dispara num documento legitimamente grande durante um batch de fim de mês é uma falha pior do que a que ele previne. Faça opt-in por caminho de intake em vez de global, e combine com os checks estruturais em validando streams de cross-reference

O budget é um teto, não um substituto para limites de formato

Cada parser mantém suas próprias constantes específicas de formato e aplica as duas. XFDF é o exemplo mais claro: o reader de fields aplica XfdfMaxTopLevelFields e uma constante MaxFieldNodes de 100000 junto com o valor do snapshot, e qualquer um dos dois pode parar o parse

const
  XfdfMaxTopLevelFields = 100000;   // format-local, always applies
...
  // Ambos os tetos estão ativos; vence o menor
  if (FieldCount >= XfdfMaxTopLevelFields) or
     (FieldCount >= Budget.MaxObjects) then
    Exit(False);

Isso é intencional e vale internalizar antes de sair procurando um knob que não existe. Um budget global de 4.000.000 de objetos não dá a um arquivo XFDF 4.000.000 de fields, porque 100000 fields já está muito fora de qualquer coisa que um export real de anotações produz, e subir um teto global não deveria alargar silenciosamente um sanity check específico de formato. A normalização de conteúdo funciona do mesmo jeito na direção oposta: o TPdfContentNormalizeOptions.Default parte dos próprios limites por página e depois limita cada um deles para baixo até o snapshot, então o MaxTokensPerPage só pode ser rebaixado pelo budget, nunca elevado. A política global aperta; nunca afrouxa

Diagrama de como o budget do parser atua como teto no PDFium Component: o reader de fields do XFDF aplica XfdfMaxTopLevelFields em 100000 junto com o valor de MaxObjects do snapshot, então o menor dos dois para o parse, enquanto o TPdfContentNormalizeOptions limita o MaxTokensPerPage para baixo até o budget, porque a política global aperta limites e nunca os afrouxa
Um budget global de 4.000.000 de objetos não dá a um arquivo XFDF 4.000.000 de fields, porque cada parser mantém suas constantes específicas de formato e aplica os dois tetos com o menor vencendo

Configuração ruim falha na hora de configurar

O ConfigurePdfParserResourceBudget valida antes de atribuir. Um valor não positivo em qualquer uma das seis dimensões numéricas levanta na hora, e MaxDecodedBytes acima de High(Integer) é rejeitado por exceder limites de plataforma em vez de ser truncado silenciosamente em algum lugar profundo de um decoder. Isso importa mais do que parece: um budget montado de um arquivo de config com uma chave faltando te dá zeros, e um teto de objetos zero aceito no startup vira uma falha de parse misteriosa no primeiro documento três horas depois, quando ninguém desconfia da config. O mesmo cuidado vale para qualquer código que aperte o budget temporariamente, inclusive testes. A política é do processo inteiro, então deixar um prazo de 4 segundos instalado depois que um fixture termina envenena todos os seguintes; salve e restaure em volta da mudança

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

O ResetPdfParserResourceBudget é a versão mais bruta, restaurando TPdfParserResourceBudget.Default por completo. Use no teardown de testes onde você quer um estado conhecido, e use save-and-restore em código de produção, onde o estado que você precisa voltar é o que o aplicativo host configurou no startup

Onde o budget para de ajudar

É uma política única do processo, e essa é a limitação principal. Você não consegue dar a uma worker thread um perfil estrito e a outra um permissivo, porque existe um record atrás de uma critical section; se você de fato precisa de limites por tenant, a forma que funciona é serializar cada classe de política no seu próprio processo, ou bracketar o trabalho com save-and-restore e aceitar que o bracket só é seguro quando nada mais está parseando em paralelo. Ele também limita parsing, não rendering: memória de rasterização, crescimento do cache de fontes e decodificação de imagem ficam fora dessas sete dimensões e precisam de controles próprios. E um budget te diz que uma operação foi cara demais, nunca por quê. O teto que disparou é o sintoma, e o array /Index malformado ou a cadeia de filtros descontrolada atrás dele ainda tem que ser diagnosticado na mão, e é por isso que o caminho de decodificação descrito em decodificação de object streams e predictors vale entender antes de começar a mexer nos números. Trate o budget como a coisa que impede um documento ruim de derrubar o processo junto, e mantenha seu logging bom o bastante para descobrir o que o documento estava fazendo

A superfície completa da API de parser, o record do budget e um build em código-fonte para Delphi, C++Builder e Lazarus estão na página do produto: PDFium Component