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
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
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
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