Zeskanowane archiwum o rozmiarze 2 GB leży w kubełku S3, a użytkownik chce stronę 900. PDFlibPas może dostarczyć tę stronę bez pobierania pliku: LoadFromRangeSource buduje strumień tylko do odczytu z możliwością pozycjonowania ponad własnym wywołaniem zwrotnym zakresów bajtów i przekazuje go do TPDFDocument, więc parser pobiera tabele odwołań krzyżowych, jedną gałąź drzewa stron i jeden strumień treści
Ta strona transportowa jest stara i nudna. Serwery HTTP reklamują zakresy bajtów od dziesięcioleci, obecnie opisane w RFC 9110 §14, a każdy magazyn obiektów mówi tym samym dialektem. Strona PDF jest równie dopracowana: ISO 32000-1 §7.5.8 definiuje linearyzację dokładnie po to, by czytnik mógł wyrenderować pierwszą stronę z początku pliku. Czego brakowało w Delphi, to element pośrodku, część decydująca, o które zakresy pytać, ile z nich trzymać i jak uniknąć pytania podwójnie
Czego LoadFromRangeSource wymaga od twojego transportu?
Dwie rzeczy i żadna z nich nie jest strumieniem. PDFlibPas żąda wiarygodnego SourceSize oraz synchronicznego wywołania zwrotnego odczytu typu TPDFlibRangeReadEvent, zadeklarowanego jako function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Wewnątrz ta para staje się TCallbackByteRangeSource wystawiającym SourceSize i ReadRange, opakowanym w strumień, którego własność przechodzi na dokument. Cel wywołań zwrotnych i jego backend pozostają twój: dokument zwalnia opakowanie przy close, clear albo reload, ale nigdy nie dotyka obiektu transportu za wskaźnikiem metody
Kontrakt jest celowo wyrozumiały w jednym kierunku i surowy w drugim. Skrócony odczyt jest legalny i oznacza po prostu, że parser zapyta ponownie. Wywołanie zwrotne, które podnosi wyjątek, jest zamieniane na skrócony odczyt i domyka się normalną ścieżką niepowodzenia wczytywania. Wywołanie zwrotne twierdzące, że zapisało więcej niż Count bajtów, jest przycinane, bo wadliwy provider nie może przepisać bufora cache. Ponowienia hasła budują świeży strumień zakresów i świeży stan parsowania ponad tym samym źródłem wywołań, więc nieudana próba nie może zostawić po sobie nieaktualnej pozycji, okna ani stanu deszyfrowania
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
{ jeden blokujący GET z 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; { zwalnia strumień-opakowanie }
Src.Free; { twój transport, twój czas życia }
end;
Ile faktycznie mieści cache zakresów?
Domyślnie 4 MiB, rozłożone na oknach wyrównanych do chunków i wysiedlanych algorytmem LRU. Wcześniejszy projekt z jednym oknem rósł do dowolnej długości, o którą poprosił wywołujący, więc jeden duży odczyt sekwencyjny mógł przeskoczyć nominalny rozmiar chunka, podczas gdy losowy skok natychmiast wyrzucał poprzednie okno. Obecny cache wyrównuje każdy offset źródłowy do ChunkSize, pobiera dokładnie jeden chunk na chybienie i pilnuje sztywnego budżetu bajtów na kilku oknach naraz. Każdy przekazany wprost budżet jest podnoszony do co najmniej jednego pełnego chunka, więc pojedynczy odczyt zawsze idzie chunk po chunku, a szczytowe obciążenie cache pozostaje przewidywalne. ChunkSize poniżej 4096 wraca do domyślnych 64 KiB
Ewidencja powtórnych odczytów to część warta podłączenia do twojej telemetrii. PDFlibPas rozpoznaje powtórę po wyrównanym początku chunka i przechowuje uporządkowane, spójne przedziały, co oddziela prawdziwe pierwsze pobranie od ponownego pobrania po wysiedleniu, a jednocześnie nie pozwala, by księgowość rosła liniowo z rozmiarem pliku. GetRangeSourceCacheInfo zwraca cały obraz jako JSON, SetRangeSourceCacheLimit zmienia budżet w trakcie działania, a ClearRangeSourceCache zrzuca okna i zeruje statystyki razem. Zmniejszenie budżetu w trakcie działania zachowuje historię i liczy zwolnienia wymuszone budżetem jako wysiedlenia, więc rosnące repeatedReads przy płaskim hits to sygnał, że zbiór roboczy już się nie mieści
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;
Co się dzieje, gdy kilka wątków chce ten sam chunk?
Czekają na jedno żądanie, nie na kilka. Klasyczny TStream ma jeden kursor pozycji, i dwa wątki, z których każdy poprawnie się blokuje, mogą mimo to mieć tę pozycję nadpisaną między Seek a Read, więc leniwe obiekty i odczyty segmentowe w PDFlibPas używają bezwzględnego ReadAt, który nigdy nie rusza kursora. Każdy wyrównany chunk dostaje jedno żądanie w locie, o które dzielą się wszyscy wywołujący tego chunka, sąsiednie zakolejkowane chunki są scalane przed startem odczytu źródła, a jeden fizyczny odczyt ma sufit 16 MiB, więc przypływ równoległej pracy nad stronami nie eskaluje ani w zduplikowane drobne żądania, ani w jedno absurdalnie wielkie. Okno scalania ma domyślnie 2 ms i dotyczy wyłącznie pierwszego brakującego chunka każdego ReadAt; pozycyjny Read nigdy na nie nie czeka, a podanie zera usuwa początkowe opóźnienie zbierania w całości, co ma znaczenie przy długich skanach sekwencyjnych, które inaczej kumulowałyby czekanie chunk po chunku. Pozycja, metadane cache i odczyty źródła stoją za trzema osobnymi blokadami, a samo wywołanie zwrotne źródła jest serializowane, dzięki czemu adapter bazy danych albo magazynu obiektów bez wewnętrznego zabezpieczenia wątków można użyć bez zmian. Czekający otrzymują własną kopię danych, więc późniejsze wysiedlenie LRU nie może unieważnić bufora, który już został wydany
Czy da się zapytać, czy strona 900 jest gotowa, bez jej pobierania?
Tak i dokładnie po to jest opcjonalne wywołanie zwrotne dostępności. Zwykłe wywołanie zwrotne odczytu nie odróżni bajtów, które już dotarły, od bajtów wymagających blokującej rundy, a sondowanie próbnym odczytem wywołałoby dokładnie to pobieranie, którego próbujesz uniknąć. TPDFlibRangeAvailabilityEvent odpowiada wyłącznie na jedno pytanie, czy kompletny zakres da się odczytać natychmiast, i ma zakaz czegokolwiek pobierania; bajty już pokryte przez cache zawsze liczą się jako dostępne. GetRangeSourceDataAvailability mapuje obiekty pośrednie na fizyczne zakresy zapisane we wpisach odwołań krzyżowych, sprowadza obiekty skompresowane do ich kontenera strumienia obiektów, koryguje przesunięty nagłówek PDF i parsuje obiekt dopiero po tym, jak pełny zakres przejdzie niepobierającą sondę, więc ścieżka braków nigdy nie wywołuje twojego wywołania zwrotnego odczytu
Przejście jest ograniczone zakresem, a nie wyczerpujące. Zapytanie o stronę przechodzi wyłącznie po gałęzi drzewa stron zawierającej stronę docelową, po czym dodaje treść strony, zasoby, adnotacje i dziedziczone atrybuty strony, pomijając krawędzie wsteczne Parent i P, więc pojedyncza strona albo widżet nie może rozwinąć się wstecz w cały dokument. Graf obiektów ma sufit 100000 żądanych obiektów i głębokość 256, obiekty strumieniowe są parsowane od słownika, a pełny parsing jako awaryjny jest dozwolony wyłącznie dla obiektów nieskompresowanych do 4 MiB. Raport JSON scala pokrywające się i sąsiadujące przedziały przed liczeniem, więc requiredBytes i missingBytes są liczone ze scalonych tablic requiredRanges i missingRanges, których end jest końcem domkniętym. Zapytanie o obiekt już dostępny może zapełnić cache zakresów; zapytanie o brakujący zostawia statystyki odczytu nietknięte
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 niesie "missingBytes" plus scalone "missingRanges" }
ShowProgress(Report)
else if Status = PDF_RANGE_DATA_NOT_PRESENT then
ShowMissingFeature; { np. plik nie ma w ogóle AcroForm }
end;
Dlaczego prefetch musi iterować
Bo jednokrotne przeczytanie bieżących missingRanges nie czyni strony dostępną. Brakujący węzeł drzewa stron albo strumień obiektów ujawnia następne piętro zależności dopiero po swoim przybyciu, więc zadanie prefetch w PDFlibPas wykonuje pętlę zapytanie, pobranie, zapytanie ponownie, aż strona, formularz albo graf obiektów będzie dostępny w całości albo zatrzyma go limit bajtów albo przebiegów. Zadanie używa własnego czytnika i małego cache drugiego poziomu, którego źródło danych przekazuje bezwzględne odczyty do pierwotnego strumienia zakresów, co trzyma stan parsowania w izolacji od pierwszoplanowego TSmartPDFReader, podczas gdy bajty, które naprawdę pobiera, lądują we współdzielonym głównym cache. Na każdy strumień zakresów przypada jeden wątek roboczy, co odpowiada serializacji, której już wymaga wywołanie zwrotne źródła, a kolejka wybiera według czterech poziomów priorytetu, a w obrębie poziomu według kolejności zgłoszeń. MaxBytes jest rozliczany w fizycznych bajtach chunków, więc parser proszący o jeden bajt wewnątrz chunka nieobecnego w cache i tak płaci za cały chunk, podczas gdy chunki już obecne we współdzielonym cache nie kosztują zadania nic. Anulowanie zakolejkowanego zadania dochodzi do stanu końcowego bez ani jednego odczytu źródła; zadanie w biegu jest sprawdzane przed każdym przebiegiem zależności i każdym chunkiem źródła, a zwalnianie strumienia zakresów czeka na powrót wywołania zwrotnego w locie, zamiast próbować je przerwać
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" i ostatni
pełny raport dostępności, więc LIMIT_REACHED pozostaje odróżnialne
od FAILED }
Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;
Kiedy to degeneruje się w pobranie całego pliku
Wczytywanie zakresowe to zakład na układ pliku, a niektóre pliki nie dotrzymują go. Plik zlinearyzowany według ISO 32000-1 §7.5.8 to przypadek dobry: sekcja pierwszej strony jest rozgrzewana przy otwarciu, ograniczona zarówno istniejącym progiem bezpieczeństwa 4 MiB, jak i bieżącym budżetem cache, więc rozgrzewka nie może natychmiast wysiedlić większości samej siebie. Plik niezlinearyzowany i tak dochodzi do celu przez trailer i łańcuch odwołań krzyżowych pod koniec pliku, co kosztuje kilka dodatkowych rund, a nie katastrofę. Prawdziwym urwiskiem jest uszkodzony plik zmuszający do ścieżki naprawy, bo odtworzenie tabeli odwołań krzyżowych oznacza skanowanie nagłówków obiektów po całym dokumencie, a to pełne pobieranie przychodzące chunk po chunku. Opóźnienie to druga uczciwa granica: przy 60 ms na żądanie parsowanie o dostępie losowym, potrzebujące czterdziestu chunków nieobecnych w cache, spędzi w tranzycie ponad dwie sekundy, jakkolwiek dobry byłby cache, i właśnie to mają ukryć argument wyprzedzającego odczytu i kolejka priorytetowa. Ta sama dyscyplina pokazuje się w podejściu z bezpośrednim dostępem do scalania i dzielenia dużych PDF, a ten cache siedzi pod spodem zarówno równoległego renderowania stron, jak i dyskowego cache stron podglądu
API źródła zakresów, zapytanie o dostępność i planista prefetch są częścią standardowej biblioteki PDFlibPas Delphi PDF Library dla Delphi, C++Builder i Free Pascal; strona produktu zawiera pełną referencję parametrów LoadFromRangeSource wraz ze stałymi priorytetu i stanów prefetch