Lesson 18 / 21
File Storage
The disk abstraction: the local disk and the in-memory disk behind the same interface; the same upload call running on two disks, a disk not named in the configuration not being wired, and a change of disk showing up in the life of the file and not in the response.
Published Updated
In this lesson
- Uses the local disk and the in-memory disk interchangeably behind the same interface
- Measures that the same upload call ran on both disks and that the controller stayed unaware of the disk
- Shows that a disk not named in the configuration is not wired to the path and that a change of disk changes the life of the file
Contents
In the Caching lesson a stored value stayed stale as long as it was unaware of a change at its source. That lesson kept its values in an array; a file is a different thing from a value: it is large, it has to live outside the response, and where it is written may change during the life of the application. A developer writes the uploaded file to a working directory today and wants to move it tomorrow to a separate service known as object storage. This lesson asks: when the place of storage changes, does the upload code change too?
In this lesson a disk is the name of a place where a file can be written and read; it is not a product name. A disk abstraction means the controller knows only the write, read and exists contract and does not know where the file goes. An interface carries the contract, and each disk implements it. The lesson has two disks: a local disk that writes to a folder in the working directory, and an in-memory disk that keeps the files in an array. The in-memory disk is a model of real object storage: a file is stored under a path name, but there is no directory tree, permission or driver; there is only a name and content. A configuration says which disk will be used: names are mapped to disks and the default name is chosen separately.
AS6. The local disk is a folder opened for this lesson under the working directory; it is deleted when the lesson ends and the path is not printed in the output. The in-memory disk is an array and is lost when the process ends. Cloud storage, network latency, credentials, concurrent writes and file permissions are not modeled; the in-memory disk carries only the “content under a name” model of object storage. Writing a file is a method call, and a failed write is not modeled.
AS7. Resolution is limited to the names in the configuration. If the requested name is not in the configuration, it does not fall back to the default disk; Storage raises an error and the controller turns that error into a 500 response. A silent backup disk is intentionally absent in this lesson, because it would cause a file to live in an unexpected place.
The Measurement Core
The same measurement is used in every lesson of the course. ran.php records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. Ledger keeps three lists: the parts placed on the request’s path, the parts called within the request, and the parts left outside the request. classify() checks them in order. The decision order is classify() itself: ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request. In this lesson the onPath() call marks that a disk was resolved from the configuration, and the ran() call marks that the write() method of that disk ran.
<?php // ran.php — did an expected part run in this request? declare(strict_types=1); final class Ledger { public static array $path = []; // parts placed on the request's path public static array $ran = []; // parts called within the request public static array $deferred = []; // parts left outside the request public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; } public static function onPath(string $part): void { self::$path[] = $part; } public static function ran(string $part): void { self::$ran[] = $part; } public static function defer(string $part): void { self::$deferred[] = $part; } } function classify(string $part): string { return match (true) { in_array($part, Ledger::$ran, true) => 'ran', in_array($part, Ledger::$deferred, true) => 'deferred', in_array($part, Ledger::$path, true) => 'skipped', default => 'not wired', }; }
The Disk Interface and the Two Disks
storage.php defines four things. The Disk interface gives the contract. LocalDisk writes the file to the folder in the working directory with file_put_contents; the cleanup() method is not part of the interface and exists only to delete the lesson’s own leftovers. MemoryDisk implements the same contract with an array. Storage maps names to disks and resolves the name in the disk() call: if no name is given the default is used, and if the name is not in the configuration it throws InvalidArgumentException. Finally, UploadController takes a file and its content, places the upload part on the request’s path, leaves its own mark and writes the file to the chosen disk.
There is not a single expression in the controller’s body that says which disk is used. The disk($disk) call returns a Disk, and the write() method comes from the contract. When the disk changes, what changes is which name is given to the Storage object; the controller sees that this information comes only from outside.
<?php // storage.php — the disk interface, the local disk, the in-memory disk and the configuration declare(strict_types=1); require_once 'ran.php'; interface Disk { public function write(string $path, string $content): void; public function read(string $path): string; public function exists(string $path): bool; } final class LocalDisk implements Disk { public function __construct(private string $root) { if (!is_dir($root)) mkdir($root, 0777, true); } public function write(string $path, string $content): void { Ledger::ran('local'); file_put_contents($this->root . '/' . $path, $content); } public function read(string $path): string { return file_get_contents($this->root . '/' . $path); } public function exists(string $path): bool { return is_file($this->root . '/' . $path); } public function cleanup(): void { foreach (glob($this->root . '/*') as $file) unlink($file); rmdir($this->root); } } final class MemoryDisk implements Disk { private array $files = []; public function write(string $path, string $content): void { Ledger::ran('memory'); $this->files[$path] = $content; } public function read(string $path): string { return $this->files[$path]; } public function exists(string $path): bool { return isset($this->files[$path]); } } final class Storage { private array $disks = []; public function __construct(private string $default) {} public function add(string $name, Disk $disk): void { $this->disks[$name] = $disk; } public function disk(?string $name = null): Disk { $name ??= $this->default; if (!isset($this->disks[$name])) { throw new InvalidArgumentException("disk not in configuration: $name"); } Ledger::onPath($name); return $this->disks[$name]; } } final class UploadController { public function __construct(private Storage $storage) {} public function upload(string $file, string $content, ?string $disk = null): array { Ledger::newRequest(); Ledger::onPath('upload'); Ledger::ran('upload'); try { $this->storage->disk($disk)->write($file, $content); } catch (InvalidArgumentException $error) { return [500, 'no disk']; } return [201, 'uploaded']; } }
The Same Call Ran on Two Disks
The driver makes the same upload() call three times: without a name, with the name memory, and with the name remote, which is not in the configuration. After each call the outcome of the three parts is printed. In the first call the default disk is local; the controller does not know the disk name when it is called, and the file still goes to the local disk. In the second call the same controller, the same code, the same file name and the same content write to the memory disk. Both calls return 201 and not one line in the controller has changed.
<?php // disk.php — the same upload() call on two disks, the third disk is not in the configuration require_once 'storage.php'; $local = new LocalDisk('store-lesson06'); $storage = new Storage('local'); $storage->add('local', $local); $storage->add('memory', new MemoryDisk()); $controller = new UploadController($storage); foreach ([null, 'memory', 'remote'] as $name) { $response = $controller->upload('report.txt', 'hello', $name); printf("upload(disk: %s) -> %d %s\n", $name ?? 'default', $response[0], $response[1]); foreach (['local', 'memory', 'remote'] as $part) printf(" %-6s %s\n", $part, classify($part)); } echo 'read from local disk: ', $local->read('report.txt'), "\n"; $local->cleanup();
upload(disk: default) -> 201 uploaded local ran memory not wired remote not wired upload(disk: memory) -> 201 uploaded local not wired memory ran remote not wired upload(disk: remote) -> 500 no disk local not wired memory not wired remote not wired read from local disk: hello
In the first call local ran, because it was both placed on the path and called; memory, although defined in the configuration, was not wired, because it was not resolved in this request. In the second call the roles swap. These two outputs together give the outcome of the lesson: the same write() call ran on both disks. The measurement does not say “written”, it says “ran”; whether the file really reached its place is shown separately by the last line of the driver, the hello read from the local disk. The result of the third call is the subject of a later section.
The Read Side Goes Through the Same Contract
The write side has been measured; the same question can be asked of the read side. reading.php writes the same two-line content to both disks, then reads the content back from each disk and calls exists() for two separate file names: summary.txt, which was written, and missing.txt, which was never written. The interface promises only a byte count and a true or false value; the measurement looks at whether the two disks keep this promise in the same way.
<?php // reading.php — the same write, read and exists calls on two disks require_once 'storage.php'; $disks = ['local' => new LocalDisk('store-lesson06c'), 'memory' => new MemoryDisk()]; foreach ($disks as $name => $disk) { $disk->write('summary.txt', "first line\nline two"); printf("%-6s read: %d bytes, summary.txt exists: %s, missing.txt exists: %s\n", $name, strlen($disk->read('summary.txt')), $disk->exists('summary.txt') ? 'yes' : 'no', $disk->exists('missing.txt') ? 'yes' : 'no'); } $disks['local']->cleanup();
local read: 19 bytes, summary.txt exists: yes, missing.txt exists: no memory read: 19 bytes, summary.txt exists: yes, missing.txt exists: no
The two lines are the same: the content returns as 19 bytes, the written file exists and the unwritten file does not. The exists() method does not throw an error for a file that does not exist; it returns false, so asking whether a file exists before reading is safe on both disks. What remains outside the same contract is what the read() method does on a file that does not exist: the interface does not define it, and the two disks may behave differently in that case. For this reason the lesson counts asking exists() before reading as part of the contract and keeps this assumption in writing.
The Contrast: A Disk Not in the Configuration Was Not Wired
In the third call the name remote was given. Storage::disk() could not find the name in the configuration, threw the error before placing a mark on the path, and the controller turned the error into a 500 response. In the last block of the output none of the three parts ran: local and memory were never resolved in this request, and remote did not even enter the path because it is not in the configuration. All three were not wired.
It is important to see why this is not skipped. A part is skipped when it is on the path and is not called. remote was never placed on the path in this request, because resolution leaves no mark until the name is valid. So the deviation did not arise from a branch of the code being skipped but because the connection itself could not be made. The form of the same error as “the disk exists but the write failed” is not in this lesson’s model; there the onPath mark would be left and the ran mark inside write() would be missing, and that would be read as skipped.
The practical meaning of the contrast is that a configuration error fails loudly instead of silently writing somewhere else. The file is on no disk and the client gets a 500; falling back to a silent default disk would have caused the uploaded file to live in an unexpected place. Here the measurement says only “a part was not wired”; it does not say whether the error was a configuration error or a spelling mistake.
The Bounding Measurement
The previous two sections said “the controller is unaware of the disk”; this section measures what that does not cover. When the disk changed, the controller did not change, but the life of the file did. persistence.php uses the two disks for the same job: it writes the file, then builds a new disk instance of the same kind and checks whether the file is there. The new instance stands for a component rebuilt in a new request.
<?php // persistence.php — the same interface, a different life require_once 'storage.php'; foreach (['local', 'memory'] as $kind) { $build = fn(): Disk => $kind === 'local' ? new LocalDisk('store-lesson06b') : new MemoryDisk(); $first = $build(); $first->write('note.txt', 'persistent'); $second = $build(); // a new instance: the same interface, built from scratch printf("%-6s first exists: %s, exists in new instance: %s\n", $kind, $first->exists('note.txt') ? 'yes' : 'no', $second->exists('note.txt') ? 'yes' : 'no'); } (new LocalDisk('store-lesson06b'))->cleanup();
local first exists: yes, exists in new instance: yes memory first exists: yes, exists in new instance: no
On the local disk the new instance sees the file, because the file stays in the folder and both instances look at the same folder. On the in-memory disk the new instance is empty; the file stayed in the first instance’s array. The interface is the same in both cases: the exists() method is called on both disks and both return a true or false value. The difference is outside the interface, in the life of the disk.
The consequence is the answer to the question asked in the Next Step of the Caching lesson: when the disk changed, the response did not change, but whether the file the response relied on exists in the next request did. The abstraction hides the form of the call; it does not hide the persistence contract of the call. The controller’s upload code was not affected by the change of disk; but code that reads the uploaded file in a later request would get a wrong result with the in-memory disk. This measurement only compares the persistence of the two disks; it does not measure the consistency and latency behavior of real object storage, a network failure or a large file.
Summary
- This lesson’s outcome is ran: the same
upload()call ran thewrite()method on the local disk and on the in-memory disk, and the controller stayed unaware of the change of disk. - The
Diskinterface gives thewrite,readandexistscontract;Storagemaps names to disks and keeps the default separate. - Its contrast: the
remotedisk, which is not in the configuration, did not enter the path and was not wired; the controller returned 500 and the file went to no disk. - In the bounding measurement the new disk instance saw the file on the local disk and did not see the file on the in-memory disk; the abstraction hides the form of the call, not the persistence.
- When the disk changes the controller does not change, but the assumption of the code that reads the file later may change.
Next Step
The uploaded file is now written to a disk, and which disk it is written to does not concern the controller. But all of this work is still inside a request, done before the response comes. If some work has to repeat by itself every minute or every day, without being tied to any request, who will run it, and when does this work count as having run from the request’s point of view? The Scheduled Tasks lesson defines a recurring task, runs it on the scheduler’s tick, and measures whether the request responds without waiting for this task.
To keep your progress and take notes, Log in
My notes
Log in to take notes.