Um arquivo digitalizado de 2 GB vive em um bucket S3 e o usuário quer a página 900. O PDFlibPas consegue servir essa página sem baixar o arquivo: LoadFromRangeSource constrói um stream seekable somente leitura sobre o seu próprio callback de byte-range e o entrega a TPDFDocument, de modo que o parser puxa as tabelas de cross-reference, um ramo da árvore de páginas e um content stream
O lado de transporte disso é velho e sem graça. Servidores HTTP anunciam byte ranges há décadas, agora especificados no RFC 9110 §14, e todo object store fala o mesmo dialeto. O lado PDF está igualmente resolvido: o ISO 32000-1 §7.5.8 define linearização precisamente para que um reader consiga renderizar a primeira página a partir do início do arquivo. O que faltava em Delphi é a peça do meio, a parte que decide quais ranges pedir, quantos manter e como evitar pedir duas vezes
O que o LoadFromRangeSource precisa do seu transporte?
Duas coisas, e nenhuma delas é um stream. O PDFlibPas pede um SourceSize autoritativo e um callback de leitura síncrono do tipo TPDFlibRangeReadEvent, declarado como function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internamente o par se torna um TCallbackByteRangeSource que expõe SourceSize e ReadRange, embrulhado em um stream cuja posse passa ao documento. O alvo do seu callback e o backend dele continuam seus: o documento libera o wrapper no close, clear ou reload, mas nunca toca no objeto de transporte por trás do method pointer
O contrato é deliberadamente tolerante em uma direção e rígido na outra. Uma leitura curta é legal e significa apenas que o parser pede de novo. Um callback que levanta exceção é convertido em uma leitura curta e converge pelo caminho normal de falha de load. Um callback que alega ter escrito mais de Count bytes é limitado, porque um provider com bug não pode estourar o buffer de cache. Retentativas de senha reconstruem um range stream novo e um estado de parse novo sobre a mesma fonte de callback, de modo que uma tentativa fracassada não pode deixar para trás posição, janela ou estado de descriptografia velhos
type
TObjectStoreSource = class
private
FClient: TRangeHttpClient;
FSize: Int64;
public
function ReadRange(Sender: TObject; Offset: Int64;
Buffer: Pointer; Count: LongInt): LongInt;
function IsResident(Sender: TObject; Offset: Int64;
Count: LongInt): Integer;
property Size: Int64 read FSize;
end;
function TObjectStoreSource.ReadRange(Sender: TObject; Offset: Int64;
Buffer: Pointer; Count: LongInt): LongInt;
begin
{ um GET bloqueante com Range: bytes=Offset-(Offset+Count-1) }
Result := FClient.FetchInto(Offset, Count, Buffer);
end;
{ ... }
Lib := TPDFlib.Create;
Src := TObjectStoreSource.Create(BucketUrl);
try
if Lib.LoadFromRangeSource(Src.Size, Src.ReadRange, '',
65536, 8 * 1024 * 1024, 2, Src.IsResident) = 1 then
Lib.SelectPage(900);
finally
Lib.Free; { libera o stream wrapper }
Src.Free; { seu transporte, sua vida útil }
end;
Quanto o cache de ranges realmente guarda?
Por padrão, 4 MiB, espalhados por janelas alinhadas a chunks e expulsos em LRU. O design anterior de janela única crescia até o comprimento que o chamador pedisse, de modo que uma única grande leitura sequencial podia passar do tamanho nominal do chunk enquanto um salto aleatório descartava a janela anterior imediatamente. O cache atual alinha cada offset de origem a ChunkSize, busca exatamente um chunk por miss e impõe um orçamento rígido de bytes sobre várias janelas. Qualquer orçamento explícito que você passar é elevado a pelo menos um chunk completo, de modo que uma única leitura sempre avança chunk a chunk e o pico de carga do cache permanece previsível. Um ChunkSize abaixo de 4096 recai sobre o padrão de 64 KiB
A contabilidade de leituras repetidas é a parte que vale ligar à sua telemetria. O PDFlibPas identifica uma repetição pelo início de chunk alinhado e mantém intervalos contíguos ordenados, o que separa uma busca genuinamente primeira de uma nova busca após expulsão, impedindo que a contabilidade cresça linearmente com o tamanho do arquivo. GetRangeSourceCacheInfo devolve o quadro inteiro como JSON, SetRangeSourceCacheLimit redimensiona o orçamento em tempo de execução, e ClearRangeSourceCache descarta as janelas e zera as estatísticas juntas. Encolher o orçamento em tempo de execução mantém o histórico e conta as liberações motivadas pelo orçamento como expulsões, de modo que um repeatedReads subindo contra um hits plano é o seu sinal de que o working set não cabe mais
var
Info: WideString;
begin
Lib.SetRangeSourceCacheLimit(16 * 1024 * 1024);
Lib.SelectPage(900);
if Lib.GetRangeSourceCacheInfo(Info) = 1 then
{ "windowCount", "cacheLimitBytes", "cachedBytes", "hits", "misses",
"evictions", "sourceReads", "sourceBytes", "repeatedReads",
"coalescedRequests", "coalescedSourceReads" }
LogRangeStats(Info);
end;
O que acontece quando várias threads querem o mesmo chunk?
Elas esperam em um pedido, não em vários. Um TStream clássico tem um único cursor de posição, e duas threads que bloqueiam corretamente cada uma ainda podem ter essa posição reescrita entre um Seek e um Read, de modo que lazy objects e leituras segmentadas no PDFlibPas usam um ReadAt absoluto que nunca move o cursor. Cada chunk alinhado ganha um único pedido in-flight que todo chamador desse chunk compartilha, chunks enfileirados adjacentes são mesclados antes de a leitura da origem começar, e uma leitura física é limitada a 16 MiB, de modo que uma rajada de trabalho paralelo de páginas não se amplifica nem em pedidos pequenos duplicados nem em um único absurdo de grande. A janela de mesclagem tem padrão de 2 ms e se aplica apenas ao primeiro chunk ausente de cada ReadAt; o Read posicional nunca espera por ela, e passar zero remove o atraso inicial de coleta por completo, o que importa para varreduras sequenciais longas que, de outra forma, acumulariam a espera chunk a chunk. Posição, metadados de cache e leituras de origem ficam atrás de três travas separadas, e o próprio callback de origem é serializado, o que permite que um adaptador de banco de dados ou object store sem proteção interna de threads seja usado sem mudanças. Quem espera recebe sua própria cópia dos dados, de modo que uma expulsão LRU posterior não pode invalidar um buffer que já foi entregue
Dá para perguntar se a página 900 está pronta sem buscá-la?
Sim, e é exatamente para isso que serve o callback opcional de disponibilidade. Um callback de leitura comum não distingue bytes que já chegaram de bytes que exigem uma ida e volta bloqueante, e sondar com uma leitura de teste dispararia justamente o download que você está tentando evitar. O TPDFlibRangeAvailabilityEvent responde a uma única pergunta, se um range completo pode ser lido imediatamente, e é proibido de buscar qualquer coisa; bytes que o cache já cobre contam sempre como disponíveis. O GetRangeSourceDataAvailability mapeia objetos indiretos para as faixas de armazenamento físico registradas nas entradas de cross-reference, resolve objetos comprimidos para seu contêiner de object stream, corrige um cabeçalho PDF deslocado e analisa um objeto só depois de o range completo passar pela sonda que não busca, de modo que o caminho ausente nunca chama seu callback de leitura
A travessia tem escopo, não é exaustiva. Uma consulta de página percorre apenas o ramo da árvore de páginas que contém a página alvo e depois soma conteúdo de página, recursos, anotações e atributos de página herdados, pulando as back-edges Parent e P para que uma única página ou widget não possa se expandir para trás até o documento inteiro. O grafo de objetos é limitado a 100000 objetos pedidos e uma profundidade de 256, objetos stream são analisados com o dicionário primeiro, e um fallback de parse completo é permitido apenas para objetos stored de até 4 MiB. O relatório JSON mescla intervalos sobrepostos e adjacentes antes de contar, de modo que requiredBytes e missingBytes são calculados a partir dos arrays mesclados requiredRanges e missingRanges, cujo end é um ponto final inclusivo. Consultar um objeto já disponível pode popular o cache de ranges; consultar um ausente deixa as estatísticas de leitura intactas
var
Report: WideString;
Status: Integer;
begin
Status := Lib.GetRangeSourceDataAvailability(PDF_RANGE_DATA_PAGE, 900,
Report);
if Status = PDF_RANGE_DATA_AVAILABLE then
RenderPageNow
else if Status = PDF_RANGE_DATA_NOT_AVAILABLE then
{ Report carrega "missingBytes" mais os "missingRanges" mesclados }
ShowProgress(Report)
else if Status = PDF_RANGE_DATA_NOT_PRESENT then
ShowMissingFeature; { ex.: o arquivo não tem AcroForm algum }
end;
Por que o prefetch precisa iterar
Porque ler os missingRanges atuais uma vez não torna a página disponível. Um nó ausente da árvore de páginas ou um object stream só revela a próxima camada de dependências depois que chega, de modo que um job de prefetch do PDFlibPas roda um ciclo de consulta, busca e nova consulta até que a página, o formulário ou o grafo de objetos esteja completamente disponível ou um limite de bytes ou de passadas o pare. O job usa seu próprio reader e um pequeno cache secundário cuja fonte de dados encaminha leituras absolutas ao range stream original, o que mantém o estado de parse isolado do TSmartPDFReader de primeiro plano enquanto os bytes que ele genuinamente baixa ainda caem no cache principal compartilhado. Existe uma worker thread por range stream, correspondendo à serialização que o callback de origem já exige, e a fila escolhe por quatro níveis de prioridade e, dentro de um nível, pela ordem de submissão. MaxBytes é cobrado em bytes físicos de chunk, de modo que um parser que pede um único byte dentro de um chunk não cacheado ainda paga pelo chunk inteiro, enquanto chunks já no cache compartilhado não custam nada ao job. Cancelar um job enfileirado chega a um estado terminal com zero leituras de origem; um job em execução é verificado antes de cada passada de dependências e de cada chunk da origem, e liberar o range stream espera um callback in-flight retornar em vez de tentar interrompê-lo
var
Job: Integer;
Info: WideString;
begin
Job := Lib.StartRangeSourcePrefetch(PDF_RANGE_DATA_PAGE, 901,
PDF_RANGE_PREFETCH_PRIORITY_HIGH, 8 * 1024 * 1024, 65536);
if Lib.WaitForRangeSourcePrefetch(Job, 5000) =
PDF_RANGE_PREFETCH_STATE_COMPLETED then
PrepareNextPage
else
Lib.CancelRangeSourcePrefetch(Job);
{ "passes", "plannedRanges", "sourceReads", "fetchedBytes" e o último
relatório completo de disponibilidade, de modo que LIMIT_REACHED
continue distinguível de FAILED }
Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;
Onde isso degrada em um download do arquivo inteiro
Carregamento por ranges é uma aposta no layout do arquivo, e alguns arquivos não a honram. Um arquivo linearizado conforme o ISO 32000-1 §7.5.8 é o bom caso: a seção da primeira página é aquecida na abertura, limitada tanto pelo limiar de segurança existente de 4 MiB quanto pelo orçamento atual do cache, para que o aquecimento não possa expulsar imediatamente a maior parte de si mesmo. Um arquivo não linearizado ainda se resolve pelo trailer e pela cadeia de cross-reference perto do fim, o que custa algumas idas e voltas extras em vez de um desastre. O penhasco real é um arquivo danificado que força o caminho de reparo, porque reconstruir uma tabela de cross-reference significa varrer cabeçalhos de objeto pelo documento inteiro, e isso é um download completo chegando um chunk por vez. A latência é o outro limite honesto: a 60 ms por pedido, um parse de acesso aleatório que precise de quarenta chunks não cacheados passa mais de dois segundos em trânsito, não importa quão bom seja o cache, que é precisamente o que o argumento de read-ahead e a fila de prioridade existem para esconder. A mesma disciplina aparece na abordagem de acesso direto para mesclar e dividir PDFs grandes, e este cache fica por baixo da renderização paralela de páginas e do cache de páginas em disco do viewer igualmente
A API de range source, a consulta de disponibilidade e o agendador de prefetch fazem parte da PDFlibPas Delphi PDF Library padrão para Delphi, C++Builder e Free Pascal; a página de produto carrega a referência completa de parâmetros de LoadFromRangeSource junto com as constantes de prioridade e estado do prefetch