Artigo Técnico

HotPDF: streaming de PDFs remotos com Range Coalescing

O HotPDF carrega um PDF a partir de qualquer fonte de acesso aleatório que você implemente, e THPDFCoalescingRandomAccessSource encapsula essa fonte de modo que as leituras pequenas e dispersas do parser se tornam um conjunto limitado de intervalos de blocos em cache, com prefetch assíncrono. Em um documento servido por meio de requisições HTTP range, essa é a diferença entre algumas centenas de round trips e apenas algumas dezenas

Nada muda no parser. Você continua chamando LoadFromRandomAccessSource, o mesmo objeto de documento é retornado, e a mesma API de páginas funciona. O que muda é o tráfego por baixo dos panos

Por que o mesmo PDF carrega instantaneamente em local e arrasta pela rede?

Porque um parser de PDF não lê um arquivo, ele navega por ele. Ele busca o final em busca de startxref, salta de volta para a tabela de referência cruzada, resolve o dicionário trailer, segue uma referência até o Catalog, depois até a raiz da árvore de páginas, depois até um nó de página, depois até seu dicionário de recursos. Cada uma dessas etapas lê dezenas de bytes a partir de um offset diferente

Em um arquivo local, esse padrão é praticamente gratuito: o sistema operacional já tem a página de 4 KiB ao redor em cache, então a segunda leitura custa um memcpy. Em um transporte de rede não existe essa localidade. Cada leitura é uma requisição com sua própria latência, e 300 requisições sequenciais a 40 ms cada resultam em doze segundos gastos quase inteiramente esperando. A correção não é ler menos; o parser precisa exatamente do que pede. A correção é fazer com que cada leitura física cubra mais do que a próxima leitura lógica vai querer

O que a coalescência muda

A fonte coalescente arredonda cada leitura para cima até um bloco e coloca o bloco em cache. BlockSize tem padrão de 262.144 bytes e MaxCacheBytes de 2.097.152, de modo que oito blocos ficam residentes por padrão e são removidos por ordem de uso menos recente, respeitando um orçamento rígido de bytes. A leitura de 40 bytes de uma chave do trailer feita pelo parser traz consigo os 256 KiB ao redor, e a dúzia de leituras seguintes nessa vizinhança, que é onde vivem os dados de referência cruzada e de catalog, são atendidas a partir da memória

Sua própria fonte permanece simples. Implemente GetSize e ReadAt, sobrescreva ReadAtCancellable se o seu transporte conseguir abortar uma operação em andamento, e deixe o wrapper cuidar do cache, da coalescência e do prefetch

type
  THttpRangeSource = class(THPDFRandomAccessSource)
  private
    FClient: TMyHttpClient;
    FUrl: string;
    FSize: Int64;
  public
    function GetSize: Int64; override;
    function ReadAt(Offset: Int64; var Buffer; Count: Longint): Longint; override;
    function ReadAtCancellable(Offset: Int64; var Buffer; Count: Longint;
      CancellationToken: THPDFCancellationToken): Longint; override;
  end;

var
  Raw: THttpRangeSource;
  Cached: THPDFCoalescingRandomAccessSource;
  Pdf: THotPDF;
begin
  Raw := THttpRangeSource.Create('https://files.example.com/contract.pdf');
  // OwnsSource=True: o wrapper libera Raw junto consigo mesmo
  Cached := THPDFCoalescingRandomAccessSource.Create(Raw, True, 262144, 8388608);
  Pdf := THotPDF.Create(nil);
  try
    Cached.AsyncPrefetchEnabled := True;
    Cached.AdaptiveReadAheadEnabled := True;
    Cached.MaxReadAheadBlocks := 8;

    if Pdf.LoadFromRandomAccessSource(Cached, True) = 1 then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

Até onde a leitura deve avançar?

A leitura antecipada adaptativa responde a essa pergunta documento a documento, em vez de forçar você a adivinhar. Com AdaptiveReadAheadEnabled ativado, a janela cresce por 1, 2, 4 e 8 blocos à medida que leituras sequenciais para a frente se acumulam, e nunca ultrapassa MaxReadAheadBlocks nem a capacidade de cache configurada. No momento em que chega uma leitura que não está aproximadamente onde a anterior terminou, a janela colapsa e o prefetch é suprimido

SequentialReadToleranceBytes, com padrão de 4.096, define o que é "aproximadamente". Leituras que caem dentro dessa distância do final da leitura anterior ainda contam como sequenciais, o que importa porque um parser de PDF percorrendo um content stream não produz offsets perfeitamente contíguos; ele pula um campo de comprimento aqui, um dicionário inline ali. Configure a tolerância baixa demais e uma varredura normal para a frente é classificada como aleatória, então a leitura antecipada nunca entra em ação. Configure-a alta demais e um acesso genuinamente aleatório parece sequencial, então você busca megabytes que ninguém quer. O padrão é calibrado para a travessia de content streams, e as estatísticas vão dizer se o seu transporte discorda

Essa assimetria é deliberada: o crescimento é gradual, o colapso é imediato. Buscar dados em excesso em uma carga de trabalho de acesso aleatório custa banda real e dinheiro real em transportes medidos, então o erro barato é preferível ao erro caro

Cancelamento que realmente interrompe a transferência

A classe base declara ReadAtCancellable, e a fonte coalescente a honra do início ao fim. Quando chega uma leitura em primeiro plano para um intervalo que um prefetch em andamento não está atendendo, o prefetch é cancelado em vez de ser deixado para terminar, de modo que a requisição de página do usuário não fica na fila atrás de tráfego especulativo. A implementação padrão em THPDFRandomAccessSource recorre a um ReadAt simples, o que significa que o recurso é opcional por transporte: clientes HTTP que suportam abortar requisições ganham cancelamento genuíno, e fontes mais simples continuam funcionando sem alteração

Combine isso com um token de cancelamento propagado pela sua UI, e um usuário que fecha um documento efetivamente interrompe o tráfego de rede em vez de esperar que ele se esgote. O mesmo modelo de token sustenta o enfileiramento descrito em renderização em segundo plano com fila de requisições, de modo que um único token pode cobrir todo o caminho, do viewport até o socket

Lendo as estatísticas do cache de intervalos

GetStatistics preenche um record THPDFRangeCacheStatistics que separa o que o seu transporte fez do que o cache fez. SourceReadCount e SourceBytesRead são tráfego físico. CacheHitCount e CacheMissCount são tráfego lógico. SequentialReadCount e RandomReadCount mostram como o padrão de acesso foi classificado, CurrentReadAheadBlocks e PeakReadAheadBlocks mostram até onde a janela se abriu, e PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount e SuppressedPrefetchCount mostram se a especulação valeu a pena

var
  S: THPDFRangeCacheStatistics;
begin
  Cached.GetStatistics(S);
  Log(Format('physical %d reads / %d bytes, hits %d, misses %d',
    [S.SourceReadCount, S.SourceBytesRead, S.CacheHitCount, S.CacheMissCount]));
  Log(Format('pattern: %d sequential, %d random, peak window %d blocks',
    [S.SequentialReadCount, S.RandomReadCount, S.PeakReadAheadBlocks]));
  Log(Format('prefetch: %d issued, %d completed, %d cancelled, %d suppressed',
    [S.PrefetchRequestCount, S.PrefetchCompletedCount,
     S.PrefetchCancelledCount, S.SuppressedPrefetchCount]));
end;

Três leituras dizem o que mudar. Muitos prefetches cancelados com uma contagem alta de leituras aleatórias significa que o documento está sendo acessado fora de ordem, então reduza MaxReadAheadBlocks e pare de pagar por banda que você descarta. Muitos misses com uma janela de pico ainda em 1 significa que a tolerância está rejeitando um padrão que é efetivamente sequencial, então aumente SequentialReadToleranceBytes. E bytes lidos muito além do tamanho do arquivo significa que o cache está em thrashing, então aumente MaxCacheBytes antes de mexer em qualquer outra coisa

Arquivos linearizados mudam a aritmética

Se você controla o produtor, linearizar o documento muda o problema em vez de apenas otimizá-lo. Um PDF linearizado coloca os objetos da primeira página e uma hint table no início do arquivo, de modo que um visualizador consegue renderizar a página um a partir do primeiro megabyte, sem precisar ver o resto. O HotPDF expõe esse caminho diretamente por meio de GetProgressiveLinearizedLoadInfo e ReadProgressiveLinearizedFirstPageSection, e o lado da escrita é abordado em geração de PDFs linearizados com hint tables

As duas técnicas se combinam bem. A coalescência torna qualquer documento tolerável em um link lento; a linearização faz a primeira página chegar rápido em documentos que você mesmo produz. Para arquivos que residem em disco local mas são grandes demais para caber na memória, os caminhos de arquivo mapeado e stream preguiçoso descritos em o workflow da API de arquivo direto costumam ser a ferramenta melhor, já que não há, para começar, latência de round trip a amortizar

O HotPDF é um componente VCL nativo de PDF para Delphi e C++Builder, sem nenhuma DLL externa para o parser e com código-fonte completo disponível. A API de fonte de acesso aleatório, o wrapper de coalescência e os pontos de entrada de carregamento progressivo estão documentados na página do componente HotPDF Delphi para PDF