diff --git a/inc/Data/Driver/ApcuCacheDriver.php b/inc/Data/Driver/ApcuCacheDriver.php new file mode 100644 index 00000000..a39bb656 --- /dev/null +++ b/inc/Data/Driver/ApcuCacheDriver.php @@ -0,0 +1,28 @@ +prefix . $key; + } + + private function sharedLockCache(): void { + \flock($this->lock_fd, LOCK_SH); + } + + private function exclusiveLockCache(): void { + \flock($this->lock_fd, LOCK_EX); + } + + private function unlockCache(): void { + \flock($this->lock_fd, LOCK_UN); + } + + private function collectImpl(): int { + /* + * A read lock is ok, since it's alright if we delete expired items from under the feet of other processes, and + * no other process add new cache items or refresh existing ones. + */ + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + $count = 0; + foreach ($files as $file) { + $data = \file_get_contents($file); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + if (@\unlink($file)) { + $count++; + } + } + } + return $count; + } + + private function maybeCollect(): void { + if ($this->collect_chance_den !== false && \mt_rand(0, $this->collect_chance_den - 1) === 0) { + $this->collect_chance_den = false; // Collect only once per instance (aka process). + $this->collectImpl(); + } + } + + public function __construct(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { + if ($base_path[\strlen($base_path) - 1] !== '/') { + $base_path = "$base_path/"; + } + + if (!\is_dir($base_path)) { + throw new \RuntimeException("$base_path is not a directory!"); + } + + if (!\is_writable($base_path)) { + throw new \RuntimeException("$base_path is not writable!"); + } + + $this->lock_fd = \fopen($base_path . $lock_file, 'w'); + if ($this->lock_fd === false) { + throw new \RuntimeException('Unable to open the lock file!'); + } + + $this->prefix = $prefix; + $this->base_path = $base_path; + $this->collect_chance_den = $collect_chance_den; + } + + public function __destruct() { + $this->close(); + } + + public function get(string $key): mixed { + $key = $this->prepareKey($key); + + $this->sharedLockCache(); + + // Collect expired items first so if the target key is expired we shortcut to failure in the next lines. + $this->maybeCollect(); + + $fd = \fopen($this->base_path . $key, 'r'); + if ($fd === false) { + $this->unlockCache(); + return null; + } + + $data = \stream_get_contents($fd); + \fclose($fd); + $this->unlockCache(); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + // Already expired, leave it there since we already released the lock and pretend it doesn't exist. + return null; + } else { + return $wrapped['inner']; + } + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $key = $this->prepareKey($key); + + $wrapped = [ + 'expires' => $expires ? \time() + $expires : false, + 'inner' => $value + ]; + + $data = \json_encode($wrapped); + $this->exclusiveLockCache(); + $this->maybeCollect(); + \file_put_contents($this->base_path . $key, $data); + $this->unlockCache(); + } + + public function delete(string $key): void { + $key = $this->prepareKey($key); + + $this->exclusiveLockCache(); + @\unlink($this->base_path . $key); + $this->maybeCollect(); + $this->unlockCache(); + } + + public function collect(): int { + $this->sharedLockCache(); + $count = $this->collectImpl(); + $this->unlockCache(); + return $count; + } + + public function flush(): void { + $this->exclusiveLockCache(); + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + foreach ($files as $file) { + @\unlink($file); + } + $this->unlockCache(); + } + + public function close(): void { + \fclose($this->lock_fd); + } +} diff --git a/inc/Data/Driver/MemcacheCacheDriver.php b/inc/Data/Driver/MemcacheCacheDriver.php new file mode 100644 index 00000000..04f62895 --- /dev/null +++ b/inc/Data/Driver/MemcacheCacheDriver.php @@ -0,0 +1,43 @@ +inner = new \Memcached(); + if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) { + throw new \RuntimeException('Unable to set the memcached protocol!'); + } + if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) { + throw new \RuntimeException('Unable to set the memcached prefix!'); + } + if (!$this->inner->addServers($memcached_server)) { + throw new \RuntimeException('Unable to add the memcached server!'); + } + } + + public function get(string $key): mixed { + $ret = $this->inner->get($key); + // If the returned value is false but the retrival was a success, then the value stored was a boolean false. + if ($ret === false && $this->inner->getResultCode() !== \Memcached::RES_SUCCESS) { + return null; + } + return $ret; + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $this->inner->set($key, $value, (int)$expires); + } + + public function delete(string $key): void { + $this->inner->delete($key); + } + + public function flush(): void { + $this->inner->flush(); + } +} diff --git a/inc/Data/Driver/NoneCacheDriver.php b/inc/Data/Driver/NoneCacheDriver.php new file mode 100644 index 00000000..8b260a50 --- /dev/null +++ b/inc/Data/Driver/NoneCacheDriver.php @@ -0,0 +1,26 @@ +inner = new \Redis(); + $this->inner->connect($host, $port); + if ($password) { + $this->inner->auth($password); + } + if (!$this->inner->select($database)) { + throw new \RuntimeException('Unable to connect to Redis!'); + } + + $$this->prefix = $prefix; + } + + public function get(string $key): mixed { + $ret = $this->inner->get($this->prefix . $key); + if ($ret === false) { + return null; + } + return \json_decode($ret, true); + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + if ($expires === false) { + $this->inner->set($this->prefix . $key, \json_encode($value)); + } else { + $expires = $expires * 1000; // Seconds to milliseconds. + $this->inner->setex($this->prefix . $key, $expires, \json_encode($value)); + } + } + + public function delete(string $key): void { + $this->inner->del($this->prefix . $key); + } + + public function flush(): void { + $this->inner->flushDB(); + } +} diff --git a/inc/cache.php b/inc/cache.php index c4053610..293660fd 100644 --- a/inc/cache.php +++ b/inc/cache.php @@ -4,164 +4,89 @@ * Copyright (c) 2010-2013 Tinyboard Development Group */ +use Vichan\Data\Driver\{CacheDriver, ApcuCacheDriver, ArrayCacheDriver, FsCacheDriver, MemcachedCacheDriver, NoneCacheDriver, RedisCacheDriver}; + defined('TINYBOARD') or exit; + class Cache { - private static $cache; - public static function init() { + private static function buildCache(): CacheDriver { global $config; switch ($config['cache']['enabled']) { case 'memcached': - self::$cache = new Memcached(); - self::$cache->addServers($config['cache']['memcached']); - break; + return new MemcachedCacheDriver( + $config['cache']['prefix'], + $config['cache']['memcached'] + ); case 'redis': - self::$cache = new Redis(); - self::$cache->connect($config['cache']['redis'][0], $config['cache']['redis'][1]); - if ($config['cache']['redis'][2]) { - self::$cache->auth($config['cache']['redis'][2]); - } - self::$cache->select($config['cache']['redis'][3]) or die('cache select failure'); - break; + return new RedisCacheDriver( + $config['cache']['prefix'], + $config['cache']['redis'][0], + $config['cache']['redis'][1], + $config['cache']['redis'][2], + $config['cache']['redis'][3] + ); + case 'apcu': + return new ApcuCacheDriver; + case 'fs': + return new FsCacheDriver( + $config['cache']['prefix'], + "tmp/cache/{$config['cache']['prefix']}", + '.lock', + $config['auto_maintenance'] ? 1000 : false + ); + case 'none': + return new NoneCacheDriver(); case 'php': - self::$cache = array(); - break; + default: + return new ArrayCacheDriver(); } } + + public static function getCache(): CacheDriver { + static $cache; + return $cache ??= self::buildCache(); + } + public static function get($key) { global $config, $debug; - $key = $config['cache']['prefix'] . $key; - - $data = false; - switch ($config['cache']['enabled']) { - case 'memcached': - if (!self::$cache) - self::init(); - $data = self::$cache->get($key); - break; - case 'apcu': - $data = apcu_fetch($key); - break; - case 'php': - $data = isset(self::$cache[$key]) ? self::$cache[$key] : false; - break; - case 'fs': - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - if (!file_exists('tmp/cache/'.$key)) { - $data = false; - } - else { - $data = file_get_contents('tmp/cache/'.$key); - $data = json_decode($data, true); - } - break; - case 'redis': - if (!self::$cache) - self::init(); - $data = json_decode(self::$cache->get($key), true); - break; + $ret = self::getCache()->get($key); + if ($ret === null) { + $ret = false; } - if ($config['debug']) - $debug['cached'][] = $key . ($data === false ? ' (miss)' : ' (hit)'); + if ($config['debug']) { + $debug['cached'][] = $config['cache']['prefix'] . $key . ($ret === false ? ' (miss)' : ' (hit)'); + } - return $data; + return $ret; } public static function set($key, $value, $expires = false) { global $config, $debug; - $key = $config['cache']['prefix'] . $key; - - if (!$expires) + if (!$expires) { $expires = $config['cache']['timeout']; - - switch ($config['cache']['enabled']) { - case 'memcached': - if (!self::$cache) - self::init(); - self::$cache->set($key, $value, $expires); - break; - case 'redis': - if (!self::$cache) - self::init(); - self::$cache->setex($key, $expires, json_encode($value)); - break; - case 'apcu': - apcu_store($key, $value, $expires); - break; - case 'fs': - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - file_put_contents('tmp/cache/'.$key, json_encode($value)); - break; - case 'php': - self::$cache[$key] = $value; - break; } - if ($config['debug']) - $debug['cached'][] = $key . ' (set)'; + self::getCache()->set($key, $value, $expires); + + if ($config['debug']) { + $debug['cached'][] = $config['cache']['prefix'] . $key . ' (set)'; + } } public static function delete($key) { global $config, $debug; - $key = $config['cache']['prefix'] . $key; + self::getCache()->delete($key); - switch ($config['cache']['enabled']) { - case 'memcached': - if (!self::$cache) - self::init(); - self::$cache->delete($key); - break; - case 'redis': - if (!self::$cache) - self::init(); - self::$cache->del($key); - break; - case 'apcu': - apcu_delete($key); - break; - case 'fs': - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - @unlink('tmp/cache/'.$key); - break; - case 'php': - unset(self::$cache[$key]); - break; + if ($config['debug']) { + $debug['cached'][] = $config['cache']['prefix'] . $key . ' (deleted)'; } - - if ($config['debug']) - $debug['cached'][] = $key . ' (deleted)'; } public static function flush() { - global $config; - - switch ($config['cache']['enabled']) { - case 'memcached': - if (!self::$cache) - self::init(); - return self::$cache->flush(); - case 'apcu': - return apcu_clear_cache('user'); - case 'php': - self::$cache = array(); - break; - case 'fs': - $files = glob('tmp/cache/*'); - foreach ($files as $file) { - unlink($file); - } - break; - case 'redis': - if (!self::$cache) - self::init(); - return self::$cache->flushDB(); - } - + self::getCache()->flush(); return false; } } diff --git a/inc/config.php b/inc/config.php index 067bc715..c111b762 100644 --- a/inc/config.php +++ b/inc/config.php @@ -139,17 +139,26 @@ /* * On top of the static file caching system, you can enable the additional caching system which is - * designed to minimize SQL queries and can significantly increase speed when posting or using the - * moderator interface. APC is the recommended method of caching. + * designed to minimize request processing can significantly increase speed when posting or using + * the moderator interface. * * https://github.com/vichan-devel/vichan/wiki/cache */ + // Uses a PHP array. MUST NOT be used in multiprocess environments. $config['cache']['enabled'] = 'php'; + // The recommended in-memory method of caching. Requires the extension. Due to how APCu works, this should be + // disabled when you run tools from the cli. // $config['cache']['enabled'] = 'apcu'; + // The Memcache server. Requires the memcached extension, with a final D. // $config['cache']['enabled'] = 'memcached'; + // The Redis server. Requires the extension. // $config['cache']['enabled'] = 'redis'; + // Use the local cache folder. Slower than native but available out of the box and compatible with multiprocess + // environments. You can mount a ram-based filesystem in the cache directory to improve performance. // $config['cache']['enabled'] = 'fs'; + // Technically available, offers a no-op fake cache. Don't use this outside of testing or debugging. + // $config['cache']['enabled'] = 'none'; // Timeout for cached objects such as posts and HTML. $config['cache']['timeout'] = 60 * 60 * 48; // 48 hours diff --git a/inc/context.php b/inc/context.php index c3ebef04..e747a68d 100644 --- a/inc/context.php +++ b/inc/context.php @@ -83,6 +83,10 @@ function build_context(array $config): Context { $config['captcha']['native']['provider_check'], $config['captcha']['native']['extra'] ); + }, + CacheDriver::class => function($c) { + // Use the global for backwards compatibility. + return \cache::getCache(); } ]); } diff --git a/inc/mod/pages.php b/inc/mod/pages.php index ef41da70..8a549481 100644 --- a/inc/mod/pages.php +++ b/inc/mod/pages.php @@ -4,8 +4,8 @@ */ use Vichan\Context; use Vichan\Functions\Format; - use Vichan\Functions\Net; +use Vichan\Data\Driver\CacheDriver; defined('TINYBOARD') or exit; @@ -112,25 +112,23 @@ function mod_dashboard(Context $ctx) { $args['boards'] = listBoards(); if (hasPermission($config['mod']['noticeboard'])) { - if (!$config['cache']['enabled'] || !$args['noticeboard'] = cache::get('noticeboard_preview')) { + if (!$args['noticeboard'] = $ctx->get(CacheDriver::class)->get('noticeboard_preview')) { $query = prepare("SELECT ``noticeboard``.*, `username` FROM ``noticeboard`` LEFT JOIN ``mods`` ON ``mods``.`id` = `mod` ORDER BY `id` DESC LIMIT :limit"); $query->bindValue(':limit', $config['mod']['noticeboard_dashboard'], PDO::PARAM_INT); $query->execute() or error(db_error($query)); $args['noticeboard'] = $query->fetchAll(PDO::FETCH_ASSOC); - if ($config['cache']['enabled']) - cache::set('noticeboard_preview', $args['noticeboard']); + $ctx->get(CacheDriver::class)->set('noticeboard_preview', $args['noticeboard']); } } - if (!$config['cache']['enabled'] || ($args['unread_pms'] = cache::get('pm_unreadcount_' . $mod['id'])) === false) { + if ($args['unread_pms'] = $ctx->get(CacheDriver::class)->get('pm_unreadcount_' . $mod['id']) === false) { $query = prepare('SELECT COUNT(*) FROM ``pms`` WHERE `to` = :id AND `unread` = 1'); $query->bindValue(':id', $mod['id']); $query->execute() or error(db_error($query)); $args['unread_pms'] = $query->fetchColumn(); - if ($config['cache']['enabled']) - cache::set('pm_unreadcount_' . $mod['id'], $args['unread_pms']); + $ctx->get(CacheDriver::class)->set('pm_unreadcount_' . $mod['id'], $args['unread_pms']); } $query = query('SELECT COUNT(*) FROM ``reports``') or error(db_error($query)); @@ -384,6 +382,8 @@ function mod_search(Context $ctx, $type, $search_query_escaped, $page_no = 1) { function mod_edit_board(Context $ctx, $boardName) { global $board, $config, $mod; + $cache = $ctx->get(CacheDriver::class); + if (!openBoard($boardName)) error($config['error']['noboard']); @@ -399,10 +399,8 @@ function mod_edit_board(Context $ctx, $boardName) { $query->bindValue(':uri', $board['uri']); $query->execute() or error(db_error($query)); - if ($config['cache']['enabled']) { - cache::delete('board_' . $board['uri']); - cache::delete('all_boards'); - } + $cache->delete('board_' . $board['uri']); + $cache->delete('all_boards'); modLog('Deleted board: ' . sprintf($config['board_abbreviation'], $board['uri']), false); @@ -467,10 +465,9 @@ function mod_edit_board(Context $ctx, $boardName) { modLog('Edited board information for ' . sprintf($config['board_abbreviation'], $board['uri']), false); } - if ($config['cache']['enabled']) { - cache::delete('board_' . $board['uri']); - cache::delete('all_boards'); - } + $cache->delete('board_' . $board['uri']); + $cache->delete('all_boards'); + Vichan\Functions\Theme\rebuild_themes('boards'); @@ -505,6 +502,8 @@ function mod_new_board(Context $ctx) { if (!preg_match('/^' . $config['board_regex'] . '$/u', $_POST['uri'])) error(sprintf($config['error']['invalidfield'], 'URI')); + $cache = $ctx->get(CacheDriver::class); + $bytes = 0; $chars = preg_split('//u', $_POST['uri'], -1, PREG_SPLIT_NO_EMPTY); foreach ($chars as $char) { @@ -544,8 +543,8 @@ function mod_new_board(Context $ctx) { query($query) or error(db_error()); - if ($config['cache']['enabled']) - cache::delete('all_boards'); + $cache = $ctx->get(CacheDriver::class); + $cache->delete('all_boards'); // Build the board buildIndex(); @@ -590,8 +589,8 @@ function mod_noticeboard(Context $ctx, $page_no = 1) { $query->bindValue(':body', $_POST['body']); $query->execute() or error(db_error($query)); - if ($config['cache']['enabled']) - cache::delete('noticeboard_preview'); + $cache = $ctx->get(CacheDriver::class); + $cache->delete('noticeboard_preview'); modLog('Posted a noticeboard entry'); @@ -631,7 +630,7 @@ function mod_noticeboard_delete(Context $ctx, $id) { $config = $ctx->get('config'); if (!hasPermission($config['mod']['noticeboard_delete'])) - error($config['error']['noaccess']); + error($config['error']['noaccess']); $query = prepare('DELETE FROM ``noticeboard`` WHERE `id` = :id'); $query->bindValue(':id', $id); @@ -639,8 +638,8 @@ function mod_noticeboard_delete(Context $ctx, $id) { modLog('Deleted a noticeboard entry'); - if ($config['cache']['enabled']) - cache::delete('noticeboard_preview'); + $cache = $ctx->get(CacheDriver::class); + $cache->delete('noticeboard_preview'); header('Location: ?/noticeboard', true, $config['redirect_http']); } @@ -706,7 +705,7 @@ function mod_news_delete(Context $ctx, $id) { $config = $ctx->get('config'); if (!hasPermission($config['mod']['news_delete'])) - error($config['error']['noaccess']); + error($config['error']['noaccess']); $query = prepare('DELETE FROM ``news`` WHERE `id` = :id'); $query->bindValue(':id', $id); @@ -843,7 +842,7 @@ function mod_view_board(Context $ctx, $boardName, $page_no = 1) { } $page['pages'] = getPages(true); - $page['pages'][$page_no-1]['selected'] = true; + $page['pages'][$page_no - 1]['selected'] = true; $page['btn'] = getPageButtons($page['pages'], true); $page['mod'] = true; $page['config'] = $config; @@ -1042,7 +1041,6 @@ function mod_edit_ban(Context $ctx, $ban_id) { Bans::delete($ban_id); header('Location: ?/', true, $config['redirect_http']); - } $args['token'] = make_secure_link_token('edit_ban/' . $ban_id); @@ -2235,10 +2233,9 @@ function mod_pm(Context $ctx, $id, $reply = false) { $query->bindValue(':id', $id); $query->execute() or error(db_error($query)); - if ($config['cache']['enabled']) { - cache::delete('pm_unread_' . $mod['id']); - cache::delete('pm_unreadcount_' . $mod['id']); - } + $cache = $ctx->get(CacheDriver::class); + $cache->delete('pm_unread_' . $mod['id']); + $cache->delete('pm_unreadcount_' . $mod['id']); header('Location: ?/', true, $config['redirect_http']); return; @@ -2249,10 +2246,9 @@ function mod_pm(Context $ctx, $id, $reply = false) { $query->bindValue(':id', $id); $query->execute() or error(db_error($query)); - if ($config['cache']['enabled']) { - cache::delete('pm_unread_' . $mod['id']); - cache::delete('pm_unreadcount_' . $mod['id']); - } + $cache = $ctx->get(CacheDriver::class); + $cache->delete('pm_unread_' . $mod['id']); + $cache->delete('pm_unreadcount_' . $mod['id']); modLog('Read a PM'); } @@ -2339,10 +2335,10 @@ function mod_new_pm(Context $ctx, $username) { $query->bindValue(':time', time()); $query->execute() or error(db_error($query)); - if ($config['cache']['enabled']) { - cache::delete('pm_unread_' . $id); - cache::delete('pm_unreadcount_' . $id); - } + $cache = $ctx->get(CacheDriver::class); + + $cache->delete('pm_unread_' . $id); + $cache->delete('pm_unreadcount_' . $id); modLog('Sent a PM to ' . utf8tohtml($username)); @@ -2368,6 +2364,8 @@ function mod_rebuild(Context $ctx) { if (!hasPermission($config['mod']['rebuild'])) error($config['error']['noaccess']); + $cache = $ctx->get(CacheDriver::class); + if (isset($_POST['rebuild'])) { @set_time_limit($config['mod']['rebuild_timelimit']); @@ -2378,7 +2376,7 @@ function mod_rebuild(Context $ctx) { if (isset($_POST['rebuild_cache'])) { if ($config['cache']['enabled']) { $log[] = 'Flushing cache'; - Cache::flush(); + $cache->flush(); } $log[] = 'Clearing template cache'; @@ -2840,6 +2838,8 @@ function mod_theme_configure(Context $ctx, $theme_name) { error($config['error']['invalidtheme']); } + $cache = $ctx->get(CacheDriver::class); + if (isset($_POST['install'])) { // Check if everything is submitted foreach ($theme['config'] as &$conf) { @@ -2868,8 +2868,8 @@ function mod_theme_configure(Context $ctx, $theme_name) { $query->execute() or error(db_error($query)); // Clean cache - Cache::delete("themes"); - Cache::delete("theme_settings_".$theme_name); + $cache->delete("themes"); + $cache->delete("theme_settings_$theme_name"); $result = true; $message = false; @@ -2928,13 +2928,15 @@ function mod_theme_uninstall(Context $ctx, $theme_name) { if (!hasPermission($config['mod']['themes'])) error($config['error']['noaccess']); + $cache = $ctx->get(CacheDriver::class); + $query = prepare("DELETE FROM ``theme_settings`` WHERE `theme` = :theme"); $query->bindValue(':theme', $theme_name); $query->execute() or error(db_error($query)); // Clean cache - Cache::delete("themes"); - Cache::delete("theme_settings_".$theme_name); + $cache->delete("themes"); + $cache->delete("theme_settings_$theme_name"); header('Location: ?/themes', true, $config['redirect_http']); } @@ -2959,7 +2961,7 @@ function mod_theme_rebuild(Context $ctx, $theme_name) { } // This needs to be done for `secure` CSRF prevention compatibility, otherwise the $board will be read in as the token if editing global pages. -function delete_page_base($page = '', $board = false) { +function delete_page_base(Context $ctx, $page = '', $board = false) { global $config, $mod; if (empty($board)) diff --git a/tools/maintenance.php b/tools/maintenance.php index 33c0a4d4..a869e2fa 100644 --- a/tools/maintenance.php +++ b/tools/maintenance.php @@ -21,5 +21,20 @@ echo "Deleted $deleted_count expired antispam in $delta seconds!\n"; $time_tot = $delta; $deleted_tot = $deleted_count; +if ($config['cache']['enabled'] === 'fs') { + $fs_cache = new Vichan\Data\Driver\FsCacheDriver( + $config['cache']['prefix'], + "tmp/cache/{$config['cache']['prefix']}", + '.lock', + false + ); + $start = microtime(true); + $fs_cache->collect(); + $delta = microtime(true) - $start; + echo "Deleted $deleted_count expired filesystem cache items in $delta seconds!\n"; + $time_tot = $delta; + $deleted_tot = $deleted_count; +} + $time_tot = number_format((float)$time_tot, 4, '.', ''); modLog("Deleted $deleted_tot expired entries in {$time_tot}s with maintenance tool");