Skip to content

Commit 16350e2

Browse files
authored
feat(io): introduce IO\spool for memory backed handles that spill to disk (#615)
1 parent 98ec362 commit 16350e2

File tree

7 files changed

+290
-1
lines changed

7 files changed

+290
-1
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 5.3.0
4+
5+
### features
6+
7+
* feat(io): introduce `IO\spool()` for memory-backed handles that spill to disk
8+
39
## 5.2.0
410

511
### features

docs/content/io/io.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ In non-CLI SAPIs, `input_handle()` reads from `php://input` and `output_handle()
4141

4242
@example('io/io-reader.php')
4343

44+
## Spool
45+
46+
`IO\spool()` creates a handle that writes to memory until a threshold is reached (default 2MB), then transparently spools to a temporary file on disk. This is useful when buffering data of unknown size without risking excessive memory usage.
47+
48+
@example('io/io-spool.php')
49+
4450
## Pipes
4551

4652
`IO\pipe()` creates a connected pair of handles: anything written to the write end can be read from the read end.

docs/examples/io/io-spool.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
require_once __DIR__ . '/../../../vendor/autoload.php';
6+
7+
use Psl\IO;
8+
use Psl\Str;
9+
10+
// Writes to memory until 2MB, then spools to a temporary file on disk
11+
$handle = IO\spool();
12+
13+
$handle->writeAll('Hello, World!');
14+
$handle->seek(0);
15+
$handle->readAll(); // 'Hello, World!'
16+
17+
$handle->close();
18+
19+
// Custom threshold: spool to disk after 64 bytes
20+
$small = IO\spool(maxMemory: 64);
21+
$small->writeAll(Str\repeat('x', 256)); // transparently written to disk
22+
$small->seek(0);
23+
$small->readAll(); // 256 bytes of 'x'
24+
25+
$small->close();

src/Psl/IO/Internal/ResourceHandle.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public function __construct(
9090
$this->useSingleRead = 'udp_socket' === $meta['stream_type'] || 'STDIO' === $meta['stream_type'];
9191
}
9292

93-
$blocks = $meta['blocked'] || ($meta['wrapper_type'] ?? '') === 'plainfile';
93+
$blocks = ($meta['blocked'] ?? true) || ($meta['wrapper_type'] ?? '') === 'plainfile';
9494
if ($seek) {
9595
$seekable = $meta['seekable'];
9696

src/Psl/IO/spool.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psl\IO;
6+
7+
use Psl;
8+
use Psl\Internal;
9+
10+
use function fopen;
11+
12+
/**
13+
* Create a handle that writes to memory until a threshold is reached,
14+
* then transparently spools to a temporary file on disk.
15+
*
16+
* @param int<0, max> $maxMemory The maximum number of bytes to keep in memory (default 2MB).
17+
*/
18+
function spool(int $maxMemory = 2_097_152): CloseSeekReadWriteStreamHandle
19+
{
20+
$stream = Internal\suppress(
21+
/**
22+
* @return resource
23+
*/
24+
static function () use ($maxMemory): mixed {
25+
$stream = fopen("php://temp/maxmemory:{$maxMemory}", 'w+b');
26+
// @codeCoverageIgnoreStart
27+
if ($stream === false) {
28+
$error = error_get_last();
29+
$message = $error['message'] ?? 'Unable to create a temporary stream.';
30+
Psl\invariant_violation($message);
31+
}
32+
33+
// @codeCoverageIgnoreEnd
34+
35+
return $stream;
36+
},
37+
);
38+
39+
return new CloseSeekReadWriteStreamHandle($stream);
40+
}

src/Psl/Internal/Loader.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ final class Loader
666666
'Psl\\IO\\output_handle' => 'Psl/IO/output_handle.php',
667667
'Psl\\IO\\error_handle' => 'Psl/IO/error_handle.php',
668668
'Psl\\IO\\pipe' => 'Psl/IO/pipe.php',
669+
'Psl\\IO\\spool' => 'Psl/IO/spool.php',
669670
'Psl\\Class\\exists' => 'Psl/Class/exists.php',
670671
'Psl\\Class\\defined' => 'Psl/Class/defined.php',
671672
'Psl\\Class\\has_constant' => 'Psl/Class/has_constant.php',

tests/unit/IO/SpoolTest.php

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psl\Tests\Unit\IO;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Psl\IO;
9+
10+
final class SpoolTest extends TestCase
11+
{
12+
public function testReturnsCloseSeekReadWriteStreamHandle(): void
13+
{
14+
$handle = IO\spool();
15+
16+
static::assertInstanceOf(IO\CloseSeekReadWriteStreamHandle::class, $handle);
17+
18+
$handle->close();
19+
}
20+
21+
public function testWriteAndReadBack(): void
22+
{
23+
$handle = IO\spool();
24+
25+
$handle->writeAll('hello, world!');
26+
$handle->seek(0);
27+
28+
static::assertSame('hello, world!', $handle->readAll());
29+
30+
$handle->close();
31+
}
32+
33+
public function testSeekAndTell(): void
34+
{
35+
$handle = IO\spool();
36+
37+
$handle->writeAll('abcdef');
38+
39+
static::assertSame(6, $handle->tell());
40+
41+
$handle->seek(3);
42+
43+
static::assertSame(3, $handle->tell());
44+
static::assertSame('def', $handle->readAll());
45+
46+
$handle->close();
47+
}
48+
49+
public function testReadEmpty(): void
50+
{
51+
$handle = IO\spool();
52+
53+
static::assertSame('', $handle->readAll());
54+
55+
$handle->close();
56+
}
57+
58+
public function testMultipleWrites(): void
59+
{
60+
$handle = IO\spool();
61+
62+
$handle->writeAll('foo');
63+
$handle->writeAll('bar');
64+
$handle->writeAll('baz');
65+
66+
$handle->seek(0);
67+
68+
static::assertSame('foobarbaz', $handle->readAll());
69+
70+
$handle->close();
71+
}
72+
73+
public function testOverwrite(): void
74+
{
75+
$handle = IO\spool();
76+
77+
$handle->writeAll('hello');
78+
$handle->seek(0);
79+
$handle->writeAll('world');
80+
$handle->seek(0);
81+
82+
static::assertSame('world', $handle->readAll());
83+
84+
$handle->close();
85+
}
86+
87+
public function testPartialRead(): void
88+
{
89+
$handle = IO\spool();
90+
91+
$handle->writeAll('hello, world!');
92+
$handle->seek(0);
93+
94+
static::assertSame('hello', $handle->read(5));
95+
static::assertSame(', world!', $handle->readAll());
96+
97+
$handle->close();
98+
}
99+
100+
public function testLargeDataSpoolsToDisk(): void
101+
{
102+
$handle = IO\spool(maxMemory: 64);
103+
$data = str_repeat('x', 256);
104+
105+
$handle->writeAll($data);
106+
$handle->seek(0);
107+
108+
static::assertSame($data, $handle->readAll());
109+
110+
$handle->close();
111+
}
112+
113+
public function testDefaultMaxMemory(): void
114+
{
115+
$handle = IO\spool();
116+
117+
$data = str_repeat('a', 1024);
118+
$handle->writeAll($data);
119+
$handle->seek(0);
120+
121+
static::assertSame($data, $handle->readAll());
122+
123+
$handle->close();
124+
}
125+
126+
public function testZeroMaxMemorySpoolsImmediately(): void
127+
{
128+
$handle = IO\spool(maxMemory: 0);
129+
130+
$handle->writeAll('test');
131+
$handle->seek(0);
132+
133+
static::assertSame('test', $handle->readAll());
134+
135+
$handle->close();
136+
}
137+
138+
public function testEofAfterReadAll(): void
139+
{
140+
$handle = IO\spool();
141+
142+
$handle->writeAll('data');
143+
$handle->seek(0);
144+
$handle->readAll();
145+
146+
static::assertTrue($handle->reachedEndOfDataSource());
147+
148+
$handle->close();
149+
}
150+
151+
public function testNotEofBeforeRead(): void
152+
{
153+
$handle = IO\spool();
154+
155+
$handle->writeAll('data');
156+
$handle->seek(0);
157+
158+
static::assertFalse($handle->reachedEndOfDataSource());
159+
160+
$handle->close();
161+
}
162+
163+
public function testCloseThrowsOnSubsequentOperations(): void
164+
{
165+
$handle = IO\spool();
166+
$handle->writeAll('test');
167+
$handle->close();
168+
169+
$this->expectException(IO\Exception\AlreadyClosedException::class);
170+
171+
$handle->read();
172+
}
173+
174+
public function testSeekToBeginningAfterWrite(): void
175+
{
176+
$handle = IO\spool();
177+
178+
$handle->writeAll('first');
179+
$handle->seek(0);
180+
$handle->writeAll('FIRST');
181+
$handle->seek(0);
182+
183+
static::assertSame('FIRST', $handle->readAll());
184+
185+
$handle->close();
186+
}
187+
188+
public function testGetStream(): void
189+
{
190+
$handle = IO\spool();
191+
192+
static::assertIsResource($handle->getStream());
193+
194+
$handle->close();
195+
}
196+
197+
public function testTryReadAndTryWrite(): void
198+
{
199+
$handle = IO\spool();
200+
201+
$written = $handle->tryWrite('hello');
202+
static::assertSame(5, $written);
203+
204+
$handle->seek(0);
205+
206+
$data = $handle->tryRead(5);
207+
static::assertSame('hello', $data);
208+
209+
$handle->close();
210+
}
211+
}

0 commit comments

Comments
 (0)