Techninis straipsnis

Progresyvus PDF intervalų įkėlimas Delphi su PDFlibPas

2 GB skenuotas archyvas gyvena S3 kibire, o vartotojas nori 900 puslapio. PDFlibPas gali patiekti tą puslapį nesisiunčiant failo: LoadFromRangeSource sukuria tik skaitymui skirtą ieškomą srautą ant jūsų pačių baitų intervalo atgalinio kvietimo ir perduoda jį TPDFDocument, todėl analizatorius atsisiunčia kryžminių nuorodų lenteles, vieną puslapių medžio šaką ir vieną turinio srautą

Transporto pusė čia sena ir nuobodi. HTTP serveriai reklamuoja baitų intervalus jau dešimtmečius, dabar apibrėžtus RFC 9110 §14, ir kiekviena objektų saugykla kalba tą patį dialektą. PDF pusė taip pat išsdirbusi: ISO 32000-1 §7.5.8 apibrėžia lineariavimą būtent tam, kad skaitytuvas galėtų atvaizduoti pirmą puslapį iš failo priekio. Delphi aplinkoje buvo trūkęs vidurinis gabalas — dalis, nusprendžianti, kurių intervalų prašyti, kiek laikyti, ir kaip išvengti prašymo du kartus

Ką LoadFromRangeSource reikalauja iš jūsų transporto?

Du dalykai, ir nė vienas iš jų nėra srautas. PDFlibPas prašo autoritetingo SourceSize ir sinchroninio skaitymo atgalinio kvietimo tipo TPDFlibRangeReadEvent, deklaruoto kaip function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Viduje pora tampa TCallbackByteRangeSource, atveriančiu SourceSize ir ReadRange, įvyniotu į srautą, kurio nuosavybė perduodama dokumentui. Jūsų atgalinio kvietimo tikslas ir jo vidinė dalis lieka jūsų: dokumentas atlaisvina įvyniojimą uždarant, išvalant arba persikraunant, bet niekada neliečia transporto objekto už metodo rodyklės

Sutartis sąmoningai atlaisi viena kryptimi ir griežta kita. Trumpas skaitymas yra teisėtas ir tiesiog reiškia, kad analizatorius prašo vėl. Išimtį keliantis atgalinis kvietimas paverčiamas trumpu skaitymu ir sueina per įprastą įkėlimo nesėkmės kelią. Atgalinis kvietimas, teigiantis parašęs daugiau nei Count baitų, užspaudžiamas, nes sugedęs tiekėjas negali perplūsti talpyklos buferio. Slaptažodžio pakartojimai per tą patį atgalinio kvietimo šaltinį atkuria šviežią intervalų srautą ir šviežią analizės būseną, todėl nesėkmingas bandymas negali palikti pasenusios pozicijos, lango arba iššifravimo būsenos

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
  { vienas blokuojantis GET su 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;  { atlaisvina įvyniojimo srautą }
  Src.Free;  { jūsų transportas, jūsų gyvavimo laikas }
end;

Kiek iš tikrųjų laiko intervalų talpykla?

Pagal numatytuosius 4 MiB, paskirstyti per gabalų išlygintus langus ir išmetamus LRU. Ankstesnis vieno lango dizainas augo iki kokio ilgio prašė kvietėjas, todėl vienas didelis nuoseklus skaitymas galėjo peršokti nominalų gabalo dydį, kol atsitiktinis šuolis iškart išmesdavo ankstesnį langą. Dabartinė talpykla išlygina kiekvieną šaltinio poslinkį pagal ChunkSize, paima lygiai vieną gabalą per praleidimą, ir taiko standų baitų biudžetą keliuose languose. Bet kokia jūsų perduota atviroji kvota pakeliama bent iki vieno pilno gabalo, todėl vienas skaitymas visada žengia gabalas po gabalo, o pikinė talpyklos apkrova lieka nuspėjama. ChunkSize žemiau 4096 grįžta į 64 KiB numatytąjį

Kaip PDFlibPas Delphi patiekti analizatoriaus skaitymą nesisiunčiant PDF: absoliutus poslinkis išlyginamas žemyn iki gabalo dydžio, patiekiamas iš vieno iš kelių LRU langų pataikymo atveju, arba virsta vienu užspaudžiamu atgaliniu kvietimu praleidimo atveju
Kiekvienas šaltinio poslinkis išlyginamas pagal gabalo dydį, todėl vienas praleidimas paima lygiai vieną gabalą, o pikinė talpyklos apkrova lieka nuspėjama

Pakartotinio skaitymo apskaita yra dalis, verta prijungti prie jūsų telemetrijos. PDFlibPas atpažįsta pakartojimą pagal išlygintą gabalo pradžią ir laiko tvarkingus ištisinius intervalus, kas atskiria tikrą pirmąjį paėmimą nuo pakartotinio po išmetimo, neleisdama apskaitai augti tiesiai su failo dydžiu. GetRangeSourceCacheInfo grąžina visą vaizdą kaip JSON, SetRangeSourceCacheLimit keičia kvotą vykdymo metu, o ClearRangeSourceCache numeta langus ir kartu atnulina statistiką. Kvotos mažinimas vykdymo metu išlaiko istoriją ir kvotomis varomus atlaisvinimus skaičiuoja kaip išmetimus, todėl kylantis repeatedReads prieš plokščią hits yra jūsų signalas, kad darbinis rinkinys daugiau netelpa

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;

Kas nutinka, kai kelios gijos nori to paties gabalo?

Jie laukia vienos užklausos, o ne kelių. Klasikinis TStream turi vieną pozicijos žymeklį, ir dvi gijos, kiekviena teisingai užrakinusi, vis tiek gali turėti tą poziciją perrašytą tarp Seek ir Read, todėl tingūs objektai ir segmentiniai skaitymai PDFlibPas naudoja absoliutų ReadAt, niekada nejudingą žymeklio. Kiekvienas išlygintas gabalas gauna vieną vykstantį užklausą, kurią dalijasi visi to gabalo kvietėjai, gretimi eilės gabalai sujungiami prieš prasidedant šaltinio skaitymui, ir vienas fizinis skaitymas ribojamas 16 MiB, todėl lygiagretaus puslapių darbo sprogimas neįamplifikuojasi nei į dubliuotas mažas užklausas, nei į vieną absurdiškai didelę. Sujungimo langas numatytas 2 ms ir taikomas tik pirmajam kiekvieno ReadAt dingusiam gabalui; pozicinis Read niekada nelaukia jo, o nulio perdavimas visai pašalina pradinį rinkimo uždelsimą, kas svarbu ilgiems nuosekliems sklavimams, kitu atveju kaupiantiems laukimą gabalas po gabalo. Pozicija, talpyklos metaduomenys ir šaltinio skaitymai sėdi už trijų atskirų užraktų, o pats šaltinio atgalinis kvietimas serializuojamas — dėl to duomenų bazės arba objektų saugyklos adapteris be vidinės gijų apsaugos gali būti naudojamas nepakeistas. Laukiantieji gauna savo pačių duomenų kopiją, todėl vėlesnis LRU išmetimas negali padaryti negaliojančiu jau išduoto buferio

Užklausų sujungimas PDFlibPas intervalų įkėlime Delphi: dvi gijos, prašančios to paties gabalo, dalijasi viena vykstančia užklausa, gretimi eilės gabalai sujungiami dviejų milisekundžių lange, ir vienas serializuotas šaltinio skaitymas patiekia visiems
Lygiagretaus puslapių darbo sprogimas sugriūva į vieną bendrą užklausą gabalui, ir kiekvienas laukiantysis vis tiek gauna savo baitų kopiją

Ar galima klausti, ar 900 puslapis paruoštas, jo neparsiunčiant?

Taip, ir būtent tam skirtas atviro pasirinkimo prieinamumo atgalinis kvietimas. Paprastas skaitymo atgalinis kvietimas negali atskirti jau atkeliavusių baitų nuo baitų, reikalaujančių blokuojančios kelionės pirmyn ir atgal, o zondavimas bandomuoju skaitymu sukeltų tą patį parsiuntimą, kurio stengiatės išvengti. TPDFlibRangeAvailabilityEvent atsako tik į vieną klausimą — ar pilnas intervalas gali būti perskaitytas nedelsiant — ir jam draudžiama nieko parsisiųsti; baitai, kuriuos talpykla jau dengia, visada skaitomi prieinamais. GetRangeSourceDataAvailability susieja netiesioginius objektus su fiziniais saugojimo intervalais, užrašytais kryžminių nuorodų įrašuose, išsprendžia suspaustus objektus į jų objektų srauto talpyklę, pataiso paslinktą PDF antraštę, ir analizuoja objektą tik po to, kai pilnas intervalas praeina neisiuntusį zondą, todėl dingimo kelias niekada nekvečia jūsų skaitymo atgalinio kvietimo

Apėjimas yra apibrėžtos apimties, o ne išsamus. Puslapio užklausa eina tik per puslapių medžio šaką, laikančią tikslinį puslapį, o tada prideda puslapio turinį, išteklius, anotacijas ir paveldėtus puslapio atributus, praleisdama Parent ir P atgalines briaunas, kad vienas puslapis arba valdiklis negalėtų išsiplėsti atgal į visą dokumentą. Objektų grafą riboja 100000 prašomų objektų ir 256 gylis, srautų objektai analizuojami pirmiausia pagal žodyną, o pilnos analizės atsarginis kelias leidžiamas tik saugomiems objektams iki 4 MiB. JSON ataskaita prieš skaičiuodama sujungia persidengiančius ir gretimus intervalus, todėl requiredBytes ir missingBytes skaičiuojami iš sujungtų requiredRanges ir missingRanges masyvų, kurių end yra įtraukiantis galinis taškas. Jau prieinamo objekto užklausimas gali užpildyti intervalų talpyklą; dingusio objekto užklausimas palieka skaitymo statistiką nepaliestą

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
    { Reporte — "missingBytes" ir sujungti "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { pvz. failas visai neturi AcroForm }
end;

Kodėl išankstinis parsiuntimas turi kartotis

Nes dabartinių missingRanges perskaitymas kartą nepaverčia puslapio prieinamu. Dingęs puslapių medžio mazgas arba objektų srautas atskleidžia kitą priklausomybių sluoksnį tik atkeliavęs, todėl PDFlibPas išankstinio parsiuntimo darbas vykdo užklausos, paėmimo, pakartotinės užklausos ciklą, kol puslapis, forma arba objektų grafai visiškai prieinami arba baitų arba praėjimų riba sustabdo. Darbas naudoja savo skaitytuvą ir mažą antrinę talpyklą, kurios duomenų šaltinis persiunčia absoliučius skaitymus į pirminį intervalų srautą, kas laiko analizės būseną atskirtą nuo pirmojo plano TSmartPDFReader, kol jo tikrai parsisiunčiami baitai vis tiek nukrenta į bendrą pagrindinę talpyklą. Viena darbinė gija egzistuoja intervalų srautui, atitinkantį serializaciją, kurios jau reikalauja šaltinio atgalinis kvietimas, o eilė renka pagal keturis prioritetų lygius ir tada pagal pateikimo tvarką lygio viduje. MaxBytes skaičiuojamas fiziniais gabalų baitais, todėl analizatorius, prašantis vieno baito neištalpytame gabale, vis tiek moka už visą gabalą, o gabalai, jau esantys bendroje talpykloje, darbui nieko nekainuoja. Eilėje esančio darbo atšaukimas pasiekia galutinę būseną su nuliu šaltinio skaitymų; vykstantis darbas tikrinamas prieš kiekvieną priklausomybių praėjimą ir kiekvieną šaltinio gabalą, o intervalų srauto atlaisvinimas laukia, kol vykstantis atgalinis kvietimas grįš, vietoj to, kad bandytų jį nutraukti

PDFlibPas išankstinio parsiuntimo ciklas Delphi: darbas teiraujasi prieinamumo, paima dingusius intervalus ir teiraujasi vėl, nes kiekvienas atkeliavęs puslapių medžio mazgas arba objektų srautas atskleidžia kitą priklausomybių sluoksnį, kol grafai baigiasi arba riba sustabdo
Išankstinio parsiuntimo darbas kartojasi, nes dingęs mazgas savo vaikus įvardija tik atkeliavęs, ir kiekvieną praėjimą jis apmokestina pilniais fiziniais gabalais
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" ir paskutinė
    pilna prieinamumo ataskaita, kad LIMIT_REACHED liktų atskiriamas
    nuo FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Kur tai degraduoja į viso failo parsiuntimą

Intervalų įkėlimas yra statymas ant failo išdėstymo, ir kai kurie failai jo nesilaiko. Lineariuotas failas pagal ISO 32000-1 §7.5.8 yra geras atvejis: pirmojo puslapio sekcija pašildoma atidarant, ribojama ir esamos 4 MiB saugos ribos, ir dabartinės talpyklos kvotos, todėl pašildymas negali iškart išmesti daugumos savęs paties. Nelineariuotas failas vis tiek išsprendžiamas per priekabą ir kryžminių nuorodų grandinę prie galo, kas kainuoja porą papildomų kelionių pirmyn atgal, o ne katastrofą. Tikrasis skardis yra pažeistas failas, privertęs remonto kelią, nes kryžminių nuorodų lentelės atstatymas reiškia objektų antraščių paiešką per visą dokumentą, o tai yra pilnas parsiuntimas, atkeliaujantis po vieną gabalą. Vėlavimas yra kita sąžininga riba: ties 60 ms užklausai, atsitiktinės prieigos analizė, reikalaujanti keturiasdešimties neištalpytų gabalų, praleidžia virš dviejų sekundžių tranzite, nepaisant kaip gera talpykla — būtent tam, kad tai paslėptų, egzistuoja išankstinio skaitymo argumentas ir prioritetų eilė. Ta pati drausmė matosi tiesioginės prieigos prieipyje, jungiant ir skaidant didelius PDF, ir ši talpykla sėdi po lygiagretiu puslapių atvaizdavimu ir žiūryklės disko puslapių talpykla vienodai

Intervalų šaltinio API, prieinamumo užklausa ir išankstinio parsiuntimo planuotojas yra standartinės PDFlibPas Delphi PDF Library dalis Delphi, C++Builder ir Free Pascal aplinkoms; produkto puslapis neša visą LoadFromRangeSource parametrų nuorodą kartu su išankstinio parsiuntimo prioriteto ir būsenų konstantomis