Τεχνικό Άρθρο

Προοδευτική φόρτωση PDF με ranges σε Delphi με PDFlibPas

Ένα σαρωμένο αρχείο 2 GB μένει σε bucket S3 και ο χρήστης θέλει τη σελίδα 900. Το PDFlibPas μπορεί να σερβίρει εκείνη τη σελίδα χωρίς να κατεβάσει το αρχείο: το LoadFromRangeSource χτίζει read-only seekable stream πάνω από δικό σας callback byte-range και το δίνει στο TPDFDocument, ώστε ο parser να τραβήξει τους cross-reference πίνακες, έναν κλάδο page tree, και ένα content stream

Η πλευρά μεταφοράς αυτού είναι παλιά και βαρετή. Οι διακομιστές HTTP διαφημίζουν byte ranges εδώ και δεκαετίες, πλέον καθορισμένες στο RFC 9110 §14, και κάθε object store μιλά την ίδια διάλεκτο. Η πλευρά PDF είναι εξίσου τακτοποιημένη: το ISO 32000-1 §7.5.8 ορίζει τη linearization ακριβώς ώστε ένας reader να μπορεί να αποδώσει την πρώτη σελίδα από το μπροστινό μέρος του αρχείου. Αυτό που έλειπε στη Delphi είναι το κομμάτι στη μέση, το μέρος που αποφασίζει ποια ranges να ζητήσετε, πόσα να κρατήσετε, και πώς να αποφύγετε να ρωτήσετε δύο φορές

Τι θέλει το LoadFromRangeSource από τη μεταφορά σας;

Δύο πράγματα, και κανένα δεν είναι stream. Το PDFlibPas ζητά ένα authoritative SourceSize και σύγχρονο read callback τύπου TPDFlibRangeReadEvent, δηλωμένο ως function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Εσωτερικά το ζευγάρι γίνεται TCallbackByteRangeSource που εκθέτει SourceSize και ReadRange, τυλιγμένο σε stream της οποίας η ιδιοκτησία περνά στο έγγραφο. Ο στόχος callback σας και το backend του μένουν δικά σας: το έγγραφο ελευθερώνει το wrapper σε close, clear, ή reload, αλλά δεν αγγίζει ποτέ το αντικείμενο μεταφοράς πίσω από τον method pointer

Η σύμβαση είναι σκόπιμα επιεικής προς μία κατεύθυνση και αυστηρή προς την άλλη. Ένα short read είναι νόμιμο και απλώς σημαίνει ότι ο parser ξαναρωτά. Ένα callback που πετάει exception μετατρέπεται σε short read και συγκλίνει μέσα από τη συνήθη διαδρομή αποτυχίας φόρτωσης. Ένα callback που ισχυρίζεται ότι έγραψε περισσότερα από Count bytes σφίγγεται, γιατί ένα ελαττωματικό provider δεν πρέπει να μπορεί να ξεπεράσει το cache buffer. Οι επαναλήψεις κωδικού ξαναχτίζουν φρέσκο range stream και φρέσκια parse κατάσταση πάνω στην ίδια πηγή callback, οπότε μια αποτυχημένη προσπάθεια δεν μπορεί να αφήσει πίσω παλιά θέση, παράθυρο, ή κατάσταση αποκρυπτογράφησης

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
  { ένα blocking GET με 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;  { ελευθερώνει το wrapper stream }
  Src.Free;  { η μεταφορά σου, ο κύκλος ζωής σου }
end;

Πόσο κρατά πραγματικά το range cache;

Εξ ορισμού 4 MiB, απλωμένα σε windows ευθυγραμμισμένα σε chunk και εκδιωγμένα LRU. Ο προγενέστερος σχεδιασμός ενός window μεγάλωνε όσο ζητούσε ο καλών, οπότε ένα μεγάλο διαδοχικό read μπορούσε να ξεπεράσει το ονομαστικό μέγεθος chunk ενώ ένα τυχαίο άλμα πετούσε αμέσως το προηγούμενο window. Το τρέχον cache ευθυγραμμίζει κάθε source offset σε ChunkSize, τραβάει ακριβώς ένα chunk ανά miss, και επιβάλλει σκληρό προϋπολογισμό bytes πάνω από πολλά windows. Οποιοσδήποτε ρητός προϋπολογισμός περνάτε ανεβάζεται τουλάχιστον σε ένα πλήρες chunk, οπότε ένα απλό read προχωρά πάντα chunk το chunk και η κορύφωση φόρτωσης cache μένει προβλέψιμη. Ένα ChunkSize κάτω από 4096 γυρνά στο default των 64 KiB

Πώς σερβίρει το PDFlibPas ένα read parser σε Delphi χωρίς να κατεβάσει το PDF: το απόλυτο offset ευθυγραμμίζεται προς τα κάτω στο μέγεθος chunk, σερβίρεται από ένα από πολλά LRU windows σε hit, ή γίνεται μία σφιγμένη κλήση callback σε miss
Κάθε source offset ευθυγραμμίζεται στο μέγεθος chunk, οπότε ένα miss τραβάει ακριβώς ένα chunk και η κορύφωση φόρτωσης cache μένει προβλέψιμη

Η λογιστική των repeat reads είναι το κομμάτι που αξίζει να δέσετε στην τηλεμετρία σας. Το PDFlibPas αναγνωρίζει repeat από ευθυγραμμισμένη αρχή chunk και κρατά διατεταγμένα συνεχόμενα διαστήματα, που χωρίζει ένα γνήσιο πρώτο τράβηγμα από refetch μετά από εκδίωξη ενώ κρατά τη λογιστική από το να μεγαλώνει γραμμικά με το μέγεθος αρχείου. Η GetRangeSourceCacheInfo επιστρέφει όλη την εικόνα ως JSON, η SetRangeSourceCacheLimit αλλάζει μέγεθος προϋπολογισμού σε runtime, και η ClearRangeSourceCache ρίχνει τα windows και μηδενίζει τα στατιστικά μαζί. Η συρρίκνωση του προϋπολογισμού σε runtime κρατά το ιστορικό και μετρά τις αποδεσμεύσεις από προϋπολογισμό ως εκδιώξεις, οπότε ανεβαίνοντας repeatedReads απέναντι σε επίπεδο hits είναι το σήμα σας ότι το working set δεν χωράει πια

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;

Τι γίνεται όταν πολλά threads θέλουν το ίδιο chunk;

Περιμένουν σε ένα αίτημα, όχι σε πολλά. Ένα κλασικό TStream έχει έναν δείκτη θέσης, και δύο threads που κλειδώνουν σωστά το καθένα μπορούν ακόμα να έχουν εκείνη τη θέση ξαναγραμμένη ανάμεσα σε Seek και Read, οπότε τα lazy objects και τα segmented reads στο PDFlibPas χρησιμοποιούν απόλυτο ReadAt που δεν μετακινεί ποτέ τον δείκτη. Κάθε ευθυγραμμισμένο chunk παίρνει ένα μόνο in-flight αίτημα που μοιράζεται κάθε καλών για εκείνο το chunk, γειτονικά chunks σε ουρά συγχωνεύονται πριν ξεκινήσει το read πηγής, και ένα φυσικό read περιορίζεται στα 16 MiB, οπότε μια έξαρση παράλληλης δουλειάς σελίδων δεν μεγεθύνεται ούτε σε διπλά μικρά αιτήματα ούτε σε ένα παράλογα μεγάλο. Το merge window είναι εξ ορισμού 2 ms και εφαρμόζεται μόνο στο πρώτο ελλιπές chunk κάθε ReadAt· το positional Read δεν περιμένει ποτέ γι αυτό, και περνώντας μηδέν αφαιρεί εντελώς την αρχική καθυστέρηση συλλογής, που μετράει για μακριές διαδοχικές σαρώσεις που διαφορετικά θα συσσώρευαν την αναμονή chunk το chunk. Θέση, metadata cache, και reads πηγής κάθονται πίσω από τρία ξεχωριστά lock, και το ίδιο το callback πηγής είναι serialized, που είναι αυτό που αφήνει έναν adapter βάσης δεδομένων ή object store χωρίς εσωτερική προστασία threads να χρησιμοποιείται αμετάβλητος. Οι περιμένοντες λαμβάνουν το δικό τους αντίγραφο των δεδομένων, οπότε μια μεταγενέστερη εκδίωξη LRU δεν μπορεί να ακυρώσει buffer που έχει ήδη παραδοθεί

Συγχώνευση αιτημάτων στη φόρτωση ranges του PDFlibPas για Delphi: δύο threads που ζητούν το ίδιο chunk μοιράζονται ένα in-flight αίτημα, γειτονικά chunks σε ουρά συγχωνεύονται μέσα σε παράθυρο δύο millisecond, και ένα serialized read πηγής εξυπηρετεί όλα
Μια έξαρση παράλληλης δουλειάς σελίδων καταρρέει σε ένα κοινό αίτημα ανά chunk, και κάθε περιμένοντας εξακολουθεί να παίρνει το δικό του αντίγραφο των bytes

Μπορείτε να ρωτήσετε αν η σελίδα 900 είναι έτοιμη χωρίς να την τραβήξετε;

Ναι, και ακριβώς γι αυτό υπάρχει το προαιρετικό callback διαθεσιμότητας. Ένα απλό read callback δεν ξεχωρίζει bytes που έχουν ήδη προσγειωθεί από bytes που θέλουν blocking κλήση στον διακομιστή, και μια δοκιμή με δοκιμαστικό read θα πυροδοτούσε ακριβώς το κατέβασμα που προσπαθείτε να αποφύγετε. Το TPDFlibRangeAvailabilityEvent απαντά ένα μόνο ερώτημα, αν ένα πλήρες range μπορεί να διαβαστεί αμέσως, και απαγορεύεται να τραβήξει οτιδήποτε· bytes που το καλύπτει ήδη το cache μετράνε πάντα ως διαθέσιμα. Η GetRangeSourceDataAvailability χαρτογραφεί indirect objects στις φυσικές σειρές αποθήκευσης που καταγράφονται στις εγγραφές cross-reference, επιλύει compressed objects στον container object stream τους, διορθώνει για μετατοπισμένη επικεφαλίδα PDF, και αναλύει ένα αντικείμενο μόνο αφού το πλήρες range περάσει τη δοκιμή χωρίς τράβηγμα, οπότε η διαδρομή του ελλιπούς δεν καλεί ποτέ το read callback σας

Η διαδρομή είναι οριοθετημένη και όχι εξαντλητική. Ένα ερώτημα σελίδας περπατά μόνο τον κλάδο του page tree που περιέχει τη σελίδα στόχο και μετά προσθέτει page content, resources, annotations, και κληρονομημένα attributes σελίδας, παραλείποντας τα back-edges Parent και P ώστε μία σελίδα ή widget να μην μπορεί να απλωθεί προς τα πίσω σε όλο το έγγραφο. Το γράφημα αντικειμένων οριοθετείται στα 100000 ζητούμενα αντικείμενα και σε βάθος 256, τα stream objects αναλύονται dictionary πρώτα, και fallback πλήρους ανάλυσης επιτρέπεται μόνο για stored objects έως 4 MiB. Η αναφορά JSON συγχωνεύει αλληλεπικαλυπτόμενα και γειτονικά διαστήματα πριν τη μέτρηση, οπότε requiredBytes και missingBytes υπολογίζονται από τους συγχωνευμένους πίνακες requiredRanges και missingRanges, των οποίων το end είναι συμπεριληπτικό άκρο. Ερώτημα για αντικείμενο ήδη διαθέσιμο μπορεί να γεμίσει το range cache· ερώτημα για ελλιπές αφήνει τα στατιστικά reads ανέγγιχτα

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
    { Η αναφορά κουβαλά "missingBytes" συν τα συγχωνευμένα "missingRanges" }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { π.χ. το αρχείο δεν έχει καθόλου AcroForm }
end;

Γιατί το prefetch πρέπει να επαναλαμβάνεται

Επειδή το διάβασμα των τρεχόντων missingRanges μία φορά δεν κάνει τη σελίδα διαθέσιμη. Ένας ελλιπής κόμβος page tree ή object stream αποκαλύπτει το επόμενο στρώμα εξαρτήσεων μόνο αφού φτάσει, οπότε μια δουλειά prefetch του PDFlibPas τρέχει βρόχο ερώτημα, τράβηγμα, ξανά ερώτημα μέχρι η σελίδα, η φόρμα, ή το γράφημα αντικειμένων να είναι πλήρως διαθέσιμα ή ένα όριο bytes ή περασμάτων να τη σταματήσει. Η δουλειά χρησιμοποιεί δικό της reader και μικρό δευτερεύον cache του οποίου η πηγή δεδομένων προωθεί απόλυτα reads στο αρχικό range stream, που κρατά την parse κατάσταση απομονωμένη από τον foreground TSmartPDFReader ενώ τα bytes που κατεβάζει πραγματικά προσγειώνονται ακόμα στο κοινό κύριο cache. Ένα worker thread υπάρχει ανά range stream, ταιριάζοντας τη serialization που απαιτεί ήδη το callback πηγής, και η ουρά διαλέγει με τέσσερα επίπεδα προτεραιότητας και μετά με σειρά υποβολής μέσα σε επίπεδο. Το MaxBytes χρεώνεται σε φυσικά bytes chunk, οπότε parser που ζητά ένα μόνο byte μέσα σε chunk που δεν είναι στο cache πληρώνει ακόμα ολόκληρο το chunk, ενώ chunks ήδη στο κοινό cache δεν κοστίζουν στη δουλειά τίποτα. Η ακύρωση δουλειάς σε ουρά φτάνει σε τελική κατάσταση με μηδενικά reads πηγής· μια τρέχουσα δουλειά ελέγχεται πριν από κάθε πέρασμα εξαρτήσεων και κάθε chunk πηγής, και η απελευθέρωση του range stream περιμένει in-flight callback να επιστρέψει αντί να προσπαθήσει να το διακόψει

Ο βρόχος prefetch του PDFlibPas σε Delphi: μια δουλειά ρωτά διαθεσιμότητα, τραβάει τα ελλιπή ranges και ξαναρωτά, επειδή κάθε φτάνοντας κόμβος page tree ή object stream αποκαλύπτει το επόμενο στρώμα εξαρτήσεων, μέχρι το γράφημα να συμπληρωθεί ή ένα όριο να το σταματήσει
Μια δουλειά prefetch επαναλαμβάνεται επειδή ένας ελλιπής κόμβος ονοματίζει τα παιδιά του μόνο όταν φτάσει, και χρεώνει κάθε πέρασμα σε ολόκληρα φυσικά chunks
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" και η τελευταία
    πλήρης αναφορά διαθεσιμότητας, ώστε LIMIT_REACHED να μένει διακριτό
    από FAILED }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

Πού αυτό υποβαθμίζεται σε κατέβασμα ολόκληρου αρχείου

Η φόρτωση ranges είναι στοίχημα στη διάταξη αρχείου, και κάποια αρχεία δεν την τιμούν. Ένα linearized αρχείο κατά ISO 32000-1 §7.5.8 είναι η καλή περίπτωση: η ενότητα πρώτης σελίδας ζεσταίνεται στο άνοιγμα, οριοθετημένη τόσο από το υπάρχον κατώφλι ασφαλείας 4 MiB όσο και από τον τρέχοντα προϋπολογισμό cache ώστε το ζέσταμα δεν μπορεί αμέσως να εκδιώξει το μεγαλύτερο μέρος του εαυτού του. Ένα μη linearized αρχείο εξακολουθεί να επιλύεται μέσω trailer και αλυσίδας cross-reference κοντά στο τέλος, που κοστίζει μερικά επιπλέον ταξίδια μετ' επιστροφής και όχι καταστροφή. Ο πραγματικός γκρεμός είναι ένα κατεστραμμένο αρχείο που επιβάλλει τη διαδρομή επισκευής, γιατί η ανακατασκευή cross-reference πίνακα σημαίνει σάρωση για επικεφαλίδες αντικειμένων σε όλο το έγγραφο, και αυτό είναι πλήρες κατέβασμα που φτάνει ένα chunk τη φορά. Η καθυστέρηση είναι το άλλο τίμιο όριο: στα 60 ms ανά αίτημα, ένα parse τυχαίας πρόσβασης που θέλει σαράντα chunks εκτός cache ξοδεύει πάνω από δύο δευτερόλεπτα σε μεταφορά ανεξαρτήτως πόσο καλό είναι το cache, που είναι ακριβώς αυτό που υπάρχει να κρύψει το επιχείρημα read-ahead και η ουρά προτεραιότητας. Η ίδια πειθαρχία εμφανίζεται στην προσέγγιση direct access για συγχώνευση και τεμαχισμό μεγάλων PDF, και αυτό το cache κάθεται από κάτω στην παράλληλη απόδοση σελίδων και στο disk page cache του viewer εξίσου

Το API range source, το ερώτημα διαθεσιμότητας, και ο προγραμματιστής prefetch είναι μέρος του standard PDFlibPas Delphi PDF Library για Delphi, C++Builder, και Free Pascal· η σελίδα προϊόντος κουβαλά την πλήρη αναφορά παραμέτρων για το LoadFromRangeSource μαζί με τις σταθερές προτεραιότητας και κατάστασης prefetch