From 368db46073e34fee528067488c95e6594249f24b Mon Sep 17 00:00:00 2001 From: Peter Sistrom Date: Fri, 3 Jul 2026 09:29:50 +1000 Subject: [PATCH 1/3] Issue #752: Migrate all the location status to bits --- .../candidates/recoverer_candidates.php | 2 +- .../local/report/location_report_builder.php | 2 +- .../report/object_location_history_table.php | 10 ++-- .../report/object_status_history_table.php | 6 +- classes/local/store/object_file_system.php | 34 ++++++----- classes/local/store/s3/file_system.php | 2 +- classes/log/aggregate_logger.php | 11 ++-- classes/tests/testcase.php | 4 +- db/install.xml | 2 +- db/upgrade.php | 29 ++++++++++ lang/en/tool_objectfs.php | 1 + lib.php | 58 ++++++++++++++----- missing_files.php | 2 +- .../local/object_manipulator/checker_test.php | 2 +- .../object_manipulator/recoverer_test.php | 10 ++-- tests/object_file_system_test.php | 12 ++-- version.php | 4 +- 17 files changed, 125 insertions(+), 66 deletions(-) diff --git a/classes/local/object_manipulator/candidates/recoverer_candidates.php b/classes/local/object_manipulator/candidates/recoverer_candidates.php index 9e29c868..525e71f5 100644 --- a/classes/local/object_manipulator/candidates/recoverer_candidates.php +++ b/classes/local/object_manipulator/candidates/recoverer_candidates.php @@ -50,6 +50,6 @@ public function get_candidates_sql() { * @return array */ public function get_candidates_sql_params() { - return ['location' => OBJECT_LOCATION_ERROR]; + return ['location' => OBJECT_LOCATION_MISSING]; } } diff --git a/classes/local/report/location_report_builder.php b/classes/local/report/location_report_builder.php index 99cb4da4..e33dd892 100644 --- a/classes/local/report/location_report_builder.php +++ b/classes/local/report/location_report_builder.php @@ -46,7 +46,7 @@ public function build_report($reportid) { OBJECT_LOCATION_DUPLICATED, OBJECT_LOCATION_EXTERNAL, OBJECT_LOCATION_ORPHANED, - OBJECT_LOCATION_ERROR, + OBJECT_LOCATION_MISSING, ]; $totalcount = 0; diff --git a/classes/local/report/object_location_history_table.php b/classes/local/report/object_location_history_table.php index faee6163..cbcfac70 100644 --- a/classes/local/report/object_location_history_table.php +++ b/classes/local/report/object_location_history_table.php @@ -136,7 +136,7 @@ public function query_db($pagesize, $useinitialsbar = true) { $duplicatedrecord = $rawrecords[$id . OBJECT_LOCATION_DUPLICATED] ?? $emptyrecord; $orphanedrecord = $rawrecords[$id . OBJECT_LOCATION_ORPHANED] ?? $emptyrecord; $externalrecord = $rawrecords[$id . OBJECT_LOCATION_EXTERNAL] ?? $emptyrecord; - $errorrecord = $rawrecords[$id . OBJECT_LOCATION_ERROR] ?? $emptyrecord; + $missingrecord = $rawrecords[$id . OBJECT_LOCATION_MISSING] ?? $emptyrecord; $filedir = $rawrecords[$id . 'filedir'] ?? $emptyrecord; $total = $rawrecords[$id . 'total'] ?? $emptyrecord; @@ -154,8 +154,8 @@ public function query_db($pagesize, $useinitialsbar = true) { $row['orphaned_size'] = get_string('object_status:location:orphanedsizeunknown', 'tool_objectfs'); $row['external_count'] = $externalrecord->count; $row['external_size'] = $externalrecord->size; - $row['missing_count'] = $errorrecord->count; - $row['missing_size'] = $errorrecord->size; + $row['missing_count'] = $missingrecord->count; + $row['missing_size'] = $missingrecord->size; $row['total_count'] = $total->count; $row['total_size'] = $total->size; $row['filedir_count'] = $filedir->count; @@ -167,7 +167,7 @@ public function query_db($pagesize, $useinitialsbar = true) { $row['duplicated_count'] = number_format($duplicatedrecord->count); $row['orphaned_count'] = number_format($orphanedrecord->count); $row['external_count'] = number_format($externalrecord->count); - $row['missing_count'] = number_format($errorrecord->count); + $row['missing_count'] = number_format($missingrecord->count); $row['total_count'] = number_format($total->count); $row['filedir_count'] = number_format($filedir->count); $row['delta_count'] = number_format($deltacount); @@ -176,7 +176,7 @@ public function query_db($pagesize, $useinitialsbar = true) { $this->duplicatedsizes[] = $this->size_to_mb($duplicatedrecord->size); $this->orphanedsizes[] = $this->size_to_mb($orphanedrecord->size); $this->externalsizes[] = $this->size_to_mb($externalrecord->size); - $this->missingsizes[] = $this->size_to_mb($errorrecord->size); + $this->missingsizes[] = $this->size_to_mb($missingrecord->size); $this->totalsizes[] = $this->size_to_mb($total->size); $this->filedirsizes[] = $this->size_to_mb($filedir->size); $this->deltasizes[] = $this->size_to_mb($deltasize); diff --git a/classes/local/report/object_status_history_table.php b/classes/local/report/object_status_history_table.php index f037e294..c31d6606 100644 --- a/classes/local/report/object_status_history_table.php +++ b/classes/local/report/object_status_history_table.php @@ -240,11 +240,11 @@ public function get_file_location_string($filelocation) { 'filedir' => 'object_status:filedir', 'deltaa' => 'object_status:delta:a', 'deltab' => 'object_status:delta:b', - OBJECT_LOCATION_ERROR => 'object_status:location:error', + OBJECT_LOCATION_MISSING => 'object_status:location:missing', + OBJECT_LOCATION_ORPHANED => 'object_status:location:orphaned', OBJECT_LOCATION_LOCAL => 'object_status:location:local', OBJECT_LOCATION_DUPLICATED => 'object_status:location:duplicated', OBJECT_LOCATION_EXTERNAL => 'object_status:location:external', - OBJECT_LOCATION_ORPHANED => 'object_status:location:orphaned', ]; if (isset($locationstringmap[$filelocation])) { return get_string($locationstringmap[$filelocation], 'tool_objectfs'); @@ -262,7 +262,7 @@ public function get_file_location_class($filelocation): string { switch ($filelocation) { case 'deltaa': case 'deltab': - case OBJECT_LOCATION_ERROR: + case OBJECT_LOCATION_MISSING: case OBJECT_LOCATION_ORPHANED: $class = 'table-danger'; break; diff --git a/classes/local/store/object_file_system.php b/classes/local/store/object_file_system.php index d7df5b93..8b566b1b 100644 --- a/classes/local/store/object_file_system.php +++ b/classes/local/store/object_file_system.php @@ -310,20 +310,21 @@ public function is_file_readable_externally_by_hash($contenthash) { * @return int */ public function get_object_location_from_hash($contenthash) { - $localreadable = $this->is_file_readable_locally_by_hash($contenthash); - $externalreadable = $this->is_file_readable_externally_by_hash($contenthash); - - if ($localreadable && $externalreadable) { - return OBJECT_LOCATION_DUPLICATED; - } else if ($localreadable && !$externalreadable) { - return OBJECT_LOCATION_LOCAL; - } else if (!$localreadable && $externalreadable) { - return OBJECT_LOCATION_EXTERNAL; - } else { - // Object is not anywhere - we toggle an error state in the DB. - manager::update_object_by_hash($contenthash, OBJECT_LOCATION_ERROR); - return OBJECT_LOCATION_ERROR; + // The mdl_files bit is always set because this function is only called + // for objects known to be referenced in the files table. + $location = OBJECT_LOCATION_IN_MDL_FILES; + if ($this->is_file_readable_locally_by_hash($contenthash)) { + $location |= OBJECT_LOCATION_IN_FILEDIR; + } + if ($this->is_file_readable_externally_by_hash($contenthash)) { + $location |= OBJECT_LOCATION_IN_REMOTE; + } + + if ($location === OBJECT_LOCATION_MISSING) { + // Object exists in mdl_files but is not anywhere physically — record missing state. + manager::update_object_by_hash($contenthash, OBJECT_LOCATION_MISSING); } + return $location; } /** @@ -487,7 +488,7 @@ public function readfile(\stored_file $file) { $this->logger->log_object_read('readfile', $path, $file->get_filesize()); if ($success === false) { - manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_ERROR); + manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_MISSING); } } } @@ -517,7 +518,7 @@ public function get_content(\stored_file $file) { $this->logger->log_object_read('file_get_contents', $path, $file->get_filesize()); if (!$contents) { - manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_ERROR); + manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_MISSING); } return $contents; @@ -627,7 +628,7 @@ public function get_content_file_handle(\stored_file $file, $type = \stored_file $this->logger->log_object_read('get_file_handle_for_path', $path, $file->get_filesize()); if (!$filehandle) { - manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_ERROR); + manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_MISSING); } return $filehandle; @@ -764,6 +765,7 @@ public function delete_object_from_hash($contenthash) { $this->delete_external_file_from_hash($contenthash); break; + case OBJECT_LOCATION_MISSING: case OBJECT_LOCATION_ERROR: default: return; diff --git a/classes/local/store/s3/file_system.php b/classes/local/store/s3/file_system.php index 029f66fd..8787d518 100644 --- a/classes/local/store/s3/file_system.php +++ b/classes/local/store/s3/file_system.php @@ -71,7 +71,7 @@ public function readfile(\stored_file $file) { $this->get_logger()->log_object_read('readfile', $path, $file->get_filesize()); if ($success === false) { - manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_ERROR); + manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_MISSING); throw new \file_exception('storedfilecannotreadfile', $file->get_filename()); } } diff --git a/classes/log/aggregate_logger.php b/classes/log/aggregate_logger.php index 406ea584..63694e66 100644 --- a/classes/log/aggregate_logger.php +++ b/classes/log/aggregate_logger.php @@ -64,7 +64,8 @@ class aggregate_logger extends objectfs_logger { public function __construct() { parent::__construct(); $this->movestatistics = [ - OBJECT_LOCATION_ERROR => [], + OBJECT_LOCATION_MISSING => [], + OBJECT_LOCATION_ORPHANED => [], OBJECT_LOCATION_LOCAL => [], OBJECT_LOCATION_DUPLICATED => [], OBJECT_LOCATION_EXTERNAL => [], @@ -157,16 +158,16 @@ protected function output_move_statistic($movestatistic, $initiallocation, $fina */ public function location_to_string($location) { switch ($location) { - case OBJECT_LOCATION_ERROR: - return 'error'; + case OBJECT_LOCATION_MISSING: + return 'missing'; + case OBJECT_LOCATION_ORPHANED: + return 'orphaned'; case OBJECT_LOCATION_LOCAL: return 'local'; case OBJECT_LOCATION_DUPLICATED: return 'duplicated'; case OBJECT_LOCATION_EXTERNAL: return 'remote'; - case OBJECT_LOCATION_ORPHANED: - return 'orphaned'; default: return $location; } diff --git a/classes/tests/testcase.php b/classes/tests/testcase.php index 9384e120..df6f92da 100644 --- a/classes/tests/testcase.php +++ b/classes/tests/testcase.php @@ -180,7 +180,7 @@ protected function create_error_file() { $file = $this->create_local_file(); $path = $this->get_local_path_from_storedfile($file); unlink($path); - manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_ERROR); + manager::update_object_by_hash($file->get_contenthash(), OBJECT_LOCATION_MISSING); return $file; } @@ -311,7 +311,7 @@ protected function create_remote_object($content = 'remote object content') { */ protected function create_error_object($content = 'error object content') { $file = $this->create_error_file($content); - return $this->create_object_record($file, OBJECT_LOCATION_ERROR); + return $this->create_object_record($file, OBJECT_LOCATION_MISSING); } /** diff --git a/db/install.xml b/db/install.xml index 0065b1d7..7afa27ce 100644 --- a/db/install.xml +++ b/db/install.xml @@ -9,7 +9,7 @@ - + diff --git a/db/upgrade.php b/db/upgrade.php index bf06b09c..640ce8ab 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -220,5 +220,34 @@ function xmldb_tool_objectfs_upgrade($oldversion) { upgrade_plugin_savepoint(true, 2024120600, 'tool', 'objectfs'); } + if ($oldversion < 2026041007) { + // Migrate OBJECT_LOCATION_* constants from sequential integers to bit flags. + // Old values: ORPHANED=-2, ERROR(missing)=-1, LOCAL=0, DUPLICATED=1, EXTERNAL=2 + // New values: ERROR=0, ORPHANED=1, MISSING=2, LOCAL=3, EXTERNAL=6, DUPLICATED=7 + // + // Processing in an order that avoids value collisions (highest old value first). + + // Widen the location column to match install.xml (LENGTH 1 -> 3) before migrating values. + $table = new xmldb_table('tool_objectfs_objects'); + $field = new xmldb_field('location', XMLDB_TYPE_INTEGER, '3', null, XMLDB_NOTNULL, null, null, 'timeduplicated'); + $dbman->change_field_precision($table, $field); + + // tool_objectfs_objects.location column. + $DB->execute('UPDATE {tool_objectfs_objects} SET location = 6 WHERE location = 2'); + $DB->execute('UPDATE {tool_objectfs_objects} SET location = 7 WHERE location = 1'); + $DB->execute('UPDATE {tool_objectfs_objects} SET location = 3 WHERE location = 0'); + $DB->execute('UPDATE {tool_objectfs_objects} SET location = 2 WHERE location = -1'); + $DB->execute('UPDATE {tool_objectfs_objects} SET location = 1 WHERE location = -2'); + + // tool_objectfs_report_data.datakey stores location values as strings for the 'location' report type. + $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '6' WHERE reporttype = 'location' AND datakey = '2'"); + $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '7' WHERE reporttype = 'location' AND datakey = '1'"); + $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '3' WHERE reporttype = 'location' AND datakey = '0'"); + $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '2' WHERE reporttype = 'location' AND datakey = '-1'"); + $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '1' WHERE reporttype = 'location' AND datakey = '-2'"); + + upgrade_plugin_savepoint(true, 2026041007, 'tool', 'objectfs'); + } + return true; } diff --git a/lang/en/tool_objectfs.php b/lang/en/tool_objectfs.php index b1884d74..816667d2 100644 --- a/lang/en/tool_objectfs.php +++ b/lang/en/tool_objectfs.php @@ -72,6 +72,7 @@ $string['object_status:location:duplicatedsize'] = 'Duplicated (size)'; $string['object_status:location:duplicatedsizechart'] = 'Duplicated (MB)'; $string['object_status:location:error'] = 'Missing from filedir and external storage (view files)'; +$string['object_status:location:missing'] = 'Missing from filedir and external storage (view files)'; $string['object_status:location:external'] = 'Only in external storage'; $string['object_status:location:externalcount'] = 'External (count)'; $string['object_status:location:externalsize'] = 'External (size)'; diff --git a/lib.php b/lib.php index 870e6f32..24dc7a69 100644 --- a/lib.php +++ b/lib.php @@ -29,36 +29,62 @@ define('OBJECTFS_PLUGIN_NAME', 'tool_objectfs'); /** - * Location enum of the object - * ORPHANED is when the {objectfs_objects} table contains a record linking to a - * moodle {files} record which is no longer present. + * Location bit flag: the object exists in the local filedir. + * Bit 1. */ -define('OBJECT_LOCATION_ORPHANED', -2); +define('OBJECT_LOCATION_IN_FILEDIR', 1); /** - * Location enum of the object - * ERROR is when the file is missing when it is expected to be there. + * Location bit flag: the object is referenced in the Moodle files table (mdl_files). + * Bit 2. + */ +define('OBJECT_LOCATION_IN_MDL_FILES', 2); + +/** + * Location bit flag: the object exists in the primary remote object store. + * Bit 4. + */ +define('OBJECT_LOCATION_IN_REMOTE', 4); + +/** + * Location of the object: completely absent — no bits set. + * This is an invalid state that should not normally be stored in the database. + * Value: 0. + */ +define('OBJECT_LOCATION_ERROR', 0); + +/** + * Location of the object: in filedir only, no mdl_files reference. + * This is a trashdir candidate marked for cleanup. + * Value: OBJECT_LOCATION_IN_FILEDIR = 1. + */ +define('OBJECT_LOCATION_ORPHANED', OBJECT_LOCATION_IN_FILEDIR); + +/** + * Location of the object: referenced in mdl_files but not present in filedir or any remote store. + * This is a missing-file error state. + * Value: OBJECT_LOCATION_IN_MDL_FILES = 2. * @see tests/object_file_system_test.php for examples. */ -define('OBJECT_LOCATION_ERROR', -1); +define('OBJECT_LOCATION_MISSING', OBJECT_LOCATION_IN_MDL_FILES); /** - * Location enum of the object - * LOCAL is when the object exists locally only. + * Location of the object: in filedir and mdl_files, not yet pushed to any remote store. + * Value: OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_MDL_FILES = 3. */ -define('OBJECT_LOCATION_LOCAL', 0); +define('OBJECT_LOCATION_LOCAL', OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_MDL_FILES); /** - * Location enum of the object - * DUPLICATED is when the object exists both locally, and remotely. + * Location of the object: in mdl_files and the primary remote store, no local filedir copy. + * Value: OBJECT_LOCATION_IN_MDL_FILES | OBJECT_LOCATION_IN_REMOTE = 6. */ -define('OBJECT_LOCATION_DUPLICATED', 1); +define('OBJECT_LOCATION_EXTERNAL', OBJECT_LOCATION_IN_MDL_FILES | OBJECT_LOCATION_IN_REMOTE); /** - * Location enum of the object - * EXTERNAL is when when the object lives remotely only. + * Location of the object: in filedir, mdl_files, and the primary remote store. + * Value: OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_MDL_FILES | OBJECT_LOCATION_IN_REMOTE = 7. */ -define('OBJECT_LOCATION_EXTERNAL', 2); +define('OBJECT_LOCATION_DUPLICATED', OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_MDL_FILES | OBJECT_LOCATION_IN_REMOTE); define('OBJECTFS_REPORT_OBJECT_LOCATION', 0); define('OBJECTFS_REPORT_LOG_SIZE', 1); diff --git a/missing_files.php b/missing_files.php index 1c22ff90..0436add3 100644 --- a/missing_files.php +++ b/missing_files.php @@ -37,7 +37,7 @@ $PAGE->set_context(context_system::instance()); $PAGE->set_cacheable(false); $output = $PAGE->get_renderer('tool_objectfs'); -$table = new files_table('missing-files', OBJECT_LOCATION_ERROR); +$table = new files_table('missing-files', OBJECT_LOCATION_MISSING); $table->define_baseurl('/admin/tool/objectfs/missing_files.php'); if ($table->is_downloading($download, get_string('filename:missingfiles', 'tool_objectfs'))) { diff --git a/tests/local/object_manipulator/checker_test.php b/tests/local/object_manipulator/checker_test.php index 3ec59e18..4bbee860 100644 --- a/tests/local/object_manipulator/checker_test.php +++ b/tests/local/object_manipulator/checker_test.php @@ -127,6 +127,6 @@ public function test_checker_manipulate_object_method_will_get_error_location_on $file = $this->create_error_object(); $reflection = new \ReflectionMethod(checker::class, "manipulate_object"); $reflection->setAccessible(true); - $this->assertEquals(OBJECT_LOCATION_ERROR, $reflection->invokeArgs($this->checker, [$file])); + $this->assertEquals(OBJECT_LOCATION_MISSING, $reflection->invokeArgs($this->checker, [$file])); } } diff --git a/tests/local/object_manipulator/recoverer_test.php b/tests/local/object_manipulator/recoverer_test.php index 03abfce0..554769a2 100644 --- a/tests/local/object_manipulator/recoverer_test.php +++ b/tests/local/object_manipulator/recoverer_test.php @@ -61,7 +61,7 @@ public function test_recoverer_get_candidate_objects_will_get_error_objects(): v public function test_recoverer_will_recover_local_objects(): void { global $DB; $object = $this->create_local_object(); - $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_ERROR, ['contenthash' => $object->contenthash]); + $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_MISSING, ['contenthash' => $object->contenthash]); $this->recoverer->execute([$object]); @@ -72,7 +72,7 @@ public function test_recoverer_will_recover_local_objects(): void { public function test_recoverer_will_recover_duplicated_objects(): void { global $DB; $object = $this->create_duplicated_object(); - $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_ERROR, ['contenthash' => $object->contenthash]); + $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_MISSING, ['contenthash' => $object->contenthash]); $this->recoverer->execute([$object]); @@ -83,7 +83,7 @@ public function test_recoverer_will_recover_duplicated_objects(): void { public function test_recoverer_will_recover_remote_objects(): void { global $DB; $object = $this->create_remote_object(); - $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_ERROR, ['contenthash' => $object->contenthash]); + $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_MISSING, ['contenthash' => $object->contenthash]); $this->recoverer->execute([$object]); @@ -94,11 +94,11 @@ public function test_recoverer_will_recover_remote_objects(): void { public function test_recoverer_will_not_recover_error_objects(): void { global $DB; $object = $this->create_error_object(); - $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_ERROR, ['contenthash' => $object->contenthash]); + $DB->set_field('tool_objectfs_objects', 'location', OBJECT_LOCATION_MISSING, ['contenthash' => $object->contenthash]); $this->recoverer->execute([$object]); $location = $DB->get_field('tool_objectfs_objects', 'location', ['contenthash' => $object->contenthash]); - $this->assertEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertEquals(OBJECT_LOCATION_MISSING, $location); } } diff --git a/tests/object_file_system_test.php b/tests/object_file_system_test.php index 48d49db8..30a893bc 100644 --- a/tests/object_file_system_test.php +++ b/tests/object_file_system_test.php @@ -162,7 +162,7 @@ public function test_copy_object_from_external_to_local_by_hash_if_not_local_and $location = $this->filesystem->copy_object_from_external_to_local_by_hash($fakehash); - $this->assertEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertEquals(OBJECT_LOCATION_MISSING, $location); } public function test_copy_object_from_local_to_external_by_hash(): void { @@ -199,7 +199,7 @@ public function test_copy_object_from_local_to_external_by_hash_if_not_local_and $location = $this->filesystem->copy_object_from_local_to_external_by_hash($fakehash); - $this->assertEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertEquals(OBJECT_LOCATION_MISSING, $location); } public function test_delete_object_from_local_by_hash(): void { @@ -229,7 +229,7 @@ public function test_delete_object_from_local_by_hash_if_not_local(): void { $location = $this->filesystem->delete_object_from_local_by_hash($fakehash); - $this->assertEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertEquals(OBJECT_LOCATION_MISSING, $location); } public function test_delete_object_from_local_by_hash_if_can_verify_external_object(): void { @@ -322,7 +322,7 @@ public function test_readfile_updates_object_with_error_location_on_fail(): void restore_error_handler(); $location = $DB->get_field('tool_objectfs_objects', 'location', ['contenthash' => $fakefile->get_contenthash()]); - $this->assertEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertEquals(OBJECT_LOCATION_MISSING, $location); } public function test_get_content_if_object_is_local(): void { @@ -354,7 +354,7 @@ public function test_get_content_updates_object_with_error_location_on_fail(): v restore_error_handler(); $location = $DB->get_field('tool_objectfs_objects', 'location', ['contenthash' => $fakefile->get_contenthash()]); - $this->assertEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertEquals(OBJECT_LOCATION_MISSING, $location); } /** @@ -427,7 +427,7 @@ public function test_get_content_file_handle_does_not_set_error_location_on_tran } $location = $DB->get_field('tool_objectfs_objects', 'location', ['contenthash' => $file->get_contenthash()]); - $this->assertNotEquals(OBJECT_LOCATION_ERROR, $location); + $this->assertNotEquals(OBJECT_LOCATION_MISSING, $location); } public function test_remove_file_will_remove_local_file(): void { diff --git a/version.php b/version.php index 3f1c91e1..4ad26fc3 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2026041006; // The current plugin version (Date: YYYYMMDDXX). -$plugin->release = 2026041006; // Same as version. +$plugin->version = 2026041007; // The current plugin version (Date: YYYYMMDDXX). +$plugin->release = 2026041007; // Same as version. $plugin->requires = 2024042200; // Requires 4.4. $plugin->component = "tool_objectfs"; $plugin->maturity = MATURITY_STABLE; From 2b67036e2656faf90db4fa3fe15f93bdf1a4f8b8 Mon Sep 17 00:00:00 2001 From: Peter Sistrom Date: Mon, 20 Jul 2026 15:09:39 +1000 Subject: [PATCH 2/3] Issue #752: Pass known locations bitmask to get_object_location_from_hash() --- .../local/object_manipulator/recoverer.php | 4 +++- classes/local/store/object_file_system.php | 24 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/classes/local/object_manipulator/recoverer.php b/classes/local/object_manipulator/recoverer.php index cceb6a59..93c2d2fe 100644 --- a/classes/local/object_manipulator/recoverer.php +++ b/classes/local/object_manipulator/recoverer.php @@ -37,6 +37,8 @@ class recoverer extends manipulator { * @return int */ public function manipulate_object(stdClass $objectrecord) { - return $this->filesystem->get_object_location_from_hash($objectrecord->contenthash); + // The recoverer only knows the object is in the objectfs table, not necessarily + // in mdl_files, so pass 0 to check all location bits fresh. + return $this->filesystem->get_object_location_from_hash($objectrecord->contenthash, 0); } } diff --git a/classes/local/store/object_file_system.php b/classes/local/store/object_file_system.php index 8b566b1b..feb959e8 100644 --- a/classes/local/store/object_file_system.php +++ b/classes/local/store/object_file_system.php @@ -306,17 +306,27 @@ public function is_file_readable_externally_by_hash($contenthash) { /** * get_object_location_from_hash * @param mixed $contenthash - * + * @param int $knownlocations Bitmask of locations already known to be present without re-checking. + * Defaults to OBJECT_LOCATION_IN_MDL_FILES since most callers reach + * this function via a stored_file or mdl_files reference. + * Pass 0 when the caller only knows the object is in the objectfs + * table (e.g. the recoverer), so all bits are checked fresh. * @return int */ - public function get_object_location_from_hash($contenthash) { - // The mdl_files bit is always set because this function is only called - // for objects known to be referenced in the files table. - $location = OBJECT_LOCATION_IN_MDL_FILES; - if ($this->is_file_readable_locally_by_hash($contenthash)) { + public function get_object_location_from_hash($contenthash, $knownlocations = OBJECT_LOCATION_IN_MDL_FILES) { + $location = $knownlocations; + + // Only check each location if not already known — avoids redundant DB/filesystem calls. + if (!($location & OBJECT_LOCATION_IN_MDL_FILES)) { + global $DB; + if ($DB->record_exists('files', ['contenthash' => $contenthash])) { + $location |= OBJECT_LOCATION_IN_MDL_FILES; + } + } + if (!($location & OBJECT_LOCATION_IN_FILEDIR) && $this->is_file_readable_locally_by_hash($contenthash)) { $location |= OBJECT_LOCATION_IN_FILEDIR; } - if ($this->is_file_readable_externally_by_hash($contenthash)) { + if (!($location & OBJECT_LOCATION_IN_REMOTE) && $this->is_file_readable_externally_by_hash($contenthash)) { $location |= OBJECT_LOCATION_IN_REMOTE; } From fc09afebf058bb912eb308e9042d0fab66eb30cd Mon Sep 17 00:00:00 2001 From: Peter Sistrom Date: Tue, 21 Jul 2026 13:21:09 +1000 Subject: [PATCH 3/3] Replace candidate classes with unified bitmask_candidates --- .../candidates/bitmask_candidates.php | 119 ++++++++++++++++++ .../candidates/candidates_factory.php | 108 ++++++++++++++-- .../candidates/checker_candidates.php | 19 ++- .../candidates/manipulator_candidates.php | 15 +-- .../manipulator_candidates_base.php | 15 --- .../candidates/orphaner_candidates.php | 22 ++-- db/upgrade.php | 4 +- 7 files changed, 233 insertions(+), 69 deletions(-) create mode 100644 classes/local/object_manipulator/candidates/bitmask_candidates.php diff --git a/classes/local/object_manipulator/candidates/bitmask_candidates.php b/classes/local/object_manipulator/candidates/bitmask_candidates.php new file mode 100644 index 00000000..e46a985e --- /dev/null +++ b/classes/local/object_manipulator/candidates/bitmask_candidates.php @@ -0,0 +1,119 @@ +. + +/** + * Unified bitmask-based candidate class for object manipulation. + * + * Replaces separate pusher_candidates, puller_candidates, deleter_candidates, + * and recoverer_candidates with a single parameterized class. + * + * @package tool_objectfs + * @author Catalyst IT + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_objectfs\local\object_manipulator\candidates; + +use stdClass; + +/** + * Universal candidate finder using bitmask filters on the location column. + * + * Accepts two bitmasks: + * - "has" mask: bits the file MUST have (location & has_mask = has_mask) + * - "not" mask: bits the file must NOT have (location & not_mask = 0) + * + * Optional filters for filesize and timeduplicated are applied when provided. + */ +class bitmask_candidates extends manipulator_candidates_base { + /** @var string Query name for logging. */ + protected $queryname; + + /** @var int Bits that must be set in location. */ + private $hasmask; + + /** @var int Bits that must NOT be set in location. */ + private $notmask; + + /** @var array Optional filter options. */ + private $options; + + /** + * Constructor. + * + * @param stdClass $config Plugin config (must include batchsize). + * @param int $hasmask Bits the location MUST have. + * @param int $notmask Bits the location must NOT have. + * @param string $queryname Name for logging. + * @param array $options Optional filters: + * 'threshold' => int — filesize > threshold (minimum file size) + * 'max_filesize' => int — filesize < max_filesize (maximum file size) + * 'size_ceiling' => int — filesize <= size_ceiling (upper file size bound) + * 'maxage' => int — timeduplicated <= maxage (timestamp threshold) + */ + public function __construct(stdClass $config, int $hasmask, int $notmask, string $queryname, array $options = []) { + parent::__construct($config); + $this->hasmask = $hasmask; + $this->notmask = $notmask; + $this->queryname = $queryname; + $this->options = $options; + } + + /** + * Get candidate objects matching the bitmask filters. + * + * @return array + */ + public function get() { + global $DB; + + $conditions = []; + $conditions[] = 'location & :has_mask = :has_mask2'; + $conditions[] = 'location & :not_mask = 0'; + + $params = [ + 'has_mask' => $this->hasmask, + 'has_mask2' => $this->hasmask, + 'not_mask' => $this->notmask, + ]; + + if (isset($this->options['threshold'])) { + $conditions[] = 'filesize > :threshold'; + $params['threshold'] = $this->options['threshold']; + } + if (isset($this->options['max_filesize'])) { + $conditions[] = 'filesize < :max_filesize'; + $params['max_filesize'] = $this->options['max_filesize']; + } + if (isset($this->options['size_ceiling'])) { + $conditions[] = 'filesize <= :size_ceiling'; + $params['size_ceiling'] = $this->options['size_ceiling']; + } + if (isset($this->options['maxage'])) { + $conditions[] = 'timeduplicated <= :maxage'; + $params['maxage'] = $this->options['maxage']; + } + + $where = implode("\n AND ", $conditions); + $sql = "SELECT contenthash, + filesize + FROM {tool_objectfs_objects} + WHERE {$where}"; + + return $DB->get_records_sql($sql, $params, 0, $this->config->batchsize); + } +} diff --git a/classes/local/object_manipulator/candidates/candidates_factory.php b/classes/local/object_manipulator/candidates/candidates_factory.php index f1479aaf..3500e561 100644 --- a/classes/local/object_manipulator/candidates/candidates_factory.php +++ b/classes/local/object_manipulator/candidates/candidates_factory.php @@ -35,31 +35,115 @@ /** * Candidates Factory + * + * Maps manipulator classes to candidate finders. Uses bitmask_candidates for + * manipulators whose candidates are determined by location bitmask filters. */ class candidates_factory { - /** @var array $manipulatormap */ - private static $manipulatormap = [ + /** + * Manipulators that use the legacy class-based mapping (non-bitmask queries). + * @var array + */ + private static $legacymap = [ checker::class => checker_candidates::class, - deleter::class => deleter_candidates::class, - puller::class => puller_candidates::class, - pusher::class => pusher_candidates::class, - recoverer::class => recoverer_candidates::class, orphaner::class => orphaner_candidates::class, ]; /** - * Finder - * @param mixed $manipulator - * @param stdClass $config + * Get the bitmask map for manipulators. + * Must be a method rather than a static property because the OBJECT_LOCATION_* + * constants are defined at runtime via define(). + * + * @return array + */ + private static function get_bitmask_map(): array { + return [ + pusher::class => [ + 'has_mask' => OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_MDL_FILES, // Must be local and referenced. + 'not_mask' => OBJECT_LOCATION_IN_REMOTE, // Must not be in remote. + 'queryname' => 'get_push_candidates', + ], + puller::class => [ + 'has_mask' => OBJECT_LOCATION_IN_MDL_FILES | OBJECT_LOCATION_IN_REMOTE, // Must be referenced and in remote. + 'not_mask' => OBJECT_LOCATION_IN_FILEDIR, // Must not be local. + 'queryname' => 'get_pull_candidates', + ], + deleter::class => [ + 'has_mask' => OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_MDL_FILES | OBJECT_LOCATION_IN_REMOTE, + 'not_mask' => 0, // All bits set, nothing excluded. + 'queryname' => 'get_delete_candidates', + ], + recoverer::class => [ + 'has_mask' => OBJECT_LOCATION_IN_MDL_FILES, // Must be referenced. + 'not_mask' => OBJECT_LOCATION_IN_FILEDIR | OBJECT_LOCATION_IN_REMOTE, // Must not be local or remote. + 'queryname' => 'get_recover_candidates', + ], + ]; + } + + /** + * Create a candidate finder for the given manipulator. * - * @return mixed + * @param string $manipulator Manipulator class name. + * @param stdClass $config Plugin config. + * @return manipulator_candidates * @throws moodle_exception */ public static function finder($manipulator, stdClass $config) { - if (isset(self::$manipulatormap[$manipulator])) { - $classname = self::$manipulatormap[$manipulator]; + // Legacy candidates (checker, orphaner) use non-bitmask SQL. + if (isset(self::$legacymap[$manipulator])) { + $classname = self::$legacymap[$manipulator]; return new $classname($config); } + + // Bitmask-based candidates. + $bitmaskmap = self::get_bitmask_map(); + if (isset($bitmaskmap[$manipulator])) { + $entry = $bitmaskmap[$manipulator]; + $options = self::get_options_for_manipulator($manipulator, $config); + return new bitmask_candidates( + $config, + $entry['has_mask'], + $entry['not_mask'], + $entry['queryname'], + $options + ); + } + throw new moodle_exception('invalidclass', 'error', '', 'Invalid manipulator class'); } + + /** + * Build filter options for a bitmask manipulator based on config. + * + * @param string $manipulator Manipulator class name. + * @param stdClass $config Plugin config. + * @return array + */ + private static function get_options_for_manipulator(string $manipulator, stdClass $config): array { + switch ($manipulator) { + case pusher::class: + $filesystem = new $config->filesystem(); + return [ + 'threshold' => $config->sizethreshold, + 'max_filesize' => $filesystem->get_maximum_upload_filesize(), + 'maxage' => time() - $config->minimumage, + ]; + + case puller::class: + return [ + 'size_ceiling' => $config->sizethreshold, + ]; + + case deleter::class: + return [ + 'threshold' => $config->sizethreshold, + 'maxage' => time() - $config->consistencydelay, + ]; + + case recoverer::class: + default: + return []; + } + } } diff --git a/classes/local/object_manipulator/candidates/checker_candidates.php b/classes/local/object_manipulator/candidates/checker_candidates.php index e52af679..28e0a122 100644 --- a/classes/local/object_manipulator/candidates/checker_candidates.php +++ b/classes/local/object_manipulator/candidates/checker_candidates.php @@ -35,23 +35,18 @@ class checker_candidates extends manipulator_candidates_base { protected $queryname = 'get_check_candidates'; /** - * get_candiates_sql - * @return string + * Get files that exist in {files} but have no tracking row in {tool_objectfs_objects}. + * + * @return array */ - public function get_candidates_sql() { - return 'SELECT f.contenthash + public function get() { + global $DB; + $sql = 'SELECT f.contenthash FROM {files} f LEFT JOIN {tool_objectfs_objects} o ON f.contenthash = o.contenthash WHERE f.filesize > 0 AND o.location is NULL GROUP BY f.contenthash'; - } - - /** - * get_candidates_sql_params - * @return array - */ - public function get_candidates_sql_params() { - return []; + return $DB->get_records_sql($sql, [], 0, $this->config->batchsize); } } diff --git a/classes/local/object_manipulator/candidates/manipulator_candidates.php b/classes/local/object_manipulator/candidates/manipulator_candidates.php index 234ee2cb..679e7e30 100644 --- a/classes/local/object_manipulator/candidates/manipulator_candidates.php +++ b/classes/local/object_manipulator/candidates/manipulator_candidates.php @@ -34,21 +34,8 @@ interface manipulator_candidates { public function get_query_name(); /** - * Returns SQL to retrieve objects for manipulation. + * Get candidate objects for manipulation. * - * @return string - */ - public function get_candidates_sql(); - - /** - * Returns a list of parameters for SQL from get_candidates_sql. - * - * @return array - */ - public function get_candidates_sql_params(); - - /** - * get * @return array * @throws dml_exception */ diff --git a/classes/local/object_manipulator/candidates/manipulator_candidates_base.php b/classes/local/object_manipulator/candidates/manipulator_candidates_base.php index a542217c..87a31819 100644 --- a/classes/local/object_manipulator/candidates/manipulator_candidates_base.php +++ b/classes/local/object_manipulator/candidates/manipulator_candidates_base.php @@ -49,19 +49,4 @@ public function __construct(stdClass $config) { public function get_query_name() { return $this->queryname; } - - /** - * get - * @return array - * @throws dml_exception - */ - public function get() { - global $DB; - return $DB->get_records_sql( - $this->get_candidates_sql(), - $this->get_candidates_sql_params(), - 0, - $this->config->batchsize - ); - } } diff --git a/classes/local/object_manipulator/candidates/orphaner_candidates.php b/classes/local/object_manipulator/candidates/orphaner_candidates.php index e2cd1db4..5622be30 100644 --- a/classes/local/object_manipulator/candidates/orphaner_candidates.php +++ b/classes/local/object_manipulator/candidates/orphaner_candidates.php @@ -35,24 +35,18 @@ class orphaner_candidates extends manipulator_candidates_base { protected $queryname = 'get_orphan_candidates'; /** - * get_candidates_sql - * @return string + * Get tracked objects that no longer have a reference in {files}. + * + * @return array */ - public function get_candidates_sql() { - return 'SELECT o.id, o.contenthash, o.location + public function get() { + global $DB; + $sql = 'SELECT o.id, o.contenthash, o.location FROM {tool_objectfs_objects} o LEFT JOIN {files} f ON o.contenthash = f.contenthash WHERE f.id is null AND o.location != :location'; - } - - /** - * get_candidates_sql_params - * @return array - */ - public function get_candidates_sql_params() { - return [ - 'location' => OBJECT_LOCATION_ORPHANED, - ]; + $params = ['location' => OBJECT_LOCATION_ORPHANED]; + return $DB->get_records_sql($sql, $params, 0, $this->config->batchsize); } } diff --git a/db/upgrade.php b/db/upgrade.php index 640ce8ab..9870d5ce 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -232,14 +232,14 @@ function xmldb_tool_objectfs_upgrade($oldversion) { $field = new xmldb_field('location', XMLDB_TYPE_INTEGER, '3', null, XMLDB_NOTNULL, null, null, 'timeduplicated'); $dbman->change_field_precision($table, $field); - // tool_objectfs_objects.location column. + // Tool_objectfs_objects.location column. $DB->execute('UPDATE {tool_objectfs_objects} SET location = 6 WHERE location = 2'); $DB->execute('UPDATE {tool_objectfs_objects} SET location = 7 WHERE location = 1'); $DB->execute('UPDATE {tool_objectfs_objects} SET location = 3 WHERE location = 0'); $DB->execute('UPDATE {tool_objectfs_objects} SET location = 2 WHERE location = -1'); $DB->execute('UPDATE {tool_objectfs_objects} SET location = 1 WHERE location = -2'); - // tool_objectfs_report_data.datakey stores location values as strings for the 'location' report type. + // The tool_objectfs_report_data.datakey stores location values as strings for the 'location' report type. $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '6' WHERE reporttype = 'location' AND datakey = '2'"); $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '7' WHERE reporttype = 'location' AND datakey = '1'"); $DB->execute("UPDATE {tool_objectfs_report_data} SET datakey = '3' WHERE reporttype = 'location' AND datakey = '0'");