1  <?php
  2  /* Copyright (c) 2012, Geert Bergman (geert@scrivo.nl)
  3   * All rights reserved.
  4   *
  5   * Redistribution and use in source and binary forms, with or without
  6   * modification, are permitted provided that the following conditions are met:
  7   *
  8   * 1. Redistributions of source code must retain the above copyright notice,
  9   *    this list of conditions and the following disclaimer.
 10   * 2. Redistributions in binary form must reproduce the above copyright notice,
 11   *    this list of conditions and the following disclaimer in the documentation
 12   *    and/or other materials provided with the distribution.
 13   * 3. Neither the name of "Scrivo" nor the names of its contributors may be
 14   *    used to endorse or promote products derived from this software without
 15   *    specific prior written permission.
 16   *
 17   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 18   * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 19   * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 20   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 21   * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 22   * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 23   * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 24   * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 25   * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 26   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 27   * POSSIBILITY OF SUCH DAMAGE.
 28   *
 29   * $Id: FileCache.php 841 2013-08-19 22:19:47Z geert $
 30   */
 31  
 32  /**
 33   * Implementation of the \Scrivo\Cache\FileCache class.
 34   */
 35  
 36  namespace Scrivo\Cache;
 37  
 38  /**
 39   * Implementation of a file cache.
 40   *
 41   * This is an implmentation of a file cache in PHP. It allows you to store
 42   * serializable objects on disk. It was designed to be a fallback for the
 43   * APC user cache, but nonetheless a very reasonable alternative.
 44   *
 45   * Some configuration notes. If possible use a RAM disk, apart from the speed
 46   * benefits it probably is also easier to limit the amount of space used by
 47   * the cache when using a RAM disk, else you'll have to resort to disk quotas.
 48   *
 49   * There is no software limit on the amount of data the cache uses. A garbage
 50   * collector is run each FileCache::$gcInterval store requests. If data
 51   * cannot be written the cache will acively purge data, keeping a certain
 52   * percentage of the most frequently used entries in the cache.
 53   *
 54   * Garbage collection only removes expired entries. When purging data another
 55   * alogrithm is used: Data that was stored but never used will be deleted
 56   * inmediately, the rest is sorted based on access time and then a given
 57   * percentage (FileCache::$pctToKeepAfterPurge) of the last accessed files
 58   * will be saved, the rest is removed.
 59   *
 60   * Note: one of the former versions supported file locking with flock. But
 61   * that does not work in a threaded web server and ISAPI. Since that is the
 62   * common case nowadays file locking is dropped. Is it thread save? I believe
 63   * so, but that is only because unserialize will return null when reading
 64   * corrupted (not fully written) entries. Furthermore beacuse of PHP's
 65   * problems with file locking cache slams are not allowed. So only the first
 66   * thread is allowed to write a file, later threads are not.
 67   */
 68  class FileCache implements \Scrivo\Cache {
 69  
 70      /**
 71       * The location of the cache directory.
 72       *
 73       * @var \Scrivo\String
 74       */
 75      private $dir;
 76  
 77      /**
 78       * The gargbage collection interval. This interval is measured in
 79       * number of store requests.
 80       *
 81       * @var int
 82       */
 83      private $gcInterval;
 84  
 85      /**
 86       * The counter for the number of store requests.
 87       *
 88       * @var int
 89       */
 90      private $storeCount 0;
 91  
 92      /**
 93       * Percentage of most frequently accessed file that will be kept after
 94       * the cache is purged when there's not enough space left.
 95       *
 96       * @var int
 97       */
 98      private $pctToKeepAfterPurge;
 99  
100      /**
101       * List of troublesome characters in file names.
102       *
103       * @var \Scrivo\String[]
104       */
105      private $reservedCharacters;
106  
107      /**
108       * List of character sequences to escape troublesome characters in file
109       * names.
110       *
111       * @var \Scrivo\String[]
112       */
113      private $escapedCharacters;
114  
115      /**
116       * Create a file cache.
117       *
118       * @param \Scrivo\String $dir The location where the cache schould
119       *   store the files. The default is the 'ScrivoCache' folder in the
120       *   system's temp directory.
121       * @param int $gcInterval The interval at which to run the garbage
122       *   collector measured in store requests.
123       * @param int $pctToKeepAfterPurge Percentage of items that were accessed
124       *   at least once that you want to keep after a cache purge due to
125       *   storage shortage.
126       */
127      public function __construct(\Scrivo\String $dir=null$gcInterval=50,
128              $pctToKeepAfterPurge=50) {
129          $this->gcInterval $gcInterval;
130          $this->pctToKeepAfterPurge $pctToKeepAfterPurge;
131          $this->reservedCharacters = \Scrivo\String::create(array(
132              "@","/","\\","?","%","*",":","|","\"","<",">","."));
133          $this->escapedCharacters = \Scrivo\String::create(array(
134              "@0","@1","@2","@3","@4","@5","@6","@7","@8","@9","@A","@B"));
135          if (!$dir) {
136              $dir sys_get_temp_dir()."/ScrivoCache";
137          }
138          if (!file_exists($dir)) {
139              mkdir($dir);
140          }
141          $this->dir = new \Scrivo\String($dir."/");
142      }
143  
144      /**
145       * Just a wrapper for fopen that throws an exception instead of an error.
146       *
147       * @param string $file
148       */
149  
150      private function fopen($file) {
151          if (!$fp fopen($file"w")) {
152              throw new \Scrivo\SystemException(
153                  "Could not create cache file '$file'");
154          }
155          return $fp;
156      }
157  
158      /**
159       * Just a wrapper for touch that throws an exception instead of returning
160       * a status.
161       *
162       * @param string $file
163       * @param string $ttl
164       */
165      private function touch($file$ttl) {
166          if (!touch($filetime()+$ttltime()-2)) {
167              throw new \Scrivo\SystemException(
168                  "Could not touch cache file '$file'");
169          }
170      }
171  
172      /**
173       * Just a wrapper for unlink that throws an exception instead of returning
174       * a status.
175       *
176       * @param string $file
177       */
178      private function unlink($file) {
179          if (!unlink($file)) {
180              throw new \Scrivo\SystemException(
181                  "Could not delete cache file '$file'");
182          }
183      }
184  
185      /**
186       * Create a file name from a key, taking in account problematic characters
187       * for a file name.
188       *
189       * @param \Scrivo\String $key Key name to create a file name for.
190       */
191      private function getFile(\Scrivo\String $key) {
192          return $this->dir $key->replace($this->reservedCharacters,
193              $this->escapedCharacters);
194      }
195  
196      /**
197       * Clear the cache from its most irrelevant items.
198       *
199       * You can use this function to free up a certain amount of the file cache
200       * First it deletes the items that were stored but never accessed. The
201       * items that were accessed at least once are sorted on access time and
202       * prec_to_keep of the most recently accessed files will be kept in the
203       * cache. The others will be deleted.
204       *
205       * @param int $percentageToKeep Percentage of items that were accessed
206       *    at least once that you want to keep.
207       */
208      public function purge($percentageToKeep=0) {
209          clearstatcache();
210          $m=0;
211          $ref = array();
212          if ($dh opendir($this->dir)) {
213              while (($file readdir($dh)) !== false) {
214                  $s stat($this->dir.$file);
215                  if (!($s["mode"] & 040000)) {
216                      if ($s["atime"] < $s["ctime"]) {
217                          // remove not yet accessed items
218                          $this->unlink($this->dir.$file);
219                          $m++;
220                      } else {
221                          $ref[$file] = $s["atime"];
222                      }
223                  }
224              }
225              closedir($dh);
226              // remove files that were accessed last
227              arsort($ref);
228              $n=0;
229              $i intval(count($ref)*$percentageToKeep/100);
230              $rem array_slice($ref0$i);
231              foreach ($rem as $k=>$d) {
232                  $this->unlink($this->dir.$k);
233                  $n++;
234              }
235          }
236          //error_log("Cache purged: $m items that were never accessed and " .
237          //    "$n of the most infrequently used items were removed.");
238      }
239  
240      /**
241       * Run the garbage collector: delete all expired cache entries.
242       */
243      private function gc() {
244          clearstatcache();
245          $i=0;
246          if ($dh opendir($this->dir)) {
247              while (($file readdir($dh)) !== false) {
248                  $s stat($this->dir.$file);
249                  if (!($s["mode"] & 040000)) {
250                      if ($s["mtime"] < time()) {
251                          $this->unlink($this->dir.$file);
252                          $i++;
253                      }
254                  }
255              }
256              closedir($dh);
257          }
258          //error_log("File cache garbage collected: $i items removed");
259      }
260  
261      /**
262       * Store a variable in the cache.
263       *
264       * Store any serializable variable in the cache. Note that it is not
265       * possible to overwrite an existing entry (cache slam). Such an event
266       * will not raise an error but the function will report it.
267       *
268       * @param \Scrivo\String $key A cache unique name for the key.
269       * @param mixed $val The (serializable) variabele to strore.
270       * @param int $ttl Time to live in seconds.
271       *
272       * @return int DATA_STORED if the variable was succesfully stored,
273       *       CACHE_SLAM if key already exists or NO_SPACE if there is not
274       *    enough space left to store the value.
275       *
276       * @throws \Scrivo\SystemException When trying to store a NULL value or
277       *    when a file operation fails.
278       */
279      public function store(\Scrivo\String $key$val$ttl=3600) {
280          if ($val === null) {
281              throw new \Scrivo\SystemException(
282                  "Can't store null values in the cache");
283          }
284          // The name for the cache file.
285          $file $this->getFile($key);
286          // If the file already exists, and other thread is already writing
287          // the file and we're done here.
288          if (file_exists($file)) {
289              return self::CACHE_SLAM;
290          }
291          // Run the garbage collector at the specified probability.
292          if (($this->storeCount $this->gcInterval) == $this->gcInterval 1) {
293              $this->gc();
294          }
295          // Get the data to store.
296          $data = new \Scrivo\ByteArray(serialize($val));
297          // Create the file and bail out not succesfull.
298          $fp $this->fopen($file);
299          // Write the data.
300          if (fwrite($fp, (string)$data) != $data->length) {
301              // Data was not fully written: drop infreq. used and unused entries.
302              fclose($fp);
303              $this->delete($key);
304              $this->purge($this->pctToKeepAfterPurge);
305              // try again
306              $fp $this->fopen($file);
307              if (fwrite($fp, (string)$data) != $data->length) {
308                  // Data was not fully written again: close and remove the file.
309                  fclose($fp);
310                  $this->delete($key);
311                  return self::NO_SPACE;
312              }
313          }
314          // Now we have a file touch it.
315          fclose($fp);
316          $this->touch($file$ttl);
317  
318          $this->storeCount++;
319          return self::DATA_STORED;
320      }
321  
322      /**
323       * Store a variable in the cache, overwrite it if it already exists.
324       *
325       * Store any serializable variable in the cache. It is guaranteed that
326       * the data will be written. But note that it is not guaranteed that the
327       * next fetch will retrieve this value.
328       *
329       * @param \Scrivo\String $key A cache unique name for the key.
330       * @param mixed $val The (serializable) variabele to strore.
331       * @param int $ttl Time to live in seconds.
332       *
333       * @return int DATA_STORED if the variable was succesfully stored or
334       *      NO_SPACE if there is not enough space left to store the value.
335       *
336       * @throws \Scrivo\SystemException When trying to store a NULL value or
337       *   when a file operation fails.
338       */
339      public function overwrite(\Scrivo\String $key$val$ttl=3600) {
340          // Theoretically just after delete an other thread can store a value.
341          for ($tmp=self::CACHE_SLAM$tmp==self::CACHE_SLAM;) {
342              $this->delete($key);
343              $tmp $this->store($key$val$ttl);
344          }
345          return $tmp;
346      }
347  
348      /**
349       * Delete/remove a cache entry.
350       *
351       * @param \Scrivo\String $key A cache unique name for the key.
352       */
353      public function delete(\Scrivo\String $key) {
354          $file $this->getFile($key);
355          if (file_exists($file)) {
356              $this->unlink($file);
357          }
358      }
359  
360      /**
361       * Retrieve a value from the cache.
362       *
363       * @param \Scrivo\String $key The key for which to retrieve the value.
364       *
365       * @return mixed The value of the stored variable or NULL if the key
366       *   does not exists or is expired.
367       */
368      public function fetch(\Scrivo\String $key) {
369          $file $this->getFile($key);
370          $res null;
371          // Suppressing the message is probably faster then calling file_exists
372          // and stat.
373          if ($s = @stat($file)) {
374              if ($s["mtime"] < time()) {
375                  $this->delete($key);
376              } else {
377                  $tmp file_get_contents($file);
378                  // if (strtoupper(substr(PHP_OS,0,3))==='WIN')
379                  //    touch($file,date('U',filemtime($file)),time());
380                  //
381                  if ($tmp) {
382                      $res unserialize($tmp);
383                  }
384              }
385          }
386          return $res $res null;
387      }
388  
389      /**
390       * List all entries in the cache.
391       *
392       * The cache list is an array in which the cache keys are the keys of
393       * the array entries and the data of the entries are objects ot type
394       * stdClass that contain at least contain the following properties:
395       *
396       * * accessed: the access time
397       * * expires: the expiration time
398       * * created: the creation time
399       * * size: the size of the entry
400       *
401       * @return object[] A array of objects that describe the current cache
402       *    entries.
403       */
404      public function entryList() {
405          $l = array();
406          if ($dh opendir($this->dir)) {
407              while (($file readdir($dh)) !== false) {
408                  $s stat($this->dir.$file);
409                  if (!($s['mode'] & 040000)) {
410                      $k = \Scrivo\String::create($file)->replace(
411                          $this->escapedCharacters$this->reservedCharacters);
412                      $l[(string)$k] = (object)array(
413                          "accessed" => $s["atime"],
414                          "expires" => $s["mtime"],
415                          "created" => $s["ctime"],
416                          "size" => $s["size"]
417                      );
418                  }
419              }
420              closedir($dh);
421          }
422          return $l;
423      }
424  
425  }
426  
427  ?>

Documentation generated by phpDocumentor 2.0.0a12 and ScrivoDocumentor on August 29, 2013