<?php /** * Perforce Swarm * * @copyright 2013 Perforce Software. All rights reserved. * @license Please see LICENSE.txt in top-level folder of this distribution. * @version <release>/<patch> */ namespace P4\ClientPool; use P4\ClientPool\Exception; use P4\Model\Connected\ConnectedAbstract; use P4\Spec\Depot; use P4\Uuid\Uuid; /** * Manages a pool of client workspaces. */ class ClientPool extends ConnectedAbstract { const MANAGEMENT_LOCK = 'manage'; const LOCK_EXTENSION = '.lock'; const SPIN_DELAY = 50000; // delay between locking attempts (50ms) protected $root = null; protected $prefix = null; protected $max = 10; protected $handles = array(); protected $clients = array(); /** * Set the maximum number of clients to provision. * * Setting to 0 or null will leave it unlimited. * * @param int|null $max the maximum number of clients to provision * @return ClientPool to maintain a fluent interface */ public function setMax($max) { $this->max = $max; return $this; } /** * Retrieves the max client limit. * * @return int|null the max number of clients 0/null for unlimited */ public function getMax() { return $this->max; } /** * Specify the prefix used for client ids. * * @param string|null $prefix the prefix to apply to client ids (e.g. 'swarm-') * @return ClientPool to maintain a fluent interface */ public function setPrefix($prefix) { $this->prefix = $prefix; return $this; } /** * Retreives the prefix used for client ids. * * @return string the prefix to apply to client ids (e.g. 'swarm-') */ public function getPrefix() { return $this->prefix; } /** * Specify the root folder to store client workspaces and locks under. * Each generated client will store its data in a sub-folder named for * the client id. Also, client locks (<client-id>.lock) and the management * lock will be stored in this folder. * * @param string|null $root the root folder to use for client workspaces * @return ClientPool to maintain a fluent interface */ public function setRoot($root) { $this->root = $root !== null ? rtrim($root, '/') : null; return $this; } /** * The root folder for client workspaces. See setRoot for details. * * @return string|null the root folder to use for client workspaces */ public function getRoot() { return $this->root; } /** * Will lock a client workspace for this thread to use and return the id. * * By default, the thread will just be given the existing locked client if one * is already being held. Getting multiple clients in a single client can be * achieved via the $reuse flag. * * It isn't guaranteed the returned client will have an up to date view map. * If you plan to sync files its advised you run 'reset' against the client first. * * @param bool $reuse optional - if a client has already been requested by * this thread its returned again if reuse is true (default). * specifying reuse false will get an additional client. * @param bool $blocking optional - by default, we will wait indefinitely to get a * client. if false is passed we'll return false if we cannot * get/add a client on our first attempt. * @return bool|string the client id to use or false if we were non-blocking and * a client couldn't be locked/added on our first attempt. * @throws Exception if the root hasn't been set or file permissions prevent opening locks */ public function grab($reuse = true, $blocking = true) { if (!$this->root) { throw new Exception( 'No root has been set, unable to get client.' ); } // if our root isn't already present, attempt to create it if (!is_dir($this->root)) { mkdir($this->root, 0700, true); } // if re-use is allowed and we have client(s) already simply return the first one if ($this->handles && $reuse) { reset($this->handles); return key($this->handles); } // we'll keep looping until we can successfully get a client // (unless non-blocking in which case we try once and give up). // first, we try to get a lock on an existing client; if that // fails we'll take a management lock and add a new client if // the server isn't already at 'max' clients. $p4 = $this->getConnection(); while (1) { // retrieve the known locks, barring the management lock. $locks = glob($this->root . '/' . $this->prefix . '*' . static::LOCK_EXTENSION); $locks = array_map('basename', $locks); $locks = array_diff($locks, array(static::MANAGEMENT_LOCK . static::LOCK_EXTENSION)); // attempt to get a lock on any existing client foreach ($locks as $lock) { $file = @fopen($this->root . '/' . $lock, 'c'); if ($file === false) { throw new Exception( 'Unable to open client pool lock, this likely indicates file permission problems' ); } // if we can get the lock, extract client id, record handle and return! if (flock($file, LOCK_EX | LOCK_NB)) { $id = basename($lock, static::LOCK_EXTENSION); // don't let handle fall out of scope or the lock will release. $this->handles[$id] = $file; // push connection's old client onto the clients // array and set the grabbed client on it. $this->clients += array(spl_object_hash($p4) => array()); $this->clients[spl_object_hash($p4)][] = array( 'old' => $p4->getClient(), 'new' => $id ); $p4->setClient($id); // if the client doesn't exist call reset to create it $info = $p4->getInfo(); if ($info['clientName'] == '*unknown*') { $this->reset(); } return $id; } // didn't get a lock; close file handle fclose($file); } // if we aren't already maxed out on workspaces try and add one if (!$this->max || count($locks) < $this->max) { // if we are able to add a client, we continue which will loop // us around and attempt to lock our new client without delay. if ($this->provision()) { continue; } } // if we were told to not be blocking; time to bail if (!$blocking) { return false; } // looks like we can't add another workspace and they are all taken. // sleep a bit and start all over. usleep(static::SPIN_DELAY); } } /** * Release previously locked client(s). It is recommended you release your * client as soon as you are done with it. * * @return ClientPool to maintain a fluent interface */ public function release() { // if we have an entry for this object with this current client value // at the top of the stack set back the original client id. $client = null; $p4 = $this->getConnection(); $hash = spl_object_hash($p4); $client = $p4->getClient(); if (isset($this->clients[$hash]) && $this->clients[$hash]) { $historic = array_pop($this->clients[$hash]); if ($p4->getClient() == $historic['new']) { $p4->setClient($historic['old']); } } // deal with closing handle(s) foreach ($this->handles as $key => $handle) { if ($client && $client != $key) { continue; } flock($handle, LOCK_UN); fclose($handle); unset($this->handles[$key]); } return $this; } /** * Resets the passed client identifier to the correct view, root and host. * If the specified client doesn't already exist it will be created. * * @param bool $clearFiles optional - if true, default, will attempt to revert and remove * files. if false, only the client settings are reset. * @param string|null|bool $stream optional - a specific stream to point the client at * if null/false (default) the client will map all depots. * @param bool $mapAllDepots optional - include all mappable depots (excludes archive and unload) * by default this is false and only local and stream depots are mapped * @return ClientPool to maintain a fluent interface */ public function reset($clearFiles = true, $stream = null, $mapAllDepots = false) { $p4 = $this->getConnection(); $client = $p4->getClient(); $root = $this->root . '/' . $client; $view = array(); // generate a view which includes all depots if $mapAllDepots is true, otherwise only local and stream depots if (!$stream) { foreach (Depot::fetchAll(null, $p4) as $depot) { $type = $depot->get('Type'); // exclude 'archive' and 'unload' depots as these cannot be mapped in client view if ($type === 'archive' || $type === 'unload') { continue; } if ($mapAllDepots || $type == 'local' || $type == 'stream') { $view[] = '"//' . $depot->getId() . '/..." "//' . $client . '/' . $depot->getId() . '/..."'; } } } // force the client to have current/correct settings $data = $p4->run('client', array('-o', $client))->expandSequences()->getData(-1); $p4->run( 'client', '-i', array( 'Host' => '', 'Root' => $root, 'View' => $view, 'Stream' => $stream ) + $data ); // ensure the root folder and lock file exist is_dir($root) ?: mkdir($root); // clear files if needed and return return $clearFiles ? $this->clearFiles($p4) : $this; } /** * Revert and remove any file in this client. * * @return ClientPool to maintain a fluent interface */ public function clearFiles() { // revert and flush (sync none without removing local files) the client $p4 = $this->getConnection(); $p4->run('revert', array('-k', '//...')); $p4->run('flush', '//...#none'); // remove the contents of the client $this->removeDirectory($this->root . '/' . $p4->getClient(), true, false); return $this; } /** * Takes a management lock and defines a new client id if the server isn't * already at 'max' clients. * * Note, the client won't actually exist in perforce until someone calls * 'reset' on it. This process will occur automatically the first time * the new client is 'grabbed'. * * @return bool true if client was provisioned, false otherwise * @throws \Exception if errors occur running revert/flush during hard reset * @throws Exception if management lock file cannot be opened (most likely due to file permissions) */ protected function provision() { $file = @fopen($this->root . '/' . static::MANAGEMENT_LOCK . static::LOCK_EXTENSION, 'c'); // if we lacked rights to open the file, bail if ($file === false) { throw new Exception( 'Unable to open client pool management lock, this likely indicates file permission problems' ); } $lock = flock($file, LOCK_EX | LOCK_NB); // if another process is already managing; can't provision bail out if (!$lock) { flock($file, LOCK_UN); fclose($file); return false; } // get the workspace list now that we have a lock to take an accurate counts $locks = glob($this->root . '/' . $this->prefix . '*' . static::LOCK_EXTENSION); unset($locks[array_search($this->root . '/' . static::MANAGEMENT_LOCK . static::LOCK_EXTENSION, $locks)]); // if we now appear to be at/above max just release lock and return failure if ($this->max && count($locks) >= $this->max) { flock($file, LOCK_UN); fclose($file); usleep(5000); return false; } // generate a new client identifier and touch the lock to define it $id = $this->prefix . new Uuid; touch($this->root . '/' . $id . static::LOCK_EXTENSION); // release our management lock and return success flock($file, LOCK_UN); fclose($file); return true; } /** * Recursively remove a directory and all of it's file contents. * * @param string $directory The directory to remove. * @param bool $recursive when true, recursively delete directories. * @param bool $removeRoot when true, remove the root (passed) directory too */ protected function removeDirectory($directory, $recursive = true, $removeRoot = true) { if (is_dir($directory)) { $files = new \RecursiveDirectoryIterator($directory); foreach ($files as $file) { if ($files->isDot()) { continue; } if ($file->isFile()) { chmod($file->getPathname(), 0777); @unlink($file->getPathname()); } elseif ($file->isDir() && $recursive) { $this->removeDirectory($file->getPathname(), true, true); } } if ($removeRoot) { chmod($directory, 0777); @rmdir($directory); } } } }