Skip to content

Commit 1394b41

Browse files
author
David Castro
committed
INT-16370: Shared redis connection for redis lock.
1 parent fe6377d commit 1394b41

File tree

4 files changed

+273
-9
lines changed

4 files changed

+273
-9
lines changed

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
#About
1+
# Redis lock
2+
3+
## About
24
Provides a Moodle lock factory class for locking with Redis. This plugin was contributed by the Blackboard Open LMS Product Development team. Blackboard is an education technology company dedicated to bringing excellent online teaching to institutions across the globe. We serve colleges and universities, schools and organizations by supporting the software that educators use to manage and deliver instructional content to learners in virtual classrooms.
35

4-
#Requirements
6+
## Requirements
57
* Moodle 2.9 or greater
68
* Redis
79
* PHP Redis extension
810

9-
#Installation
11+
## Installation
1012
Clone the repository or download and extract the code into the local directory of your Moodle install (e.g. $CFG->wwwroot/local/redislock) and run the site's upgrade script. Set $CFG->local_redislock_redis_server with your Redis server's connection string. Set $CFG->lock_factory to '\\\\local_redislock\\\\lock\\\\redis_lock_factory' in your config file.
1113

12-
Use the boolean flag `$CFG->local_redislock_logging` to control whether verbose
14+
## Flags
15+
16+
* Use the boolean flag `$CFG->local_redislock_logging` to control whether verbose
1317
logging should be emitted. If not set, logging is automatically-enabled when running
1418
in the CLI environment with debugging enabled on `DEBUG_NORMAL` level at least.
19+
* Use the boolean flag `$CFG->local_redislock_disable_shared_connection` to force creation
20+
of the redis connection for each factory instance.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
// This file is part of Moodle - http://moodle.org/
3+
//
4+
// Moodle is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// Moodle is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU General Public License
15+
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
16+
17+
/**
18+
* Shared redis connection handling class.
19+
*
20+
* @package local_redislock
21+
* @author David Castro
22+
* @copyright Copyright (c) 2020 Open LMS. (https://www.openlms.net)
23+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24+
*/
25+
26+
namespace local_redislock\api;
27+
28+
defined('MOODLE_INTERNAL') || die();
29+
30+
use Redis;
31+
32+
/**
33+
* Shared redis connection handling class.
34+
*
35+
* @package local_redislock
36+
* @author David Castro
37+
* @copyright Copyright (c) 2020 Open LMS. (https://www.openlms.net)
38+
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39+
*/
40+
41+
class shared_redis_connection {
42+
43+
/**
44+
* @var shared_redis_connection
45+
*/
46+
private static $instance;
47+
48+
/**
49+
* @var Redis
50+
*/
51+
private $redis;
52+
53+
/**
54+
* @var int
55+
*/
56+
private $factorycount;
57+
58+
/**
59+
* @var boolean
60+
*/
61+
private $logging;
62+
63+
/**
64+
* Singleton constructor.
65+
*/
66+
private function __construct() {
67+
$this->factorycount = 0;
68+
if (isset($CFG->local_redislock_logging)) {
69+
$this->logging = (bool) $CFG->local_redislock_logging;
70+
} else {
71+
$this->logging = (CLI_SCRIPT && debugging() && !PHPUNIT_TEST);
72+
}
73+
}
74+
75+
/**
76+
* @return shared_redis_connection
77+
*/
78+
public static function get_instance() {
79+
if (self::$instance == null) {
80+
self::$instance = new shared_redis_connection();
81+
}
82+
return self::$instance;
83+
}
84+
85+
/**
86+
* @param Redis $redis
87+
*/
88+
public function set_redis(Redis $redis) {
89+
$this->redis = $redis;
90+
}
91+
92+
/**
93+
* @return Redis
94+
*/
95+
public function get_redis(): ?Redis {
96+
return $this->redis;
97+
}
98+
99+
/**
100+
* Closes the shared connection.
101+
*/
102+
public function close() {
103+
if (!is_null($this->redis) && $this->redis->isConnected()) {
104+
$this->redis->close();
105+
$this->redis = null;
106+
$this->log("Shared Redis connection is closed.");
107+
}
108+
}
109+
110+
/**
111+
* Clears the redis attribute. Use only when the connection has become unresponsive.
112+
*/
113+
public function clear() {
114+
$this->redis = null;
115+
}
116+
117+
/**
118+
* Adds a factory to the count.
119+
*/
120+
public function add_factory() {
121+
$this->factorycount++;
122+
}
123+
124+
/**
125+
* @throws \coding_exception
126+
*/
127+
public function remove_factory() {
128+
if (empty($this->factorycount)) {
129+
throw new \coding_exception('Can\'t remove a factory, count is 0.');
130+
}
131+
$this->factorycount--;
132+
}
133+
134+
/**
135+
* @return int
136+
*/
137+
public function get_factory_count() {
138+
return $this->factorycount;
139+
}
140+
141+
/**
142+
* Log message
143+
*
144+
* @param $message
145+
*/
146+
private function log($message) {
147+
if ($this->logging) {
148+
mtrace(sprintf('Redis lock; pid=%d; %s', getmypid(), $message));
149+
}
150+
}
151+
}

classes/lock/redis_lock_factory.php

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929

3030
use core\lock\lock_factory;
3131
use core\lock\lock;
32+
use local_redislock\api\shared_redis_connection;
3233

3334
/**
3435
* Redis-backed lock factory class.
@@ -60,6 +61,21 @@ class redis_lock_factory implements lock_factory {
6061
*/
6162
protected $logging;
6263

64+
/**
65+
* @var string Redis server connection string.
66+
*/
67+
private $redisserver;
68+
69+
/**
70+
* @var boolean Shared connection enabled.
71+
*/
72+
private $shareconnection;
73+
74+
/**
75+
* @var int Connection count.
76+
*/
77+
private static $conncount = 0;
78+
6379
/**
6480
* @param string $type The type this lock is used for (e.g. cron, cache).
6581
* @param \Redis|null $redis An instance of the PHPRedis extension class.
@@ -70,9 +86,14 @@ public function __construct($type, \Redis $redis = null, $logging = null) {
7086
global $CFG;
7187

7288
$this->type = $type;
73-
89+
$this->redisserver = $CFG->local_redislock_redis_server ?? null;
90+
$this->shareconnection = empty($CFG->local_redislock_disable_shared_connection);
7491
if (is_null($redis)) {
92+
shared_redis_connection::get_instance()->add_factory();
7593
$redis = $this->bootstrap_redis();
94+
} else {
95+
// If a Redis instance is set, we shouldn't share it as we don't know who else is using it.
96+
$this->shareconnection = false;
7697
}
7798
if (is_null($logging)) {
7899
if (isset($CFG->local_redislock_logging)) {
@@ -148,6 +169,11 @@ public function get_lock($resource, $timeout, $maxlifetime = 86400) {
148169
$resource = $CFG->dbname . '_' . $resource;
149170
}
150171

172+
if ($this->shareconnection) {
173+
// Re-get the Redis shared connection in case it's be cleared or recreated elsewhere.
174+
$this->redis = $this->bootstrap_redis();
175+
}
176+
151177
$this->log('Waiting to get '.$resource.' lock');
152178

153179
$exception = false;
@@ -160,6 +186,10 @@ public function get_lock($resource, $timeout, $maxlifetime = 86400) {
160186
} catch (\RedisException $e) {
161187
// If there has been a redis exception, we will try to reconnect.
162188
$exception = $e;
189+
if (!$this->shareconnection) {
190+
self::$conncount--;
191+
}
192+
shared_redis_connection::get_instance()->clear(); // Delete shared connection.
163193
$this->log("Got exception while trying to get lock: {$e->getMessage()}");
164194
$this->log("Attempting to reconnect to Redis");
165195
$this->redis = $this->bootstrap_redis();
@@ -197,6 +227,11 @@ public function get_lock($resource, $timeout, $maxlifetime = 86400) {
197227
public function release_lock(lock $lock) {
198228
$resource = $lock->get_key();
199229

230+
if ($this->shareconnection) {
231+
// Re-get the Redis shared connection in case it's be cleared or recreated elsewhere.
232+
$this->redis = $this->bootstrap_redis();
233+
}
234+
200235
// We will retry connecting and releasing up to 5 times.
201236
$failcount = 0;
202237
$value = false;
@@ -212,6 +247,10 @@ public function release_lock(lock $lock) {
212247
}
213248

214249
// If there has been a redis exception, we will try to reconnect.
250+
if (!$this->shareconnection) {
251+
self::$conncount--;
252+
}
253+
shared_redis_connection::get_instance()->clear(); // Delete shared connection.
215254
$this->log("Got exception while trying to release lock: {$e->getMessage()}");
216255
$this->log('Attempting to reconnect to Redis');
217256
$this->redis = $this->bootstrap_redis();
@@ -272,7 +311,18 @@ public function auto_release() {
272311
$lock->release();
273312
}
274313

275-
$this->redis->close();
314+
if (!$this->shareconnection) {
315+
// Connection is not shared. Closing now!
316+
$this->redis->close();
317+
self::$conncount--;
318+
$conncount = self::$conncount;
319+
$this->log("Connection to Redis from factory type {$this->type} is closed, {$conncount} remaining.");
320+
} else {
321+
shared_redis_connection::get_instance()->remove_factory();
322+
if (empty(shared_redis_connection::get_instance()->get_factory_count())) {
323+
shared_redis_connection::get_instance()->close();
324+
}
325+
}
276326
}
277327

278328
/**
@@ -293,19 +343,27 @@ public function get_ttl(lock $lock) {
293343
* @throws \coding_exception
294344
*/
295345
protected function bootstrap_redis() {
296-
global $CFG;
346+
if (!is_null($redis = shared_redis_connection::get_instance()->get_redis())) {
347+
// Reuse the connection if available.
348+
return $redis;
349+
}
297350

298351
if (!class_exists('Redis')) {
299352
throw new \coding_exception('Redis class not found, Redis PHP Extension is probably not installed on host: '
300353
. $this->get_hostname());
301354
}
302-
if (empty($CFG->local_redislock_redis_server)) {
355+
if (empty($this->redisserver)) {
303356
throw new \coding_exception('Redis connection string is not configured in $CFG->local_redislock_redis_server');
304357
}
305358

306359
try {
307360
$redis = new \Redis();
308-
$redis->connect($CFG->local_redislock_redis_server);
361+
$redis->connect($this->redisserver);
362+
if ($this->shareconnection) {
363+
shared_redis_connection::get_instance()->set_redis($redis); // Reusing the connection.
364+
} else {
365+
self::$conncount++;
366+
}
309367
} catch (\RedisException $e) {
310368
throw new \coding_exception("RedisException caught on host {$this->get_hostname()} with message: {$e->getMessage()}");
311369
}

tests/redis_lock_factory_test.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
defined('MOODLE_INTERNAL') || die();
2727

2828
use core\lock\lock_config;
29+
use local_redislock\api\shared_redis_connection;
2930

3031
/**
3132
* PHPUnit testcase class for \local_redislock\lock\redis_lock_factory.
@@ -48,6 +49,16 @@ public function setUp() {
4849
$CFG->lock_factory = '\\local_redislock\\lock\\redis_lock_factory';
4950
}
5051

52+
/**
53+
* @throws coding_exception
54+
*/
55+
protected function tearDown() {
56+
shared_redis_connection::get_instance()->close();
57+
while (!empty(shared_redis_connection::get_instance()->get_factory_count())) {
58+
shared_redis_connection::get_instance()->remove_factory();
59+
}
60+
}
61+
5162
/**
5263
* Tests acquiring locks using the Redis lock factory.
5364
*
@@ -189,6 +200,44 @@ public function test_lock_zero_timeout() {
189200
$this->assertLessThan(.5, microtime(true) - $start);
190201
}
191202

203+
/**
204+
* Tests shared connection.
205+
*
206+
* @throws coding_exception
207+
*/
208+
public function test_shared_connection() {
209+
if (!$this->is_redis_available()) {
210+
$this->markTestSkipped('Redis server not available');
211+
}
212+
213+
/** @var local_redislock\lock\redis_lock_factory $redislockfactory1 */
214+
$redislockfactory1 = lock_config::get_lock_factory('conduit_cron');
215+
$lock1 = $redislockfactory1->get_lock('shared_conn_test1', 10, 200);
216+
$this->assertNotEmpty($lock1);
217+
$redis1 = shared_redis_connection::get_instance()->get_redis();
218+
$this->assertNotNull($redis1);
219+
$lock1->release(); // All locks should be released.
220+
221+
/** @var local_redislock\lock\redis_lock_factory $redislockfactory2 */
222+
$redislockfactory2 = lock_config::get_lock_factory('cron');
223+
$lock2 = $redislockfactory2->get_lock('shared_conn_test2', 10, 200);
224+
$this->assertNotEmpty($lock2);
225+
226+
// Simulating auto releases.
227+
$redislockfactory1->auto_release(); // This should not close redis.
228+
229+
$redis2 = shared_redis_connection::get_instance()->get_redis();
230+
$this->assertSame($redis1, $redis2);
231+
$this->assertTrue($redis2->isConnected());
232+
233+
// Last auto-release.
234+
$redislockfactory2->auto_release(); // This SHOULD close redis.
235+
236+
// Connection should be auto closed when Moodle shuts down (All auto-releases have run).
237+
$redis3 = shared_redis_connection::get_instance()->get_redis();
238+
$this->assertNull($redis3);
239+
}
240+
192241
/**
193242
* Helper method to determine whether a Redis server is available to run these tests.
194243
* If LOCAL_REDISLOCK_REDIS_LOCK_TEST is not true most of these tests will be skipped.

0 commit comments

Comments
 (0)