Skip to content

Commit

Permalink
Merge pull request #343 from thekid/feature/io-buffer
Browse files Browse the repository at this point in the history
Implement a limited in-memory buffer with filesystem overflow
  • Loading branch information
thekid authored Jun 23, 2024
2 parents 0d4a813 + 2faec8c commit 25a4366
Show file tree
Hide file tree
Showing 2 changed files with 232 additions and 0 deletions.
124 changes: 124 additions & 0 deletions src/main/php/io/streams/Buffer.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php namespace io\streams;

use io\{File, Folder};
use lang\{IllegalArgumentException, IllegalStateException};

/**
* Buffers in memory up until a given threshold, using the file system once
* it's exceeded.
*
* @see https://github.com/xp-forge/web/issues/118
* @test io.unittest.BufferTest
*/
class Buffer implements InputStream, OutputStream {
private $files, $threshold;
private $memory= '';
private $file= null;
private $size= 0;
private $pointer= 0;
private $draining= false;

/**
* Creates a new buffer
*
* @param io.Folder|io.Path|string $files
* @param int $threshold
* @throws lang.IllegalArgumentException
*/
public function __construct($files, int $threshold) {
if ($threshold < 0) {
throw new IllegalArgumentException('Threshold must be >= 0');
}

$this->files= $files instanceof Folder ? $files->getURI() : (string)$files;
$this->threshold= $threshold;
}

/** Returns buffer size */
public function size(): int { return $this->size; }

/** Returns the underlying file, if any */
public function file(): ?File { return $this->file; }

/** Returns whether this buffer is draining */
public function draining(): bool { return $this->draining; }

/**
* Write a string
*
* @param var $arg
* @return void
* @throws lang.IllegalStateException
*/
public function write($bytes) {
if ($this->draining) throw new IllegalStateException('Started draining buffer');

$this->size+= strlen($bytes);
if ($this->size <= $this->threshold) {
$this->memory.= $bytes;
return;
}

if (null === $this->file) {
$this->file= new File(tempnam($this->files, "b{$this->threshold}"));
$this->file->open(File::READWRITE);
$this->file->write($this->memory);
$this->memory= null;
}
$this->file->write($bytes);
}

/** @return void */
public function flush() {
$this->file && $this->file->flush();
}

/**
* Resets buffer to be able to read from the beginning
*
* @return void
*/
public function reset() {
$this->file ? $this->file->seek(0, SEEK_SET) : $this->pointer= 0;
$this->draining= true;
}

/** @return int */
public function available() {
return $this->draining
? $this->size - ($this->file ? $this->file->tell() : $this->pointer)
: $this->size
;
}

/**
* Read a string
*
* @param int $limit
* @return string
*/
public function read($limit= 8192) {
if ($this->file) {
$this->draining || $this->file->seek(0, SEEK_SET) && $this->draining= true;
return (string)$this->file->read($limit);
} else {
$this->draining= true;
$chunk= substr($this->memory, $this->pointer, $limit);
$this->pointer+= strlen($chunk);
return $chunk;
}
}

/** @return void */
public function close() {
if (null === $this->file || !$this->file->isOpen()) return;

$this->file->close();
$this->file->unlink();
}

/** Ensure the file (if any) is closed */
public function __destruct() {
$this->close();
}
}
108 changes: 108 additions & 0 deletions src/test/php/io/unittest/BufferTest.class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php namespace io\unittest;

use io\File;
use io\streams\Buffer;
use lang\{Environment, IllegalArgumentException, IllegalStateException};
use test\{Assert, Before, Test, Values};

class BufferTest {
const THRESHOLD= 128;
private $temp;

#[Before]
public function temp() {
$this->temp= Environment::tempDir();
}

#[Test]
public function can_create() {
new Buffer($this->temp, self::THRESHOLD);
}

#[Test]
public function threshold_must_be_larger_than_zero() {
Assert::throws(IllegalArgumentException::class, fn() => new Buffer($this->temp, -1));
}

#[Test, Values([1, 127, 128])]
public function uses_memory_under_threshold($length) {
$bytes= str_repeat('*', $length);

$fixture= new Buffer($this->temp, self::THRESHOLD);
$fixture->write($bytes);

Assert::null($fixture->file());
Assert::equals($length, $fixture->size());
Assert::equals($bytes, $fixture->read());
}

#[Test, Values([129, 256, 1024])]
public function stores_file_when_exceeding_threshold($length) {
$bytes= str_repeat('*', $length);

$fixture= new Buffer($this->temp, self::THRESHOLD);
$fixture->write($bytes);

Assert::instance(File::class, $fixture->file());
Assert::equals($length, $fixture->size());
Assert::equals($bytes, $fixture->read());
}

#[Test, Values([127, 128, 129])]
public function read_after_eof($length) {
$bytes= str_repeat('*', $length);

$fixture= new Buffer($this->temp, self::THRESHOLD);
$fixture->write($bytes);

Assert::equals($length, $fixture->available());
Assert::equals($bytes, $fixture->read());
Assert::equals(0, $fixture->available());
Assert::equals('', $fixture->read());
}

#[Test, Values([127, 128, 129])]
public function reset($length) {
$bytes= str_repeat('*', $length);

$fixture= new Buffer($this->temp, self::THRESHOLD);
$fixture->write($bytes);

Assert::equals($length, $fixture->available());
Assert::equals($bytes, $fixture->read());

$fixture->reset();

Assert::equals($length, $fixture->available());
Assert::equals($bytes, $fixture->read());
}

#[Test]
public function cannot_write_after_draining_started() {
$fixture= new Buffer($this->temp, self::THRESHOLD);
$fixture->write('Test');
Assert::false($fixture->draining());

$fixture->read();
Assert::true($fixture->draining());
Assert::throws(IllegalStateException::class, fn() => $fixture->write('Test'));
}

#[Test]
public function file_deleted_on_close() {
$fixture= new Buffer($this->temp, 0);
$fixture->write('Test');

$fixture->close();
Assert::false($fixture->file()->exists());
}

#[Test]
public function double_close() {
$fixture= new Buffer($this->temp, 0);
$fixture->write('Test');

$fixture->close();
$fixture->close();
}
}

0 comments on commit 25a4366

Please sign in to comment.