Articolo tecnico

Caricamento progressivo a range in Delphi con PDFlibPas

Un archivio scansionato da 2 GB sta in un bucket S3 e l'utente vuole la pagina 900. PDFlibPas può servire quella pagina senza scaricare il file: LoadFromRangeSource costruisce uno stream seekable di sola lettura sopra la tua callback di byte range e lo passa a TPDFDocument, così il parser tira giù le tabelle cross-reference, un ramo dell'albero delle pagine e un content stream

Il lato trasporto di tutto questo è vecchio e noioso. I server HTTP pubblicizzano i byte range da decenni, oggi specificati in RFC 9110 §14, e ogni object store parla lo stesso dialetto. Il lato PDF è altrettanto risolto: ISO 32000-1 §7.5.8 definisce la linearizzazione proprio perché un reader possa renderizzare la prima pagina dall'inizio del file. Ciò che è mancato in Delphi è il pezzo in mezzo, la parte che decide quali range chiedere, quanti tenerne e come evitare di chiedere due volte

Cosa richiede LoadFromRangeSource al tuo trasporto?

Due cose, e nessuna delle due è uno stream. PDFlibPas chiede una SourceSize autorevole e una callback di lettura sincrona di tipo TPDFlibRangeReadEvent, dichiarata come function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internamente la coppia diventa un TCallbackByteRangeSource che espone SourceSize e ReadRange, avvolto in uno stream la cui proprietà passa al documento. Il target della tua callback e il suo backend restano tuoi: il documento libera il wrapper su close, clear o reload, ma non tocca mai l'oggetto di trasporto dietro il method pointer

Il contratto è deliberatamente permissivo in una direzione e severo nell'altra. Una lettura corta è legale e significa semplicemente che il parser richiede di nuovo. Una callback che solleva un'eccezione viene convertita in una lettura corta e converge attraverso il normale percorso di fallimento del caricamento. Una callback che dichiara di aver scritto più di Count byte viene clampata, perché un provider difettoso non deve poter sforare il buffer della cache. I tentativi di password ricostruiscono uno stream di range fresco e uno stato di parsing fresco sulla stessa sorgente di callback, così un tentativo fallito non può lasciare dietro posizione, finestre o stato di decifratura stantii

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
  { un GET bloccante con 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 lo stream wrapper }
  Src.Free;  { il tuo trasporto, il tuo ciclo di vita }
end;

Quanto tiene davvero la cache dei range?

Per default 4 MiB, distribuiti su finestre allineate al chunk ed evitate in LRU. Il vecchio design a finestra singola cresceva alla lunghezza che il chiamante chiedeva, così una grande lettura sequenziale poteva superare la dimensione nominale del chunk mentre un salto casuale buttava via subito la finestra precedente. La cache attuale allinea ogni offset della sorgente a ChunkSize, scarica esattamente un chunk per ogni miss e impone un budget di byte rigido su più finestre. Qualsiasi budget esplicito che passi viene innalzato ad almeno un chunk intero, così una singola lettura avanza sempre chunk per chunk e il picco di carico della cache resta prevedibile. Una ChunkSize sotto 4096 ricade sul default di 64 KiB

Come PDFlibPas serve una lettura del parser in Delphi senza scaricare il PDF: l'offset assoluto viene allineato per difetto alla dimensione del chunk, servito da una di diverse finestre LRU in caso di hit, o trasformato in una singola chiamata di callback limitata in caso di miss
Ogni offset della sorgente è allineato alla dimensione del chunk, così un miss scarica esattamente un chunk e il picco di carico della cache resta prevedibile

La contabilità delle letture ripetute è la parte che vale la pena cablare nella tua telemetria. PDFlibPas identifica una ripetizione dall'inizio allineato del chunk e mantiene intervalli contigui ordinati, cosa che separa un primo scarico genuino da un riscarico dopo un eviction tenendo la contabilità lontana da una crescita lineare con la dimensione del file. GetRangeSourceCacheInfo restituisce l'intero quadro come JSON, SetRangeSourceCacheLimit ridimensiona il budget a runtime, e ClearRangeSourceCache butta le finestre e azzera le statistiche insieme. Restringere il budget a runtime conserva la storia e conta i rilasci dettati dal budget come eviction, così un repeatedReads in salita contro un hits piatto è il tuo segnale che il working set non ci sta più

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;

Cosa succede quando più thread vogliono lo stesso chunk?

Aspettano su una richiesta, non su tante. Un classico TStream ha un unico cursore di posizione, e due thread che bloccano correttamente possono comunque vedere quella posizione riscritta tra un Seek e un Read, così i lazy object e le letture segmentate in PDFlibPas usano un ReadAt assoluto che non muove mai il cursore. Ogni chunk allineato ha una singola richiesta in volo che tutti i chiamanti per quel chunk condividono, i chunk in coda adiacenti vengono fusi prima che la lettura della sorgente parta, e una lettura fisica è limitata a 16 MiB, così un picco di lavoro parallelo sulle pagine non si amplifica né in piccole richieste duplicate né in una sola assurdamente grande. La finestra di fusione è di default 2 ms e si applica solo al primo chunk mancante di ogni ReadAt; la Read posizionale non aspetta mai quella finestra, e passare zero elimina del tutto il ritardo di raccolta iniziale, cosa che conta per le lunghe scansioni sequenziali che altrimenti accumulerebbero l'attesa chunk per chunk. Posizione, metadati della cache e letture della sorgente stanno dietro tre lock separati, e la callback della sorgente è essa stessa serializzata, ed è questo che permette a un adattatore per database o object store senza protezione interna dei thread di essere usato tale e quale. I waiter ricevono la loro copia dei dati, così un successivo eviction LRU non può invalidare un buffer già consegnato

Fusione delle richieste nel range loading di PDFlibPas per Delphi: due thread che chiedono lo stesso chunk condividono una richiesta in volo, i chunk in coda adiacenti vengono fusi dentro una finestra di due millisecondi, e una singola lettura serializzata della sorgente serve tutti
Un picco di lavoro parallelo sulle pagine collassa in una richiesta condivisa per chunk, e ogni waiter riceve comunque la propria copia dei byte

Puoi chiedere se la pagina 900 è pronta senza scaricarla?

Sì, ed è esattamente per questo che esiste la callback opzionale di disponibilità. Una callback di lettura normale non può distinguere i byte già arrivati dai byte che richiedono un round trip bloccante, e sondare con una lettura di prova innescerebbe proprio lo scarico che stai cercando di evitare. TPDFlibRangeAvailabilityEvent risponde a una domanda sola, se un range completo si possa leggere immediatamente, e gli è vietato scaricare qualsiasi cosa; i byte che la cache copre già contano sempre come disponibili. GetRangeSourceDataAvailability mappa gli oggetti indiretti sugli intervalli di storage fisico registrati nelle voci cross-reference, risolve gli oggetti compressi nel loro contenitore object stream, corregge un header PDF spostato, e parsa un oggetto solo dopo che l'intero range passa il sondaggio che non scarica, così il percorso mancante non chiama mai la tua callback di lettura

L'attraversamento è mirato, non esaustivo. Una query di pagina percorre solo il ramo dell'albero delle pagine che contiene la pagina obiettivo e poi aggiunge contenuto di pagina, risorse, annotazioni e attributi di pagina ereditati, saltando i back-edge Parent e P così una singola pagina o widget non può espandersi all'indietro verso l'intero documento. Il grafo degli oggetti è limitato a 100000 oggetti richiesti e a una profondità di 256, gli oggetti stream vengono parsati col dizionario per primo, e un fallback a parsing completo è permesso solo per gli oggetti stored fino a 4 MiB. Il rapporto JSON fonde gli intervalli sovrapposti e adiacenti prima di contare, così requiredBytes e missingBytes sono calcolati dagli array fusi requiredRanges e missingRanges, il cui end è un estremo inclusivo. Interrogare un oggetto già disponibile può popolare la cache dei range; interrogarne uno mancante lascia intatte le statistiche di lettura

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 contiene "missingBytes" più i "missingRanges" fusi }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { es. il file non ha proprio un AcroForm }
end;

Perché il prefetch deve iterare

Perché leggere gli attuali missingRanges una volta non rende la pagina disponibile. Un nodo dell'albero delle pagine o un object stream mancante rivela lo strato successivo di dipendenze solo dopo il suo arrivo, così un job di prefetch di PDFlibPas esegue un ciclo di query, fetch e requery finché la pagina, il form o il grafo di oggetti non è completamente disponibile o un limite di byte o di passaggi non lo ferma. Il job usa il suo reader e una piccola cache secondaria la cui sorgente dati inoltra letture assolute allo stream di range originale, cosa che tiene lo stato di parsing isolato dal TSmartPDFReader in primo piano mentre i byte che scarica davvero finiscono comunque nella cache principale condivisa. Esiste un thread worker per stream di range, a riscontro della serializzazione che la callback della sorgente già richiede, e la coda sceglie per quattro livelli di priorità e poi per ordine di invio dentro un livello. MaxBytes è addebitato in byte di chunk fisici, così un parser che chiede un singolo byte dentro un chunk non in cache paga comunque il chunk intero, mentre i chunk già nella cache condivisa non costano nulla al job. Annullare un job in coda raggiunge uno stato terminale con zero letture della sorgente; un job in esecuzione viene controllato prima di ogni passaggio di dipendenze e di ogni chunk della sorgente, e liberare lo stream di range aspetta che una callback in volo ritorni invece di provare a interromperla

Il ciclo di prefetch di PDFlibPas in Delphi: un job interroga la disponibilità, scarica i range mancanti e interroga di nuovo, perché ogni nodo dell'albero delle pagine o object stream in arrivo rivela lo strato successivo di dipendenze, finché il grafo non si completa o un limite lo ferma
Un job di prefetch itera perché un nodo mancante nomina i propri figli solo una volta arrivato, e addebita ogni passaggio in chunk fisici interi
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 l'ultimo
    rapporto completo di disponibilità, così LIMIT_REACHED resta
    distinguibile da FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Dove tutto questo degenera in uno scarico dell'intero file

Il range loading è una scommessa sul layout del file, e alcuni file non la rispettano. Un file linearizzato secondo ISO 32000-1 §7.5.8 è il caso buono: la sezione della prima pagina viene scaldata all'apertura, con un limite dato sia dalla soglia di sicurezza esistente di 4 MiB sia dal budget attuale della cache, così il riscaldamento non può subito evitare la maggior parte di se stesso. Un file non linearizzato si risolve comunque attraverso il trailer e la catena cross-reference vicino alla fine, che costa un paio di round trip extra anziché un disastro. La scarpata vera è un file danneggiato che forza il percorso di riparazione, perché ricostruire una tabella cross-reference significa cercare header di oggetto attraverso l'intero documento, e quello è uno scarico completo che arriva un chunk alla volta. La latenza è l'altro limite onesto: a 60 ms per richiesta, un parsing ad accesso casuale che ha bisogno di quaranta chunk non in cache passa oltre due secondi in transito per quanto buona sia la cache, ed è precisamente quello che l'argomento read-ahead e la coda di priorità esistono per nascondere. La stessa disciplina si vede nell'approccio ad accesso diretto per fusione e divisione di PDF grandi, e questa cache sta sotto il rendering parallelo delle pagine e la cache su disco delle pagine del viewer allo stesso modo

L'API della sorgente di range, la query di disponibilità e lo scheduler di prefetch fanno parte della standard PDFlibPas Delphi PDF Library per Delphi, C++Builder e Free Pascal; la pagina prodotto porta il riferimento completo dei parametri di LoadFromRangeSource insieme alle costanti di priorità e stato del prefetch