Сканированный архив на 2 ГБ лежит в бакете S3, а пользователю нужна страница 900. PDFlibPas отдаст эту страницу, не скачивая файл целиком: LoadFromRangeSource строит поток только для чтения с позиционированием поверх вашего собственного колбэка байтовых диапазонов и передаёт его TPDFDocument, поэтому парсер подтягивает таблицы перекрёстных ссылок, одну ветвь дерева страниц и один поток содержимого
Транспортная сторона задачи стара и скучна. HTTP-серверы десятилетиями рекламируют байтовые диапазоны, теперь специфицированные в RFC 9110 §14, и каждое объектное хранилище говорит на том же диалекте. Сторона PDF устроена не менее определённо: ISO 32000-1 §7.5.8 определяет линеаризацию именно затем, чтобы читатель мог отрисовать первую страницу из начала файла. Чего не хватало в Delphi, так это среднего звена — части, которая решает, какие диапазоны запрашивать, сколько их держать и как не спрашивать дважды
Что LoadFromRangeSource требует от вашего транспорта?
Две вещи, и ни одна из них не является потоком. PDFlibPas запрашивает достоверный SourceSize и синхронный колбэк чтения типа TPDFlibRangeReadEvent, объявленный как function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Внутри эта пара превращается в TCallbackByteRangeSource, открывающий SourceSize и ReadRange и завёрнутый в поток, чьё владение переходит документу. Цель колбэка и её бэкенд остаются вашими: документ освобождает обёртку при закрытии, очистке или перезагрузке, но никогда не трогает транспортный объект за указателем метода
Контракт намеренно снисходителен в одну сторону и строг в другую. Короткое чтение законно и просто означает, что парсер спросит ещё раз. Колбэк, выбросивший исключение, превращается в короткое чтение и сводится к обычному пути сбоя загрузки. Колбэк, заявивший, что записал больше Count байтов, обрезается, ведь сбойный провайдер не должен иметь возможности переполнить буфер кэша. Повторные попытки ввода пароля строят свежий поток диапазонов и свежее состояние разбора над тем же источником колбэка, поэтому неудачная попытка не может оставить после себя устаревшую позицию, окно или состояние расшифровки
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 с 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; { освобождает поток-обёртку }
Src.Free; { ваш транспорт, ваше время жизни }
end;
Сколько на самом деле вмещает кэш диапазонов?
По умолчанию 4 МиБ, распределённые по выровненным по чанкам окнам с вытеснением LRU. Прежний дизайн с единственным окном разрастался до любой запрошенной вызывающим длины, поэтому одно крупное последовательное чтение могло выйти за номинальный размер чанка, а случайный прыжок мгновенно выбрасывал предыдущее окно. Текущий кэш выравнивает каждое смещение источника по ChunkSize, при каждом промахе запрашивает ровно один чанк и держит жёсткий байтовый бюджет на несколько окон. Явно переданный бюджет поднимается минимум до одного полного чанка, поэтому одиночное чтение всегда продвигается чанк за чанком, а пиковая нагрузка на кэш остаётся предсказуемой. ChunkSize ниже 4096 откатывается к принятому по умолчанию значению 64 КиБ
Учёт повторных чтений — та часть, которую стоит завести в вашу телеметрию. PDFlibPas опознаёт повтор по выровненному началу чанка и хранит упорядоченные непрерывные интервалы, что отличает настоящий первый запрос от повторного после вытеснения, не позволяя бухгалтерии расти линейно с размером файла. GetRangeSourceCacheInfo возвращает всю картину в JSON, SetRangeSourceCacheLimit меняет бюджет на ходу, а ClearRangeSourceCache сбрасывает окна и статистику вместе. Уменьшение бюджета на ходу сохраняет историю и считает вызванные бюджетом освобождения вытеснениями, поэтому растущий repeatedReads при плоском hits — ваш сигнал, что рабочий набор больше не помещается
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;
Что происходит, когда несколько потоков хотят один и тот же чанк?
Они ждут один запрос, а не несколько. У классического TStream один курсор позиции, и два потока, каждый из которых корректно блокируется, всё равно могут обнаружить, что позицию переписали между Seek и Read, поэтому ленивые объекты и сегментированные чтения в PDFlibPas используют абсолютный ReadAt, никогда не двигающий курсор. Каждый выровненный чанк получает один находящийся в полёте запрос, который разделяют все обратившиеся к этому чанку, смежные чанки из очереди сливаются до начала чтения источника, а одно физическое чтение ограничено 16 МиБ, поэтому всплеск параллельной работы со страницами не оборачивается ни дублирующимися мелкими запросами, ни одним абсурдно крупным. Окно слияния по умолчанию составляет 2 мс и применяется только к первому отсутствующему чанку каждого ReadAt; позиционный Read его никогда не ждёт, а передача нуля полностью убирает начальную задержку сбора, что важно для длинных последовательных сканирований, которые иначе копили бы ожидание чанк за чанком. Позиция, метаданные кэша и чтения источника стоят за тремя отдельными блокировками, а сам колбэк источника сериализован — именно поэтому адаптер базы данных или объектного хранилища без внутренней защиты потоков можно использовать как есть. Ожидающие получают собственную копию данных, поэтому более позднее вытеснение LRU не может сделать недействительным уже выданный буфер
Можно спросить, готова ли страница 900, не скачивая её?
Да, именно для этого и существует необязательный колбэк доступности. Обычный колбэк чтения не отличает уже прибывшие байты от байтов, требующих блокирующего похода в сеть, а проба пробным чтением спровоцировала бы ту самую загрузку, которую вы пытаетесь избежать. TPDFlibRangeAvailabilityEvent отвечает ровно на один вопрос — можно ли прочитать полный диапазон немедленно, — и ему запрещено что-либо загружать; байты, уже покрытые кэшем, всегда считаются доступными. GetRangeSourceDataAvailability отображает косвенные объекты на физические диапазоны хранения, записанные в записях перекрёстных ссылок, сводит сжатые объекты к их контейнеру объектного потока, корректирует сдвинутый PDF-заголовок и разбирает объект только после того, как полный диапазон пройдёт незагружающий зонд, поэтому путь отсутствия никогда не вызывает ваш колбэк чтения
Обход ограничен по охвату, а не исчерпывающий. Запрос страницы идёт только по ветви дерева страниц, содержащей целевую страницу, затем добавляет содержимое страницы, ресурсы, аннотации и унаследованные атрибуты страницы, пропуская обратные рёбра Parent и P, чтобы отдельная страница или виджет не могли развернуться назад во весь документ. Граф объектов ограничен 100000 запрошенных объектов и глубиной 256, потоковые объекты разбираются сначала по словарю, а запасной полный разбор разрешён лишь для сохранённых объектов до 4 МиБ. JSON-отчёт сливает перекрывающиеся и смежные интервалы перед подсчётом, поэтому requiredBytes и missingBytes вычисляются из слитых массивов requiredRanges и missingRanges, у которых end — включительная конечная точка. Запрос уже доступного объекта может заполнить кэш диапазонов; запрос отсутствующего статистику чтений не трогает
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 ещё не делает страницу доступной. Отсутствующий узел дерева страниц или объектный поток раскрывает следующий слой зависимостей только после прибытия, поэтому задание prefetch в PDFlibPas крутит цикл запрос — загрузка — повторный запрос, пока страница, форма или граф объектов не станут доступными полностью либо пока его не остановит лимит байтов или проходов. Задание использует собственного читателя и небольшой вторичный кэш, чей источник данных перенаправляет абсолютные чтения исходному потоку диапазонов, благодаря чему состояние разбора остаётся изолированным от основного TSmartPDFReader, а по-настоящему скачанные байты всё равно оседают в общем главном кэше. На каждый поток диапазонов приходится один рабочий поток — в соответствии с сериализацией, которую и так требует колбэк источника, — а очередь выбирает по четырём уровням приоритета и затем по порядку подачи внутри уровня. MaxBytes тарифицируется в физических байтах чанков, поэтому парсер, запросивший один байт внутри некэшированного чанка, всё равно платит за весь чанк, тогда как чанки, уже лежащие в общем кэше, заданию не стоят ничего. Отмена задания, стоящего в очереди, приводит к терминальному состоянию с нулём чтений источника; работающее задание проверяется перед каждым проходом зависимостей и каждым чанком источника, а освобождение потока диапазонов ждёт возврата находящегося в полёте колбэка, а не пытается его прервать
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;
Где это вырождается в загрузку файла целиком
Диапазонная загрузка — это ставка на раскладку файла, и некоторые файлы её не оправдывают. Линеаризованный файл по ISO 32000-1 §7.5.8 — хороший случай: секция первой страницы прогревается при открытии, ограниченная и существующим порогом безопасности 4 МиБ, и текущим бюджетом кэша, поэтому прогрев не может немедленно вытеснить большую часть самого себя. Нелинеаризованный файл всё же разрешается через trailer и цепочку перекрёстных ссылок ближе к концу, что стоит пары лишних походов в сеть, а не катастрофы. Настоящий обрыв — повреждённый файл, вынуждающий путь восстановления: реконструкция таблицы перекрёстных ссылок означает сканирование заголовков объектов по всему документу, а это полная загрузка, прибывающая чанк за чанком. Задержка — второй честный предел: при 60 мс на запрос разбор со случайным доступом, которому нужны сорок некэшированных чанков, проводит в пути свыше двух секунд, как бы ни был хорош кэш, — именно ради сокрытия этого существуют опережающее чтение и приоритетная очередь. Та же дисциплина проявляется в подходе с прямым доступом к слиянию и разбиению больших PDF, а этот кэш лежит в основании и параллельного рендеринга страниц, и дискового кэша страниц просмотрщика
API источника диапазонов, запрос доступности и планировщик prefetch входят в стандартную PDFlibPas Delphi PDF Library для Delphi, C++Builder и Free Pascal; на странице продукта приведён полный справочник параметров LoadFromRangeSource вместе с константами приоритета и состояний prefetch