diff --git a/README.md b/README.md index 534660a..2157657 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,41 @@ logging should be emitted. If not set, logging is automatically-enabled. * Use the boolean flag `$CFG->local_redislock_disable_shared_connection` to force creation of the redis connection for each factory instance. +## Customising the lock identity payload +The value stored alongside each Redis lock is produced by an `identity_provider` service. By default it records the hostname and the current PHP process ID, but you can override the DI binding to record whatever metadata you need (for example an AWS ARN, container identifier). + +Registering an override inside another plugin via **local/my_plugin/classes/hook_callbacks.php**: + +```php +namespace local_my_plugin + +class hook_callbacks { + public static function di_configuration(\core\hook\di_configuration $hook): void { + $hook->add_definition( + \local_redislock\lock\identity_provider::class, + \DI\autowire(\local_yourplugin\lock\arn_identity_provider::class), + ); + } +} +``` + +Because local_redislock registers its DI callback with priority 999, any plugin using the default priority (100) will run afterwards and automatically overwrite the binding without extra effort. + +### Override via config.php +If it's ever needed to restore the default identity provider or change priorities without touching code, hook priorities can be adjusted via **config.php**: + +```php +$CFG->hooks_callback_overrides = [ + \core\hook\di_configuration::class => [ + '\local_redislock\hook_callbacks::di_configuration' => [ + 'priority' => 0, // Ensure redislock runs last, restoring the default identity provider. + ], + ], +]; +``` + +Setting `'disabled' => true` for another plugin's callback will stop it from overriding the provider entirely. + ## License Copyright (c) 2021 Open LMS (https://www.openlms.net) diff --git a/classes/hook_callbacks.php b/classes/hook_callbacks.php new file mode 100644 index 0000000..96a5db0 --- /dev/null +++ b/classes/hook_callbacks.php @@ -0,0 +1,43 @@ +. + +declare(strict_types=1); + +namespace local_redislock; + +use core\hook\di_configuration; +use local_redislock\lock\default_identity_provider; +use local_redislock\lock\identity_provider; + +use function DI\autowire; + +/** + * Hook callbacks for the Redis lock plugin. + * + * @package local_redislock + * @copyright 2025 Cameron Ball + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_callbacks { + /** + * Register DI definitions. + * + * @param di_configuration $hook + */ + public static function di_configuration(di_configuration $hook): void { + $hook->add_definition(identity_provider::class, autowire(default_identity_provider::class)); + } +} diff --git a/classes/lock/default_identity_provider.php b/classes/lock/default_identity_provider.php new file mode 100644 index 0000000..057e7d9 --- /dev/null +++ b/classes/lock/default_identity_provider.php @@ -0,0 +1,37 @@ +. + +declare(strict_types=1); + +namespace local_redislock\lock; + +/** + * Default identity provider for Redis locks. + * + * @package local_redislock + * @copyright 2025 Cameron Ball + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class default_identity_provider implements identity_provider { + /** + * Build default metadata. + * + * @return array Associative array of hostname and process ID. + */ + public function build(): array { + return ['hostname' => gethostname() ?: 'UNKNOWN', 'processid' => (string)getmypid()]; + } +} diff --git a/classes/lock/identity_provider.php b/classes/lock/identity_provider.php new file mode 100644 index 0000000..5467a35 --- /dev/null +++ b/classes/lock/identity_provider.php @@ -0,0 +1,40 @@ +. + +declare(strict_types=1); + +namespace local_redislock\lock; + +/** + * Interface for the metadata stored inside Redis locks. + * + * Implementations must return the same associative array for the lifetime + * of the current process, otherwise releases will fail because the stored value + * will not match. Keys and values in the returned associative array must be strings. + * + * @package local_redislock + * @copyright 2025 Cameron Ball + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface identity_provider { + /** + * Produce the identifying metadata for the current process. + * + * @return array Associative array that will be encoded into the lock value. + * Keys and values must be strings. + */ + public function build(): array; +} diff --git a/classes/lock/redis_lock_factory.php b/classes/lock/redis_lock_factory.php index fcee5e5..b28576c 100644 --- a/classes/lock/redis_lock_factory.php +++ b/classes/lock/redis_lock_factory.php @@ -25,9 +25,12 @@ namespace local_redislock\lock; +use core\di; +use core\exception\coding_exception; use core\lock\lock_factory; use core\lock\lock; use local_redislock\api\shared_redis_connection; +use local_redislock\lock\identity_provider; /** * Redis-backed lock factory class. @@ -79,13 +82,19 @@ class redis_lock_factory implements lock_factory { */ private $auth; + /** + * @var identity_provider Identity provider service. + */ + private identity_provider $identityprovider; + /** * @param string $type The type this lock is used for (e.g. cron, cache). * @param \Redis|null $redis An instance of the PHPRedis extension class. * @param boolean|null $logging Should verbose logs be emitted. + * @param identity_provider|null $identityprovider Identity provider service. * @throws \core\exception\coding_exception */ - public function __construct($type, \Redis $redis = null, $logging = null) { + public function __construct($type, ?\Redis $redis = null, $logging = null, ?identity_provider $identityprovider = null) { global $CFG; $this->type = $type; @@ -103,11 +112,12 @@ public function __construct($type, \Redis $redis = null, $logging = null) { // Logging enabled only for CLI, web gets damaged by lock logs. $logging = (CLI_SCRIPT && debugging() && !PHPUNIT_TEST); if (isset($CFG->local_redislock_logging)) { - $logging = $this->logging && ((bool) $CFG->local_redislock_logging); + $logging = $logging && ((bool) $CFG->local_redislock_logging); } } $this->redis = $redis; $this->logging = $logging; + $this->identityprovider = $identityprovider ?? di::get(identity_provider::class); if (!PHPUNIT_TEST) { \core_shutdown_manager::register_function(array($this, 'auto_release')); @@ -404,7 +414,7 @@ protected function log($message) { } /** - * Returns the hostname or 'UNKNOWN' for use in the lock value. + * Returns the hostname or 'UNKNOWN' for logging purposes. * * @return string */ @@ -418,12 +428,33 @@ protected function get_hostname() { /** * Get the value that should be used for the lock. * - * @return string + * @return string The lock value. + * @throws coding_exception If the identity metadata is invalid. */ protected function get_lock_value() { - return http_build_query(array( - 'hostname' => $this->get_hostname(), - 'processid' => getmypid(), - ), '', '&'); + $data = $this->identityprovider->build(); + if (!is_array($data) || empty($data)) { + throw new coding_exception( + 'Redis lock identity provider must return a non-empty array of metadata.', + ); + } + + $stringkeys = array_filter(array_keys($data), static fn($key) => is_string($key)); + if (count($data) !== count($stringkeys)) { + throw new coding_exception( + 'Redis lock identity provider keys must be strings.', + ); + } + + $stringvalues = array_filter(array_values($data), static fn($value) => is_string($value)); + if (count($data) !== count($stringvalues)) { + throw new coding_exception( + 'Redis lock identity provider values must be strings.', + ); + } + + ksort($data); + + return http_build_query($data, '', '&'); } } diff --git a/db/hooks.php b/db/hooks.php new file mode 100644 index 0000000..9eae178 --- /dev/null +++ b/db/hooks.php @@ -0,0 +1,40 @@ +. + +/** + * Hook callbacks. + * + * @package local_redislock + * @copyright Cameron Ball + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + // The hostname resolver is retrieved via DI, and we provide the default + // implementation by listening to the DI configuration hook. + 'hook' => \core\hook\di_configuration::class, + 'callback' => [\local_redislock\hook_callbacks::class, 'di_configuration'], + // Higher priority means "runs sooner". Setting 999 here means pretty much + // any plugin that listens to this hook will run after us, allowing them + // to switch in their own hostname resolver. + 'priority' => 999, + ], +]; diff --git a/version.php b/version.php index 6bbc4f1..fdddfcc 100644 --- a/version.php +++ b/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); /** @var object $plugin */ -$plugin->version = 2025060500; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2025111200; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2024100700; // Requires this Moodle version. $plugin->component = 'local_redislock'; // Full name of the plugin (used for diagnostics). $plugin->maturity = MATURITY_STABLE;