Um arquivo digitalizado de 2 GB vive num bucket S3 e o utilizador quer a página 900. O PDFlibPas consegue servir essa página sem descarregar o ficheiro: LoadFromRangeSource constrói um stream só de leitura e com seek sobre a sua própria callback de intervalos de bytes e entrega-o a TPDFDocument, pelo que o parser puxa as tabelas de referências cruzadas, um ramo da árvore de páginas, e um stream de conteúdo
O lado do transporte disto é antigo e aborrecido. Os servidores HTTP anunciam intervalos de bytes há décadas, agora especificados na RFC 9110 §14, e todo o object store fala o mesmo dialecto. O lado PDF está igualmente assente: a ISO 32000-1 §7.5.8 define a linearização precisamente para um leitor conseguir renderizar a primeira página a partir do início do ficheiro. O que faltava em Delphi era a peça do meio, a parte que decide que intervalos pedir, quantos guardar, 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 uma callback de leitura síncrona do tipo TPDFlibRangeReadEvent, declarada como function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internamente, o par torna-se um TCallbackByteRangeSource que expõe SourceSize e ReadRange, embrulhado num stream cuja propriedade passa para o documento. O alvo da sua callback e o seu backend continuam a ser seus: o documento liberta o wrapper ao fechar, limpar ou recarregar, mas nunca toca no objeto de transporte por trás do method pointer
O contrato é deliberadamente indulgente num sentido e estrito no outro. Uma leitura curta é legal e significa simplesmente que o parser pede de novo. Uma callback que lança exceção é convertida numa leitura curta e converge pelo caminho normal de falha de carregamento. Uma callback que afirme ter escrito mais de Count bytes é limitada, porque um fornecedor com erros não pode poder trasbordar o buffer da cache. As repetições de palavra-passe reconstruem um stream de intervalos novo e um estado de análise novo sobre a mesma fonte de callback, pelo que uma tentativa falhada não pode deixar para trás posição, janela ou estado de desencriptação obsoletos
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; { liberta o stream wrapper }
Src.Free; { o seu transporte, o seu ciclo de vida }
end;
Quanto guarda realmente a cache de intervalos?
Por predefinição 4 MiB, espalhados por janelas alinhadas a chunks e despejadas por LRU. O desenho anterior de janela única crescia até ao comprimento que o chamador pedisse, pelo que uma grande leitura sequencial podia rebentar o tamanho nominal do chunk enquanto um salto aleatório deitava fora a janela anterior de imediato. A cache atual alinha cada offset da fonte a ChunkSize, busca exatamente um chunk por falha, e impõe um orçamento rígido de bytes por várias janelas. Qualquer orçamento explícito que passe é elevado a pelo menos um chunk completo, pelo que uma única leitura avança sempre chunk a chunk e a carga de pico da cache permanece previsível. Um ChunkSize abaixo de 4096 recai no padrão de 64 KiB
A contabilização de leituras repetidas é a parte que vale a pena 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 primeira busca genuína de uma nova busca após despejo, mantendo a contabilidade longe de crescer linearmente com o tamanho do ficheiro. GetRangeSourceCacheInfo devolve o quadro completo como JSON, SetRangeSourceCacheLimit redimensiona o orçamento em execução, e ClearRangeSourceCache deita fora as janelas e repõe as estatísticas em conjunto. Encolher o orçamento em execução mantém o histórico e conta as liberações motivadas pelo orçamento como despejos, pelo que um repeatedReads a subir contra um hits plano é o seu sinal de que o conjunto de trabalho já não cabe
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?
Esperam por um único pedido, não por vários. Um TStream clássico tem um único cursor de posição, e duas threads que bloqueiem corretamente cada uma podem ainda ver essa posição reescrita entre um Seek e um Read, pelo que os objetos preguiçosos e as leituras segmentadas no PDFlibPas usam um ReadAt absoluto que nunca move o cursor. Cada chunk alinhado recebe um único pedido em curso que todos os chamadores desse chunk partilham, os chunks adjacentes em fila são fundidos antes de a leitura da fonte começar, e uma leitura física é limitada a 16 MiB, pelo que uma rajada de trabalho paralelo de páginas não se amplifica nem em pequenos pedidos duplicados nem num absurdo gigante. A janela de fusão é de 2 ms por predefinição e aplica-se apenas ao primeiro chunk em falta de cada ReadAt; o Read posicional nunca a espera, e passar zero remove por completo o atraso inicial de recolha, o que importa para varrimentos sequenciais longos que de outro modo acumulariam a espera chunk a chunk. Posição, metadados da cache, e leituras da fonte ficam atrás de três bloqueios separados, e a própria callback da fonte é serializada, o que permite usar sem alterações um adaptador de base de dados ou de object store sem proteção interna de threads. Os esperadores recebem a sua própria cópia dos dados, pelo que um despejo LRU posterior não pode invalidar um buffer que já foi entregue
Pode perguntar-se se a página 900 está pronta sem a buscar?
Sim, e é exatamente para isso que serve a callback opcional de disponibilidade. Uma callback de leitura comum não distingue bytes que já chegaram de bytes que exigem uma viagem de ida e volta bloqueante, e sondar com uma leitura de ensaio despoletaria precisamente a descarga que se está a tentar evitar. A TPDFlibRangeAvailabilityEvent responde a uma única pergunta, se um intervalo completo pode ser lido de imediato, e está proibida de buscar qualquer coisa; os bytes que a cache já cobre contam sempre como disponíveis. A GetRangeSourceDataAvailability mapeia objetos indiretos para os intervalos de armazenamento físico registados nas entradas de referências cruzadas, resolve objetos comprimidos para o seu contentor de object stream, corrige um cabeçalho PDF deslocado, e analisa um objeto apenas depois de o intervalo completo passar na sonda sem buscas, pelo que o caminho em falta nunca chama a sua callback de leitura
A travessia é delimitada em vez de exaustiva. Uma consulta de página percorre apenas o ramo da árvore de páginas que contém a página alvo e depois acrescenta conteúdo da página, recursos, anotações, e atributos de página herdados, saltando as arestas de retorno Parent e P para que uma única página ou widget não se possa expandir para trás até ao documento inteiro. O grafo de objetos é limitado a 100000 objetos pedidos e a uma profundidade de 256, os objetos stream são analisados primeiro pelo dicionário, e um fallback de análise completa só é permitido para objetos armazenados até 4 MiB. O relatório JSON funde intervalos sobrepostos e adjacentes antes de contar, pelo que requiredBytes e missingBytes são calculados a partir dos arrays fundidos requiredRanges e missingRanges, cujo end é um ponto final inclusivo. Consultar um objeto já disponível pode povoar a cache de intervalos; consultar um em falta 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 traz "missingBytes" mais os "missingRanges" fundidos }
ShowProgress(Report)
else if Status = PDF_RANGE_DATA_NOT_PRESENT then
ShowMissingFeature; { ex.: o ficheiro não tem AcroForm nenhum }
end;
Porque é que o prefetch tem de iterar
Porque ler os missingRanges atuais uma vez não torna a página disponível. Um nó em falta da árvore de páginas ou um object stream só revela a camada seguinte de dependências depois de chegar, pelo que um trabalho de prefetch do PDFlibPas corre um ciclo de consulta, busca, nova consulta até a página, o formulário, ou o grafo de objetos estar completamente disponível ou um limite de bytes ou de passagens o parar. O trabalho usa o seu próprio leitor e uma pequena cache secundária cuja fonte de dados encaminha leituras absolutas para o stream de intervalos original, o que mantém o estado de análise isolado do TSmartPDFReader de primeiro plano enquanto os bytes que genuinamente descarrega ainda caem na cache principal partilhada. Existe uma thread de trabalho por stream de intervalos, correspondendo à serialização que a callback da fonte já exige, e a fila escolhe por quatro níveis de prioridade e depois por ordem de submissão dentro de um nível. MaxBytes é cobrado em bytes físicos de chunk, pelo que um parser que peça um único byte dentro de um chunk não em cache ainda paga o chunk inteiro, enquanto os chunks já na cache partilhada não custam nada ao trabalho. Cancelar um trabalho em fila atinge um estado terminal com zero leituras da fonte; um trabalho em execução é verificado antes de cada passagem de dependências e de cada chunk da fonte, e libertar o stream de intervalos espera que uma callback em curso regresse em vez de tentar interrompê-la
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 de disponibilidade completo, para que LIMIT_REACHED continue
distinguível de FAILED }
Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;
Onde isto degrada numa descarga do ficheiro inteiro
O carregamento por intervalos é uma aposta na disposição do ficheiro, e alguns ficheiros não a honram. Um ficheiro linearizado conforme a ISO 32000-1 §7.5.8 é o caso bom: a secção da primeira página é aquecida à abertura, limitada tanto pelo limiar de segurança existente de 4 MiB como pelo orçamento atual da cache, para que o aquecimento não possa despejar de imediato a maior parte de si próprio. Um ficheiro não linearizado ainda resolve através do trailer e da cadeia de referências cruzadas perto do fim, o que custa algumas viagens de ida e volta extra em vez de um desastre. O verdadeiro penhasco é um ficheiro danificado que força o caminho de reparação, porque reconstruir uma tabela de referências cruzadas significa varrer cabeçalhos de objeto por todo o documento, e isso é uma descarga completa a chegar um chunk de cada vez. A latência é o outro limite honesto: a 60 ms por pedido, uma análise de acesso aleatório que precise de quarenta chunks não em cache passa mais de dois segundos em trânsito não importa quão boa seja a cache, e é precisamente isso que o argumento de read-ahead e a fila de prioridade existem para esconder. A mesma disciplina aparece na abordagem de acesso direto para juntar e dividir PDF grandes, e esta cache fica por baixo da renderização paralela de páginas e da cache de páginas em disco do visualizador
A API de fonte de intervalos, 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 traz a referência completa de parâmetros de LoadFromRangeSource junto com as constantes de prioridade e de estado do prefetch