Un archivo escaneado de 2 GB vive en un bucket de S3 y el usuario quiere la página 900. PDFlibPas puede servir esa página sin descargar el archivo: LoadFromRangeSource construye un flujo seekable de solo lectura sobre tu propio callback de rangos de bytes y lo entrega a TPDFDocument, de modo que el parser tira de las tablas de referencias cruzadas, una rama del árbol de páginas y un flujo de contenido
El lado del transporte es viejo y aburrido. Los servidores HTTP llevan décadas anunciando rangos de bytes, hoy especificados en RFC 9110 §14, y todo object store habla el mismo dialecto. El lado PDF está igual de asentado: ISO 32000-1 §7.5.8 define la linearización precisamente para que un lector pueda renderizar la primera página desde el frente del archivo. Lo que faltaba en Delphi es la pieza del medio, la parte que decide qué rangos pedir, cuántos conservar y cómo no pedir dos veces
¿Qué necesita LoadFromRangeSource de tu transporte?
Dos cosas, y ninguna de las dos es un flujo. PDFlibPas pide un SourceSize fiable y un callback de lectura síncrono de tipo TPDFlibRangeReadEvent, declarado como function(Sender: TObject; Offset: Int64; Buffer: Pointer; Count: LongInt): LongInt of object. Internamente la pareja se convierte en un TCallbackByteRangeSource que expone SourceSize y ReadRange, envuelto en un flujo cuya propiedad pasa al documento. Tu destino del callback y su backend siguen siendo tuyos: el documento libera el envoltorio al cerrar, limpiar o recargar, pero nunca toca el objeto de transporte detrás del puntero a método
El contrato es deliberadamente indulgente en una dirección y estricto en la otra. Una lectura corta es legal y simplemente significa que el parser vuelve a pedir. Un callback que lanza una excepción se convierte en lectura corta y converge por la ruta normal de fallo de carga. Un callback que dice haber escrito más de Count bytes se recorta, porque un proveedor con errores no debe poder desbordar el búfer de caché. Los reintentos de contraseña reconstruyen un flujo de rangos nuevo y un estado de parseo nuevo sobre la misma fuente del callback, así que un intento fallido no puede dejar atrás posición, ventana ni estado de descifrado rancios
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
{ un GET bloqueante con 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; { libera el flujo envoltorio }
Src.Free; { tu transporte, tu ciclo de vida }
end;
¿Cuánto retiene de verdad la caché de rangos?
Por defecto 4 MiB, repartidos en ventanas alineadas a chunks y desalojados por LRU. El diseño anterior de ventana única crecía hasta la longitud que pidiera el llamante, así que una gran lectura secuencial podía rebasar el tamaño nominal de chunk mientras que un salto aleatorio tiraba la ventana anterior de inmediato. La caché actual alinea cada offset de origen a ChunkSize, trae exactamente un chunk por fallo y aplica un presupuesto duro de bytes sobre varias ventanas. Cualquier presupuesto explícito que pases se eleva al menos a un chunk completo, de modo que una lectura avanza siempre chunk a chunk y la carga máxima de la caché se mantiene predecible. Un ChunkSize por debajo de 4096 cae al valor por defecto de 64 KiB
La contabilidad de lecturas repetidas es la parte que merece conectar a tu telemetría. PDFlibPas identifica una repetición por el inicio de chunk alineado y conserva intervalos contiguos ordenados, lo que separa una primera traída genuina de una re-traída tras un desalojo sin que la contabilidad crezca linealmente con el tamaño del archivo. GetRangeSourceCacheInfo devuelve el cuadro completo como JSON, SetRangeSourceCacheLimit redimensiona el presupuesto en tiempo de ejecución y ClearRangeSourceCache suelta las ventanas y reinicia las estadísticas juntas. Encoger el presupuesto en tiempo de ejecución conserva el historial y cuenta las liberaciones por presupuesto como desalojos, así que un repeatedReads en ascenso frente a un hits plano es tu señal de que el working set ya no cabe
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;
¿Qué pasa cuando varios hilos quieren el mismo chunk?
Esperan sobre una petición, no sobre varias. Un TStream clásico tiene un único cursor de posición, y dos hilos que bloquean correctamente cada uno por su lado pueden ver esa posición reescrita entre un Seek y un Read, así que los objetos lazy y las lecturas segmentadas de PDFlibPas usan un ReadAt absoluto que nunca mueve el cursor. Cada chunk alineado tiene una única petición en vuelo que comparten todos los llamantes de ese chunk, los chunks adyacentes en cola se fusionan antes de que arranque la lectura del origen, y una lectura física se limita a 16 MiB, de modo que una ráfaga de trabajo de páginas en paralelo no se amplifica ni en peticiones pequeñas duplicadas ni en una absurda gigante. La ventana de fusión es por defecto de 2 ms y solo aplica al primer chunk ausente de cada ReadAt; el Read posicional nunca la espera, y pasar cero elimina por completo el retraso inicial de recogida, algo que importa en escaneos secuenciales largos que acumularían la espera chunk a chunk. La posición, los metadatos de caché y las lecturas del origen viven tras tres bloqueos separados, y el propio callback del origen se serializa, que es lo que permite usar sin cambios un adaptador de base de datos u object store sin protección interna de hilos. Los que esperan reciben su propia copia de los datos, así que un desalojo LRU posterior no puede invalidar un búfer que ya se entregó
¿Se puede preguntar si la página 900 está lista sin traerla?
Sí, y para eso está justo el callback opcional de disponibilidad. Un callback de lectura corriente no distingue los bytes que ya aterrizaron de los que exigen un viaje de ida y vuelta bloqueante, y sondear con una lectura de prueba dispararía la misma descarga que intentas evitar. TPDFlibRangeAvailabilityEvent responde a una sola pregunta, si un rango completo se puede leer de inmediato, y tiene prohibido traer nada; los bytes que la caché ya cubren cuentan siempre como disponibles. GetRangeSourceDataAvailability mapea los objetos indirectos a los rangos de almacenamiento físico registrados en las entradas de referencias cruzadas, resuelve los objetos comprimidos a su contenedor de object stream, corrige una cabecera PDF desplazada y parsea un objeto solo después de que el rango completo pase el sondeo sin traída, de modo que la ruta de lo ausente nunca llama a tu callback de lectura
El recorrido está acotado, no es exhaustivo. Una consulta de página recorre solo la rama del árbol de páginas que contiene la página objetivo y después añade contenido de página, recursos, anotaciones y atributos de página heredados, saltándose las aristas de vuelta Parent y P para que una sola página o widget no pueda expandirse hacia atrás hasta el documento entero. El grafo de objetos se limita a 100000 objetos solicitados y una profundidad de 256, los objetos de flujo se parsean primero por diccionario, y un fallback de parseo completo solo se permite para objetos almacenados de hasta 4 MiB. El informe JSON fusiona los intervalos solapados y adyacentes antes de contar, así que requiredBytes y missingBytes se calculan desde los arrays fusionados requiredRanges y missingRanges, cuyo end es un extremo inclusivo. Consultar un objeto ya disponible puede poblar la caché de rangos; consultar uno ausente deja las estadísticas de lectura intactas
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 lleva "missingBytes" más los "missingRanges" fusionados }
ShowProgress(Report)
else if Status = PDF_RANGE_DATA_NOT_PRESENT then
ShowMissingFeature; { p. ej. el archivo no tiene AcroForm alguno }
end;
Por qué el prefetch tiene que iterar
Porque leer los missingRanges actuales una vez no hace disponible la página. Un nodo del árbol de páginas o un object stream ausente solo revela la siguiente capa de dependencias cuando llega, así que un trabajo de prefetch de PDFlibPas ejecuta un bucle de consulta, traída y re-consulta hasta que la página, el formulario o el grafo de objetos está completamente disponible o un límite de bytes o de pasadas lo detiene. El trabajo usa su propio lector y una pequeña caché secundaria cuya fuente de datos reenvía las lecturas absolutas al flujo de rangos original, lo que mantiene el estado de parseo aislado del TSmartPDFReader de primer plano mientras que los bytes que de verdad descarga siguen cayendo en la caché principal compartida. Hay un hilo de trabajo por cada flujo de rangos, en línea con la serialización que ya exige el callback del origen, y la cola elige por cuatro niveles de prioridad y luego por orden de envío dentro de un nivel. MaxBytes se cobra en bytes de chunk físico, así que un parser que pide un solo byte dentro de un chunk sin cachear paga igualmente el chunk entero, mientras que los chunks ya en la caché compartida no le cuestan nada al trabajo. Cancelar un trabajo en cola alcanza un estado terminal con cero lecturas del origen; un trabajo en marcha se comprueba antes de cada pasada de dependencias y de cada chunk del origen, y liberar el flujo de rangos espera a que un callback en vuelo retorne en vez de intentar interrumpirlo
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" y el último
informe completo de disponibilidad, para que LIMIT_REACHED siga siendo
distinguible de FAILED }
Lib.GetRangeSourcePrefetchInfo(Job, Info);
end;
Dónde esto degrada a una descarga del archivo entero
La carga por rangos es una apuesta a la disposición del archivo, y algunos archivos no la respetan. Un archivo linealizado según ISO 32000-1 §7.5.8 es el caso bueno: la sección de primera página se calienta al abrir, acotada a la vez por el umbral de seguridad de 4 MiB existente y por el presupuesto actual de caché, de modo que el calentamiento no puede desalojar de inmediato la mayor parte de sí mismo. Un archivo no linealizado aún se resuelve por el trailer y la cadena de referencias cruzadas cerca del final, que cuesta un par de viajes extra en vez de un desastre. El verdadero acantilado es un archivo dañado que fuerza la ruta de reparación, porque reconstruir una tabla de referencias cruzadas significa rastrear cabeceras de objeto por todo el documento, y eso es una descarga completa llegando de un chunk en un chunk. La latencia es el otro límite honesto: a 60 ms por petición, un parseo de acceso aleatorio que necesita cuarenta chunks sin cachear pasa más de dos segundos en tránsito sin importar lo buena que sea la caché, que es precisamente lo que el argumento de read-ahead y la cola de prioridad existen para esconder. La misma disciplina aparece en el enfoque de acceso directo para fusionar y dividir PDF grandes, y esta caché se sitúa bajo el renderizado de páginas en paralelo y la caché de páginas en disco del visor por igual
La API de fuente de rangos, la consulta de disponibilidad y el planificador de prefetch forman parte de la PDFlibPas Delphi PDF Library estándar para Delphi, C++Builder y Free Pascal; la página de producto lleva la referencia completa de parámetros de LoadFromRangeSource junto a las constantes de prioridad y estado del prefetch