Skip to content

Commit a907827

Browse files
fix: ensure internal_callable pagination takes reverse into consideration
1 parent 7841902 commit a907827

9 files changed

Lines changed: 363 additions & 124 deletions

File tree

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Core/Model.inc

Lines changed: 51 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -918,20 +918,19 @@ class Model {
918918
* the output of the assigned callable.
919919
* @param bool $from_all_parents When set to `true`, this method will obtain internal objects from all parent
920920
* objects in config. This is only applicable to Models with a `parent_model_class` assigned.
921+
* @param int $limit Pagination limit for internal callables (0 = no limit).
922+
* @param int $offset Pagination offset for internal callables (0 = no offset).
923+
* @param bool $reverse Whether to read in reverse order (oldest first) for internal callables.
921924
* @throws ServerError When neither a `config_path` nor a `internal_callable` are assigned to this model, OR both a
922925
* `config_path` and a `internal_callable` are assigned to this model
923926
* @return array The array of internal objects without any additional processing performed.
924927
*/
925-
/**
926-
* @param bool $from_all_parents Whether to obtain objects from all parent Models.
927-
* @param int $limit Pagination hint for internal callables (0 = no limit). When non-zero, callables that accept
928-
* a $limit parameter (e.g. log-backed Models) can use it to avoid loading entire datasets
929-
* into memory. Callables that do not accept a $limit parameter are called without it.
930-
* @param int $offset Pagination hint for internal callables (0 = no offset). When non-zero, callables that accept
931-
* an $offset parameter can use it to skip entries. Callables that do not accept an $offset
932-
* parameter are called without it.
933-
*/
934-
public function get_internal_objects(bool $from_all_parents = false, int $limit = 0, int $offset = 0): array {
928+
public function get_internal_objects(
929+
bool $from_all_parents = false,
930+
int $limit = 0,
931+
int $offset = 0,
932+
bool $reverse = false,
933+
): array {
935934
global $mock_internal_objects;
936935

937936
# Throw an error if both `config_path` and `internal_callable` are set.
@@ -957,17 +956,16 @@ class Model {
957956
elseif ($this->internal_callable) {
958957
$callable = $this->internal_callable;
959958

960-
# Forward the pagination limit and offset to callables that accept them. This allows log-backed
961-
# Models to stop reading early instead of loading entire log files into memory.
962-
# Callables that don't accept these parameters continue to work unchanged.
963-
# Reflection results are cached per class+callable to avoid repeated introspection.
964-
$accepts_limit = ($limit > 0 || $offset > 0) && $this->callable_accepts_param($callable, 'limit');
965-
$accepts_offset = ($limit > 0 || $offset > 0) && $this->callable_accepts_param($callable, 'offset');
966-
967-
if ($accepts_limit && $accepts_offset) {
968-
$internal_objects = $this->$callable(limit: $limit, offset: $offset);
969-
} elseif ($accepts_limit) {
970-
$internal_objects = $this->$callable(limit: $limit);
959+
# Forward pagination parameters to callables that accept ALL THREE parameters (limit, offset, reverse).
960+
# This allows pagination to be handled by callables that may attempt to load huge amounts of data into
961+
# memory. Callables can use these parameters to selectively load data instead of loading everything.
962+
$accepts_limit = $this->callable_accepts_param($callable, 'limit');
963+
$accepts_offset = $this->callable_accepts_param($callable, 'offset');
964+
$accepts_reverse = $this->callable_accepts_param($callable, 'reverse');
965+
$needs_pagination = ($limit > 0 or $offset > 0 or $reverse);
966+
967+
if ($accepts_limit and $accepts_offset and $accepts_reverse and $needs_pagination) {
968+
$internal_objects = $this->$callable(limit: $limit, offset: $offset, reverse: $reverse);
971969
} else {
972970
$internal_objects = $this->$callable();
973971
}
@@ -2003,11 +2001,11 @@ class Model {
20032001
$model_name = self::get_class_fqn();
20042002
$model = new $model_name();
20052003
$model_objects = [];
2006-
$requests_pagination = ($limit or $offset);
2007-
$cache_exempt = ($requests_pagination or $reverse or $model->model_cache_exempt);
2004+
$requests_pagination = ($limit or $offset or $reverse);
2005+
$cache_exempt = ($requests_pagination or $model->model_cache_exempt);
20082006

20092007
# Throw an error if pagination was requested on a Model without $many enabled
2010-
if (!$model->many and $requests_pagination) {
2008+
if (!$model->many and ($limit or $offset)) {
20112009
throw new ValidationError(
20122010
message: "Model `$model->verbose_name` does not support pagination. Please remove the `limit` and/or. " .
20132011
'`offset`parameters and try again.',
@@ -2020,70 +2018,39 @@ class Model {
20202018
return Model::get_model_cache()::fetch_modelset($model_name);
20212019
}
20222020

2023-
# Obtain all of this Model's internally stored objects, including those from parent Models if applicable.
2024-
# Pass the pagination parameters so that callables (e.g. log readers) can stop early.
2025-
# When $reverse is true, the caller wants the oldest entries, so we cannot pre-limit to the newest -
2026-
# the full dataset must be loaded to reverse correctly.
2027-
#
2028-
# For callables that support both limit and offset natively, pass both to allow efficient pagination
2029-
# directly at the data source level. For callables that only support limit, we pass limit+offset as
2030-
# the limit and then apply the offset via array_slice afterward.
2031-
$pagination_limit = $limit > 0 && !$reverse ? $limit : 0;
2032-
$pagination_offset = $offset > 0 && !$reverse ? $offset : 0;
2021+
# Check if the callable supports native pagination (all three parameters: limit, offset, reverse)
20332022
$callable_handled_pagination = false;
2034-
2035-
# Check if the callable supports native offset handling
2036-
if ($model->internal_callable && $pagination_offset > 0) {
2023+
if ($model->internal_callable && $requests_pagination) {
20372024
$callable = $model->internal_callable;
2038-
$callable_handles_offset = $model->callable_accepts_param($callable, 'offset');
2039-
$callable_handles_limit = $model->callable_accepts_param($callable, 'limit');
2040-
2041-
if ($callable_handles_offset && $callable_handles_limit) {
2042-
# Callable handles both - pagination will be done at source level
2043-
$internal_objects = $model->get_internal_objects(
2044-
from_all_parents: true,
2045-
limit: $pagination_limit,
2046-
offset: $pagination_offset,
2047-
);
2025+
$callable_handles_all = $model->callable_accepts_param($callable, 'limit') &&
2026+
$model->callable_accepts_param($callable, 'offset') &&
2027+
$model->callable_accepts_param($callable, 'reverse');
2028+
2029+
if ($callable_handles_all) {
20482030
$callable_handled_pagination = true;
2049-
} elseif ($callable_handles_limit) {
2050-
# Callable only handles limit - pass limit+offset and paginate afterward
2051-
$internal_objects = $model->get_internal_objects(
2052-
from_all_parents: true,
2053-
limit: $pagination_limit + $pagination_offset,
2054-
offset: 0,
2055-
);
2056-
} else {
2057-
# Callable handles neither - read all and paginate afterward
2058-
$internal_objects = $model->get_internal_objects(from_all_parents: true, limit: 0, offset: 0);
2059-
}
2060-
} else {
2061-
# No offset or not using internal_callable - use the standard path
2062-
$internal_objects = $model->get_internal_objects(
2063-
from_all_parents: true,
2064-
limit: $pagination_limit,
2065-
offset: $pagination_offset,
2066-
);
2067-
# If there's no internal_callable or offset is 0, check if we can skip pagination
2068-
if ($model->internal_callable && $pagination_limit > 0) {
2069-
$callable = $model->internal_callable;
2070-
if (
2071-
$model->callable_accepts_param($callable, 'limit') &&
2072-
$model->callable_accepts_param($callable, 'offset')
2073-
) {
2074-
$callable_handled_pagination = true;
2075-
}
20762031
}
20772032
}
20782033

2034+
# Obtain all internal objects, passing pagination params for callables that support them
2035+
$internal_objects = $model->get_internal_objects(
2036+
from_all_parents: true,
2037+
limit: $limit,
2038+
offset: $offset,
2039+
reverse: $reverse,
2040+
);
2041+
20792042
# For non `many` Models, wrap the internal object in an array so we can loop
20802043
$internal_objects = $model->many ? $internal_objects : [$internal_objects];
20812044

2082-
# Reverse the order we read the objects and/or paginate if requested
2083-
# Skip pagination if the callable already handled it natively
2084-
$internal_objects = $reverse ? array_reverse($internal_objects, preserve_keys: true) : $internal_objects;
2085-
if (!$callable_handled_pagination) {
2086-
$internal_objects = self::paginate($internal_objects, $limit, $offset, preserve_keys: true);
2045+
# Apply pagination and/or reverse if the callable did not handle it natively
2046+
if (!$callable_handled_pagination && $requests_pagination) {
2047+
if ($reverse) {
2048+
# Paginate from the beginning (oldest entries), then reverse for display
2049+
$internal_objects = array_reverse($internal_objects, preserve_keys: true);
2050+
$internal_objects = self::paginate($internal_objects, $limit, $offset, preserve_keys: true);
2051+
} else {
2052+
$internal_objects = self::paginate($internal_objects, $limit, $offset, preserve_keys: true);
2053+
}
20872054
}
20882055

20892056
# Loop through each internal object and create a Model object for it
@@ -2095,8 +2062,12 @@ class Model {
20952062
$parent_id = $parent_model ? $parent_model->id : null;
20962063
$internal_object = $parent_model ? $internal_object['_internal_object'] : $internal_object;
20972064

2098-
# Normalize IDs
2065+
# Normalize IDs - when the callable handled pagination natively, the array indices start
2066+
# at 0 but the actual IDs should account for the offset
20992067
$internal_id = is_numeric($internal_id) ? (int) $internal_id : $internal_id;
2068+
if ($callable_handled_pagination && is_int($internal_id)) {
2069+
$internal_id += $offset;
2070+
}
21002071

21012072
# Create a new Model object for this internal object and assign its ID
21022073
$model_object = new $model(id: $internal_id, parent_id: $parent_id, skip_init: true);

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/ModelTraits/LogFileModelTraits.inc

Lines changed: 96 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -317,30 +317,30 @@ trait LogFileModelTraits {
317317
* Reads all available logs for a given base log file path and returns the contents as an array of lines.
318318
* This will include rotated logs, including compressed logs.
319319
*
320-
* When $limit > 0, reads newest log files first and stops as soon as enough lines are collected, avoiding
321-
* loading the entire log history into memory. For the common case of small limits (e.g. ?limit=50), this
322-
* typically only reads the current log file and skips all compressed rotated copies entirely.
320+
* When $limit > 0 and $reverse is false, reads newest log files first and stops as soon as enough lines
321+
* are collected, avoiding loading the entire log history into memory.
323322
*
324-
* When $offset > 0 and $limit > 0, skips the first $offset newest entries before collecting $limit entries.
325-
* This enables efficient pagination through log files without loading the entire dataset.
323+
* When $reverse is true, reads from the oldest entries first (beginning of oldest log files) and
324+
* paginates from there. This is useful for reading logs in chronological order.
326325
*
327326
* When $limit is 0 (default), all log files are read for full backward compatibility.
328327
*
329328
* @note zstd compressed logs are not supported.
330329
* @param string $base_log The base log file path.
331330
* @param int $limit Maximum total lines to return. 0 means unlimited (read everything).
332-
* @param int $offset Number of newest entries to skip before collecting. Only used when $limit > 0.
331+
* @param int $offset Number of entries to skip before collecting.
332+
* @param bool $reverse When true, read from oldest entries first; when false, read from newest first.
333333
* @return array An array of all log file contents for the given base log.
334334
*/
335-
public function read_log(string $base_log, int $limit = 0, int $offset = 0): array {
335+
public function read_log(string $base_log, int $limit = 0, int $offset = 0, bool $reverse = false): array {
336336
# Ensure the base log file exists
337337
$this->check_file_exists($base_log);
338338

339-
# Gather all log file paths (ordered newest-first for bounded reads)
339+
# Gather all log file paths (ordered newest-first)
340340
$log_filepaths = $this->gather_log_filepaths($base_log);
341341

342-
# Unbounded read: preserve existing behavior exactly (read oldest-first, return all)
343-
if ($limit <= 0) {
342+
# Unbounded read: read all entries
343+
if ($limit <= 0 && $offset <= 0) {
344344
$log_contents = [];
345345

346346
# Read oldest-first (reverse of our newest-first ordering)
@@ -364,19 +364,37 @@ trait LogFileModelTraits {
364364
$log_contents = array_filter($log_contents);
365365
$log_contents = array_values($log_contents);
366366

367+
# Reverse if requested (show newest first)
368+
if ($reverse) {
369+
$log_contents = array_reverse($log_contents);
370+
}
371+
367372
# Map the log contents so each entry is an object with a 'text' property
368373
return array_map(fn($line) => ['text' => $line], $log_contents);
369374
}
370375

371-
# Bounded read with offset: collect lines from newest files first, skip $offset entries,
372-
# then collect $limit entries. This is the key optimization - for small limits with offset,
373-
# we typically only read the current log file and skip all compressed rotated copies entirely.
376+
# Handle reverse pagination (reading from oldest entries first)
377+
if ($reverse) {
378+
return $this->read_log_reverse($log_filepaths, $limit, $offset);
379+
}
380+
381+
# Normal pagination: read from newest entries first
382+
return $this->read_log_forward($log_filepaths, $limit, $offset);
383+
}
384+
385+
/**
386+
* Reads log entries starting from the newest, with pagination support.
387+
* @param array $log_filepaths Log file paths ordered newest-first.
388+
* @param int $limit Maximum entries to return.
389+
* @param int $offset Number of newest entries to skip.
390+
* @return array Log entries with 'text' property.
391+
*/
392+
private function read_log_forward(array $log_filepaths, int $limit, int $offset): array {
374393
$collected = [];
375-
$skipped = 0;
376394
$total_needed = $limit + $offset;
377395

378396
foreach ($log_filepaths as $log_filepath) {
379-
$remaining = $total_needed - count($collected) - $skipped;
397+
$remaining = $total_needed - count($collected);
380398
if ($remaining <= 0) {
381399
break;
382400
}
@@ -399,24 +417,16 @@ trait LogFileModelTraits {
399417
}
400418
}
401419

402-
# Apply offset: skip the first $offset entries from the newest (end of array)
403-
# Since $collected is in chronological order (oldest first), we need to:
404-
# 1. Take the newest entries (from the end)
405-
# 2. Skip the first $offset of those
406-
# 3. Return up to $limit entries
420+
# Apply offset: skip the newest $offset entries (from the end)
421+
# $collected is in chronological order (oldest first), newest at end
407422
if (count($collected) > $total_needed) {
408-
# We have more than we need, slice to get exactly what we want
409423
$collected = array_slice($collected, -$total_needed);
410424
}
411425

412-
# Now apply offset by taking entries after skipping the newest $offset entries
413-
# The newest entries are at the end, so we slice from position 0 to (count - offset)
414-
# then take the last $limit of those
415426
if ($offset > 0 && count($collected) > $offset) {
416427
# Remove the newest $offset entries (from the end)
417428
$collected = array_slice($collected, 0, -$offset);
418429
} elseif ($offset > 0) {
419-
# Offset is larger than what we have, return empty
420430
$collected = [];
421431
}
422432

@@ -429,4 +439,65 @@ trait LogFileModelTraits {
429439
$collected = array_values(array_filter($collected));
430440
return array_map(fn($line) => ['text' => $line], $collected);
431441
}
442+
443+
/**
444+
* Reads log entries starting from the oldest, with pagination support.
445+
* Returns entries in reverse chronological order (newest first within the page).
446+
* @param array $log_filepaths Log file paths ordered newest-first.
447+
* @param int $limit Maximum entries to return.
448+
* @param int $offset Number of oldest entries to skip.
449+
* @return array Log entries with 'text' property, in reverse chronological order.
450+
*/
451+
private function read_log_reverse(array $log_filepaths, int $limit, int $offset): array {
452+
$collected = [];
453+
$total_needed = $limit + $offset;
454+
$skipped = 0;
455+
456+
# Read oldest-first (reverse of newest-first ordering)
457+
foreach (array_reverse($log_filepaths) as $log_filepath) {
458+
if (count($collected) >= $total_needed) {
459+
break;
460+
}
461+
462+
$type = $this->get_log_type($log_filepath);
463+
464+
# Read the entire file (we need to read from the beginning for oldest-first)
465+
$lines = match ($type) {
466+
'bz2' => $this->read_bzip2_log($log_filepath),
467+
'gz' => $this->read_gzip_log($log_filepath),
468+
'xz' => $this->read_xz_log($log_filepath),
469+
'log' => $this->read_uncompressed_log($log_filepath),
470+
default => throw new NotAcceptableError(
471+
message: "The log file at $log_filepath has an unsupported file extension.",
472+
response_id: 'LOG_FILE_TRAITS_UNSUPPORTED_LOG_FILE_EXTENSION',
473+
),
474+
};
475+
476+
# Filter empty lines
477+
$lines = array_filter($lines, fn($line) => $line !== '');
478+
479+
foreach ($lines as $line) {
480+
# Skip the first $offset entries
481+
if ($skipped < $offset) {
482+
$skipped++;
483+
continue;
484+
}
485+
486+
# Collect until we have $limit entries
487+
if (count($collected) < $limit) {
488+
$collected[] = $line;
489+
}
490+
491+
if (count($collected) >= $limit) {
492+
break 2;
493+
}
494+
}
495+
}
496+
497+
# Reverse the collected entries so newest (within the page) comes first
498+
$collected = array_reverse($collected);
499+
500+
# Wrap in ['text' => ...] format
501+
return array_map(fn($line) => ['text' => $line], $collected);
502+
}
432503
}

pfSense-pkg-RESTAPI/files/usr/local/pkg/RESTAPI/Models/AuthLog.inc

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ class AuthLog extends Model {
3232
/**
3333
* Obtains the auth log as an array. This method is the internal callable for this Model.
3434
* @param int $limit Maximum entries to return (0 = all). Passed through from pagination.
35-
* @param int $offset Number of newest entries to skip before collecting. Passed through from pagination.
35+
* @param int $offset Number of entries to skip. Passed through from pagination.
36+
* @param bool $reverse Whether to read from oldest entries first.
3637
* @return array The auth log as an array of objects.
3738
*/
38-
protected function get_auth_log(int $limit = 0, int $offset = 0): array {
39-
return $this->read_log($this->log_file, $limit, $offset);
39+
protected function get_auth_log(int $limit = 0, int $offset = 0, bool $reverse = false): array {
40+
return $this->read_log($this->log_file, $limit, $offset, $reverse);
4041
}
4142
}

0 commit comments

Comments
 (0)