บทความเทคนิค

โหลด PDF แบบ range ทีละส่วนใน Delphi ด้วย PDFlibPas

คลังเอกสารสแกนขนาด 2 GB นอนอยู่ใน S3 bucket และผู้ใช้ต้องการหน้า 900 PDFlibPas หยิบหน้านั้นให้ได้โดยไม่ต้องโหลดทั้งไฟล์: LoadFromRangeSource สร้าง stream แบบ read-only ที่ seek ได้ครอบ callback ย่านไบต์ของคุณเองแล้วส่งต่อให้ TPDFDocument parser จึงดึงแค่ตาราง cross-reference, หนึ่งกิ่งของ page tree และหนึ่ง content stream

ฝั่งขนส่งของเรื่องนี้เก่าและน่าเบื่อ เซิร์ฟเวอร์ HTTP โฆษณา byte range มาหลายสิบปี ตอนนี้ถูกระบุใน RFC 9110 §14 และ object store ทุกตัวพูดภาษาเดียวกัน ฝั่ง PDF ก็นิ่งพอกัน: ISO 32000-1 §7.5.8 นิยาม linearization เพื่อให้ reader เรนเดอร์หน้าแรกจากหัวไฟล์ได้พอดี สิ่งที่ขาดใน Delphi คือชิ้นกลาง ชิ้นที่ตัดสินว่าจะขอย่านไหน เก็บกี่ชุด และจะไม่ถามซ้ำเป็นครั้งที่สองอย่างไร

LoadFromRangeSource ต้องการอะไรจากการขนส่งของคุณ

สองอย่าง และไม่มีอย่างใดคือ stream PDFlibPas ขอ SourceSize ที่เป็นจริงแท้ กับ 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

สัญญาถูกตั้งใจให้ใจกว้างด้านหนึ่งและเข้มด้านหนึ่ง การอ่านได้สั้นกว่าที่ขอเป็นเรื่องถูกกฎหมาย และแปลว่า parser ขอใหม่เฉย ๆ callback ที่ raise จะถูกแปลงเป็น short read แล้วบรรจบผ่านเส้นทางโหลดล้มเหลวปกติ callback ที่อวดว่าเขียนไปมากกว่า Count ไบต์จะถูกหนีบ เพราะ provider พังต้องไม่มีสิทธิ์วิ่งเลยบัฟเฟอร์แคช การลองรหัสผ่านซ้ำสร้าง range stream ใหม่พร้อม parse state ใหม่เหนือ 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
  { GET แบบ block หนึ่งครั้ง พร้อม 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 จริง ๆ เก็บได้เท่าไร

ค่า default 4 MiB กระจายเป็นหน้าต่างที่ชนขอบกับ chunk และขับไล่แบบ LRU ดีไซน์หน้าต่างเดี่ยวรุ่นก่อนโตได้ตามที่ผู้เรียกขอ การอ่านลำดับชุดใหญ่หนึ่งครั้งจึงพุ่งทลายขนาด chunk ที่ระบุไว้ได้ และการกระโดดสุ่มหนึ่งครั้งก็ทิ้งหน้าต่างก่อนหน้าทันที แคชปัจจุบันจัดแนว offset ต้นทางทุกตัวเข้ากับ ChunkSize ดึงหนึ่ง chunk ต่อหนึ่ง miss พอดี และบังคับงบไบต์ตายตัวคร่อมหลายหน้าต่าง งบที่คุณส่งผ่านเข้ามาจะถูกปรับขึ้นอย่างน้อยหนึ่ง chunk เต็ม การอ่านหนึ่งครั้งจึงไล่ทีละ chunk และภาระสูงสุดของแคชคงพยากรณ์ได้ ChunkSize ที่ต่ำกว่า 4096 ถอยไปใช้ default 64 KiB

PDFlibPas รองรับการอ่านของ parser ใน Delphiโดยไม่ต้องดาวน์โหลด PDF: offset สัมบูรณ์ถูกจัดแนวลงตามขนาด chunk เสิร์ฟจากหนึ่งในหลายหน้าต่าง LRU เมื่อโดน หรือกลายเป็น callback call เดียวที่ถูกหนีบเมื่อไม่โดน
offset ต้นทางทุกตัวถูกจัดแนวเข้าขนาด chunk หนึ่ง miss จึงดึงหนึ่ง chunk พอดี และภาระสูงสุดของแคชคงพยากรณ์ได้

บัญชีการอ่านซ้ำคือส่วนที่ควรต่อเข้า telemetry ของคุณ PDFlibPas ระบุการอ่านซ้ำด้วยจุดเริ่ม chunk ที่จัดแนวแล้ว เก็บช่วงต่อเนื่องเรียงลำดับ ซึ่งแยกการดึงครั้งแรกจริง ๆ ออกจากการดึงซ้ำหลังถูกขับไล่ พร้อมกันนั้นก็กันงานบัญชีโตเป็นเชิงเส้นตามขนาดไฟล์ GetRangeSourceCacheInfo คืนภาพรวมทั้งหมดเป็น JSON, SetRangeSourceCacheLimit ปรับงบได้ตอนรันไทม์ และ ClearRangeSourceCache ทิ้งหน้าต่างพร้อมรีเซ็ตสถิติไปด้วยกัน การหดงบตอนรันไทม์คงประวัติไว้และนับการปล่อยที่ถูกงบบังคับเป็น eviction จำนวน 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;

เกิดอะไรขึ้นเมื่อหลายเธรดต้องการ chunk เดียวกัน

พวกมันรอที่ request เดียว ไม่ใช่หลาย request TStream คลาสสิกมีเคอร์เซอร์ตำแหน่งเดียว สองเธรดที่ล็อกถูกต้องทั้งคู่ก็ยังอาจมีตำแหน่งถูกเขียนทับระหว่าง Seek กับ Read lazy object กับการอ่านแบบแบ่งส่วนใน PDFlibPas จึงใช้ ReadAt แบบสัมบูรณ์ที่ไม่ขยับเคอร์เซอร์เลย แต่ละ chunk ที่จัดแนวแล้วมี request ที่บินอยู่หนึ่งตัวซึ่งผู้เรียกทุกตัวของ chunk นั้นใช้ร่วม หน้าต่างรวมของ default คือ 2 ms และใช้เฉพาะกับ chunk แรกที่ขาดของแต่ละ ReadAt Read แบบมีตำแหน่งไม่รอมันเสมอ และการส่งศูนย์กำจัดความล่าช้าในการรวบรวมตอนต้นไปทั้งหมด ซึ่งสำคัญกับการสแกนตามลำดับยาว ๆ ที่ไม่งั้นจะสะสมเวลารอไปเรื่อย ๆ ทีละ chunk ตำแหน่ง, เมตาดาต้าแคช และการอ่านต้นทางอยู่หลังล็อกแยกสามดวง และตัว callback ต้นทางเองถูกซีเรียลไว้ ซึ่งเป็นสิ่งที่อนุญาตให้ adapter ของ database หรือ object store ที่ไม่มีการป้องกันเธรดภายในถูกใช้ต่อโดยไม่แก้เลย ผู้รอแต่ละตัวได้สำเนาข้อมูลของตัวเอง eviction รอบหลังจึงทำให้บัฟเฟอร์ที่มอบออกไปแล้วไม่มีวันเสียสภาพ

การรวม request ในการโหลด range ของ PDFlibPas สำหรับ Delphi: สองเธรดที่ขอ chunk เดียวกันใช้ request ที่บินอยู่ตัวเดียวกัน chunk ที่ต่อแถวถูกรวมในหน้าต่างสองมิลลิวินาที และการอ่านต้นทางแบบซีเรียลหนึ่งครั้งเสิร์ฟทุกตัว
การทำงานหน้าแบบขนานที่ระเบิดพร้อมกันยุบเป็น request ร่วมหนึ่งตัวต่อ chunk และผู้รอทุกตัวยังได้สำเนาไบต์ของตัวเอง

ถามได้ไหมว่าหน้า 900 พร้อมหรือยังโดยไม่ต้องดึงมัน

ได้ และนั่นคือหน้าที่ของ callback availability ที่เสริมได้ callback อ่านแบบเปล่า ๆ แยกไบต์ที่ตกแล้วจากไบต์ที่ต้องเดินทางไปกลับแบบ block ไม่ได้ และการเกาะตรวจด้วยการอ่านลองเป็นการจุดชนวนการดาวน์โหลดพอดีที่คุณพยายามเลี่ยง TPDFlibRangeAvailabilityEvent ตอบคำถามเดียว ว่าย่านครบสามารถอ่านได้ทันทีหรือไม่ และถูกห้ามดึงอะไรเลย ไบต์ที่แคชครอบอยู่แล้วนับเป็น available เสมอ GetRangeSourceDataAvailability จับแมป indirect object ไปย่าน storage กายภาพที่บันทึกไว้ใน entry ของ cross-reference resolve ออบเจกต์ที่ถูกบีบอัดไปยังคอนเทนเนอร์ object stream แก้ส่วนเผื่อของ header PDF ที่ถูกเลื่อน แล้วจึง parse ออบเจกต์เมื่อย่านเต็มผ่านการเกาะตรวจแบบไม่ดึง เส้นทางที่ขาดจึงไม่มีวันเรียก callback อ่านของคุณ

การเดินสำรวจถูกกำหนดขอบเขต ไม่ใช่แผ่ทั่ว การ query หน้าเดินเฉพาะกิ่งของ page tree ที่มีหน้าเป้าหมายแล้วค่อยเพิ่มเนื้อหาหน้า, รีซอร์ส, annotation และแอตทริบิวต์หน้าที่ถูกสืบทอด โดยข้ามขอบย้อน Parent กับ P เพื่อไม่ให้หน้าหรือ widget หนึ่งชิ้นขยายย้อนกลับทั้งเอกสาร กราฟออบเจกต์ถูกจำกัดที่ 100000 ออบเจกต์ที่ถูกขอและความลึก 256 ออบเจกต์ stream ถูก parse ที่ dictionary ก่อน และ fallback แบบ parse เต็มถูกยอมเฉพาะออบเจกต์ที่เก็บไว้ไม่เกิน 4 MiB รายงาน JSON รวมช่วงที่ซ้อนทับกับชิดกันเข้าด้วยกันก่อนนับ requiredBytes กับ missingBytes จึงถูกคำนวณจากอาร์เรย์ requiredRanges กับ missingRanges ที่ถูกรวมแล้ว โดย end เป็นปลายรวม การ query ออบเจกต์ที่พร้อมอยู่แล้วอาจเติม range cache การ query ออบเจกต์ที่ขาดไม่แตะสถิติการอ่านเลย

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 แถม "missingBytes" กับ "missingRanges" ที่ถูกรวมแล้ว }
    ShowProgress(Report)
  else if Status = PDF_RANGE_DATA_NOT_PRESENT then
    ShowMissingFeature;  { เช่น ไฟล์ไม่มี AcroForm เลย }
end;

ทำไม prefetch ต้องวนซ้ำ

เพราะการอ่าน missingRanges ปัจจุบันครั้งเดียวไม่ทำให้หน้าพร้อม โหนด page tree หรือ object stream ที่ขาดเผยชั้น dependency ถัดไปเมื่อมันมาถึงเท่านั้น งาน prefetch ของ PDFlibPas จึงวนลูป query, ดึง, query ใหม่ จนกระทั่งหน้า, form หรือกราฟออบเจกต์พร้อมครบ หรือจนงบไบต์หรือจำนวน pass ตัดมัน งานใช้ reader ของตัวเองกับแคชรองเล็ก ๆ ที่ data source ส่งต่อการอ่านสัมบูรณ์ไปยัง range stream ต้นฉบับ สถานะ parse จึงแยกจาก TSmartPDFReader เบื้องหน้า ขณะที่ไบต์ที่มันดาวน์โหลดจริง ๆ ยังไปตกที่แคชหลักร่วมกัน มีเวิร์กเกอร์หนึ่งตัวต่อหนึ่ง range stream เทียบเท่าการซีเรียลที่ callback ต้นทางเรียกร้องอยู่แล้ว และคิวเลือกด้วยระดับความสำคัญสี่ระดับแล้วตามด้วยลำดับการส่งภายในระดับ MaxBytes ถูกเรียกเก็บเป็นไบต์ chunk กายภาพ parser ที่ขอไบต์เดียวใน chunk ที่ยังไม่ถูกแคชจึงต้องจ่ายทั้ง chunk ขณะที่ chunk ที่อยู่ในแคชร่วมแล้วแทบไม่เสียอะไรกับงานเลย การยกเลิกงานที่ยังต่อคิวไปถึงสถานะปลายทางด้วยการอ่านต้นทางเป็นศูนย์ งานที่กำลังรันจะถูกเช็คก่อนทุก dependency pass กับทุก chunk ต้นทาง และการปล่อย range stream รอ callback ที่บินอยู่คืน แทนที่จะพยายามขัดขวางมัน

ลูป prefetch ของ PDFlibPas ใน Delphi: งาน query availability, ดึงย่านที่ขาดแล้ว query ใหม่ เพราะโหนด page tree หรือ object stream ที่มาถึงแต่ละตัวเผยชั้น dependency ถัดไป จนกราฟครบหรือจนขีดจำกัดตัดมัน
งาน prefetch ต้องวนเพราะโหนดที่ขาดตั้งชื่อลูกของตัวเองได้ก็ต่อเมื่อมันมาถึง และมันเรียกเก็บทุก pass เป็น chunk กายภาพเต็ม
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" และ
    รายงาน availability เต็มรอบล่าสุด เพื่อให้ LIMIT_REACHED ยัง
    แยกออกจาก FAILED ได้ }
  Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;

เส้นทางนี้เสื่อมเป็นการดาวน์โหลดทั้งไฟล์เมื่อไร

การโหลดแบบ range คือการเดิมพันกับเลย์เอาต์ไฟล์ และบางไฟล์ไม่เชิดชูมัน ไฟล์ linearized ตาม ISO 32000-1 §7.5.8 คือเคสดี ส่วนหน้าแรกถูกอุ่นตอนเปิด ถูกกำด้วยทั้งเกณฑ์ความปลอดภัย 4 MiB ที่มีอยู่แล้วและงบแคชปัจจุบัน การอุ่นจึงไม่ขับไล่ตัวเองส่วนใหญ่ทิ้งทันที ไฟล์ที่ไม่ linearized ยัง resolve ผ่าน trailer กับห่วงโซ่ cross-reference แถวท้ายได้ ซึ่งเสียการเดินทางไปกลับสองสามครั้งพิเศษ ไม่ใช่หายนะ หน้าผาจริงคือไฟล์เสียที่บังคับเข้าเส้นทางซ่อม เพราะการสร้างตาราง cross-reference ใหม่แปลว่าแสกนหา header ของออบเจกต์ทั่วทั้งเอกสาร นั่นคือการดาวน์โหลดเต็มที่มาทีละ chunk ความหน่วงคือขีดจำกัดซื่อตรงอีกด้าน: ที่ 60 ms ต่อ request การ parse แบบสุ่มเข้าถึงที่ต้องการ chunk ที่ยังไม่ถูกแคชสี่สิบตัวใช้เวลาเกินสองวินาทีบนเส้นทาง ไม่ว่าแคชจะเก่งแค่ไหน ซึ่งพอดีเป็นเหตุผลที่ read-ahead กับคิวความสำคัญถูกสร้างไว้ปิดบัง วินัยเดียวกันนี้โผล่ใน แนวทาง direct access สำหรับ merge และ split PDF ขนาดใหญ่ และแคชนี้ซ้อนอยู่ใต้ทั้ง การเรนเดอร์หน้าแบบขนาน และ แคชหน้าบนดิสก์ของ viewer ไปพร้อมกัน

API range source, คำถาม availability และตัวจัดคิว prefetch เป็นส่วนของ PDFlibPas Delphi PDF Library มาตรฐานสำหรับ Delphi, C++Builder และ Free Pascal หน้าผลิตภัณฑ์มี reference พารามิเตอร์เต็มของ LoadFromRangeSource พร้อมค่าคงที่ความสำคัญและสถานะของ prefetch