Articolo tecnico

Streaming PDF remoti in Delphi con HotPDF Range Coalescing

HotPDF carica un PDF da qualsiasi sorgente ad accesso casuale che tu implementi, e THPDFCoalescingRandomAccessSource avvolge quella sorgente in modo che le letture piccole e sparse del parser diventino un insieme limitato di range di blocchi in cache con prefetch asincrono. Su un documento servito tramite richieste HTTP range, questa è la differenza tra qualche centinaio di round trip e qualche dozzina

Nel parser non cambia nulla. Chiami comunque LoadFromRandomAccessSource, torna indietro lo stesso oggetto documento, e la stessa API di pagina funziona. Ciò che cambia è il traffico sottostante

Perché lo stesso PDF si carica istantaneamente in locale e striscia sulla rete?

Perché un parser PDF non legge un file, lo naviga. Si posiziona alla fine per startxref, salta indietro alla cross-reference table, risolve il trailer dictionary, segue un riferimento al Catalog, poi alla radice dell'albero delle pagine, poi a un nodo pagina, poi al suo resource dictionary. Ognuno di questi passi legge decine di byte da un offset diverso

Su un file locale quel pattern è quasi gratuito: il sistema operativo ha già in cache la pagina di 4 KiB circostante, quindi la seconda lettura costa una memcpy. Su un trasporto di rete non esiste questa località. Ogni lettura è una richiesta con la propria latenza, e 300 richieste sequenziali da 40 ms ciascuna sono dodici secondi spesi quasi interamente in attesa. La correzione non è leggere meno; il parser ha bisogno esattamente di ciò che chiede. La correzione è far sì che ogni lettura fisica copra più di ciò che la prossima lettura logica vorrà

Cosa cambia il coalescing

La sorgente con coalescing arrotonda ogni lettura per eccesso a un blocco e mette in cache il blocco. BlockSize ha valore predefinito 262.144 byte e MaxCacheBytes 2.097.152, quindi per default sono residenti otto blocchi, evitati in ordine least-recently-used contro un budget rigido in byte. La lettura di 40 byte di una chiave del trailer da parte del parser porta con sé i 256 KiB circostanti, e la dozzina di letture successive in quel vicinato, dove risiedono i dati di cross-reference e catalog, viene servita dalla memoria

La tua sorgente resta semplice. Implementa GetSize e ReadAt, esegui l'override di ReadAtCancellable se il tuo trasporto può annullare in corso, e lascia che il wrapper gestisca caching, coalescing e 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: il wrapper libera Raw insieme a se stesso
  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;

Quanto in avanti dovrebbe leggere?

Il read-ahead adattivo risponde a questa domanda per ogni documento invece di costringerti a indovinare. Con AdaptiveReadAheadEnabled impostato, la finestra cresce attraverso 1, 2, 4 e 8 blocchi man mano che si accumulano letture in avanti sostenute, e non supera mai MaxReadAheadBlocks né la capacità di cache configurata. Nel momento in cui arriva una lettura che non è approssimativamente dove è terminata la precedente, la finestra collassa e il prefetch viene soppresso

SequentialReadToleranceBytes, predefinito 4.096, definisce «approssimativamente». Le letture che cadono entro quella distanza dalla fine della lettura precedente contano comunque come sequenziali, il che conta perché un parser PDF che percorre un content stream non produce offset perfettamente contigui; salta un campo di lunghezza qui, un dizionario inline là. Se imposti la tolleranza troppo bassa, una normale scansione in avanti viene classificata come casuale, quindi il read-ahead non si attiva mai. Se la imposti troppo alta, un accesso casuale genuino sembra sequenziale, quindi recuperi megabyte che nessuno vuole. Il valore predefinito è calibrato per l'attraversamento dei content stream, e le statistiche ti diranno se il tuo trasporto non è d'accordo

Questa asimmetria è deliberata: la crescita è graduale, il collasso è immediato. Recuperare troppo su un carico di lavoro ad accesso casuale costa banda reale e denaro reale sui trasporti a consumo, quindi l'errore economico è preferito a quello costoso

Una cancellazione che ferma davvero il trasferimento

La classe base dichiara ReadAtCancellable, e la sorgente con coalescing la rispetta end to end. Quando arriva una lettura in primo piano per un range che un prefetch in corso non sta servendo, il prefetch viene annullato invece di essere lasciato terminare, così la richiesta di pagina dell'utente non resta in coda dietro traffico speculativo. L'implementazione predefinita su THPDFRandomAccessSource ricade su un semplice ReadAt, il che significa che la funzionalità è opt-in per trasporto: i client HTTP che supportano l'annullamento della richiesta ottengono una cancellazione genuina, mentre le sorgenti più semplici continuano a funzionare invariate

Combina questo con un cancellation token propagato attraverso la tua UI e un utente che chiude un documento ferma davvero il traffico di rete invece di aspettare che si esaurisca. Lo stesso modello di token sostiene la coda descritta in rendering in background con una coda di richieste, così un solo token può coprire l'intero percorso dal viewport al socket

Leggere le statistiche della cache dei range

GetStatistics compila un record THPDFRangeCacheStatistics che separa cosa ha fatto il tuo trasporto da cosa ha fatto la cache. SourceReadCount e SourceBytesRead sono traffico fisico. CacheHitCount e CacheMissCount sono traffico logico. SequentialReadCount e RandomReadCount mostrano come è stato classificato il pattern di accesso, CurrentReadAheadBlocks e PeakReadAheadBlocks mostrano quanto si è aperta la finestra, e PrefetchRequestCount, PrefetchCompletedCount, PrefetchCancelledCount e SuppressedPrefetchCount mostrano se la speculazione ha ripagato

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;

Tre letture ti dicono cosa cambiare. Molti prefetch annullati con un alto numero di letture casuali significa che il documento viene acceduto fuori ordine, quindi abbassa MaxReadAheadBlocks e smetti di pagare per banda che scarti. Molti mancati con una finestra di picco ancora a 1 significa che la tolleranza sta rifiutando un pattern che è di fatto sequenziale, quindi alza SequentialReadToleranceBytes. E byte letti molto superiori alla dimensione del file significano che la cache sta thrashando, quindi alza MaxCacheBytes prima di toccare qualunque altra cosa

I file linearizzati cambiano l'aritmetica

Se controlli il producer, linearizzare il documento cambia il problema invece di ottimizzarlo. Un PDF linearizzato colloca gli oggetti della prima pagina e una hint table all'inizio del file, così un viewer può renderizzare la pagina uno dal primo megabyte senza vedere il resto. HotPDF espone direttamente quel percorso tramite GetProgressiveLinearizedLoadInfo e ReadProgressiveLinearizedFirstPageSection, e il lato scrittura è trattato in generare PDF linearizzati con hint table

Le due tecniche si combinano. Il coalescing rende tollerabile qualsiasi documento su un collegamento lento; la linearizzazione fa arrivare rapidamente la prima pagina sui documenti che produci tu stesso. Per i file che vivono su disco locale ma sono troppo grandi per stare in memoria, i percorsi a file mappato e stream lazy descritti in il workflow della direct file API sono di solito lo strumento migliore, dato che non c'è alcuna latenza di round trip da ammortizzare in primo luogo

HotPDF è un componente PDF VCL nativo per Delphi e C++Builder, senza DLL esterne per il parser e con codice sorgente completo disponibile. L'API della sorgente ad accesso casuale, il wrapper con coalescing e i punti di ingresso per il caricamento progressivo sono documentati sulla pagina del componente PDF Delphi HotPDF