diff --git a/README.md b/README.md index c706c3c..09b7de8 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,24 @@ Penelope is designed to handle large file operations efficiently by utilizing PH - **Flexible**: Support for both synchronous and asynchronous operations - **Transformable**: Apply custom transformations during read/write operations - **Progress Tracking**: Monitor write progress in real-time +- **Compression Support**: Built-in support for gzip, bzip2, and deflate compression +- **Error Resilience**: Robust error handling with retry mechanisms and logging ## šŸ“‹ Requirements - PHP 8.1 or higher (Fiber support required) - Composer for dependency management +- PHP Extensions: + - `zlib` for gzip/deflate compression + - `bz2` for bzip2 compression (optional) ## šŸ›  Installation ```bash composer require cmatosbc/penelope + +# For bzip2 support (Ubuntu/Debian) +sudo apt-get install php-bz2 ``` ## šŸ“– Usage @@ -57,47 +65,95 @@ while ($fiber->isSuspended()) { } ``` -### Basic File Writing +### Compression Support ```php -$handler = new AsyncFileHandler('output.txt', 'w'); +use Penelope\Compression\CompressionHandler; -// Synchronous write -$written = $handler->writeSync($data); +// Create a compression handler (gzip, bzip2, or deflate) +$compression = new CompressionHandler('gzip', 6); // level 6 compression -// Asynchronous write with progress tracking -$fiber = $handler->writeAsync($data); +// Compress data +$compressed = $compression->compress($data); -$progress = $fiber->start(); -while ($fiber->isSuspended()) { - $progress = $fiber->resume(); - if ($progress !== null) { - echo "Progress: {$progress['progress']}%\n"; - } -} +// Decompress data +$decompressed = $compression->decompress($compressed); + +// Get file extension for compressed files +$extension = $compression->getFileExtension(); // Returns .gz for gzip ``` -### Data Transformation +### Error Handling with Retries ```php -$handler = new AsyncFileHandler('data.txt', 'r'); - -// Set up a transformation (e.g., remove whitespace) -$handler->setTransformCallable(function(string $chunk): string { - return preg_replace('/\s+/', '', $chunk); -}); +use Penelope\Error\ErrorHandler; +use Penelope\Error\RetryPolicy; +use Psr\Log\LoggerInterface; + +// Create a retry policy with custom settings +$retryPolicy = new RetryPolicy( + maxAttempts: 3, // Maximum number of retry attempts + delayMs: 100, // Initial delay between retries in milliseconds + backoffMultiplier: 2.0, // Multiplier for exponential backoff + maxDelayMs: 5000 // Maximum delay between retries +); + +// Create an error handler with custom logger (optional) +$errorHandler = new ErrorHandler($logger, $retryPolicy); + +// Execute an operation with retry logic +try { + $result = $errorHandler->executeWithRetry( + function() { + // Your operation here + return $someResult; + }, + 'Reading file chunk' + ); +} catch (\RuntimeException $e) { + // Handle final failure after all retries +} +``` -// Read with transformation -$fiber = $handler->readAsync(); -$content = ''; +### Combining Features -$chunk = $fiber->start(); -while ($chunk !== null || $fiber->isSuspended()) { - if ($chunk !== null) { - $content .= $chunk; // Chunk is already transformed - } - $chunk = $fiber->resume(); -} +```php +use Penelope\AsyncFileHandler; +use Penelope\Compression\CompressionHandler; +use Penelope\Error\ErrorHandler; +use Penelope\Error\RetryPolicy; + +// Set up handlers +$compression = new CompressionHandler('gzip'); +$retryPolicy = new RetryPolicy(maxAttempts: 3); +$errorHandler = new ErrorHandler(null, $retryPolicy); +$fileHandler = new AsyncFileHandler('large_file.txt', 'r'); + +// Read and compress file with retry logic +$errorHandler->executeWithRetry( + function() use ($fileHandler, $compression) { + $fiber = $fileHandler->readAsync(); + $compressedContent = ''; + + // Start reading + $chunk = $fiber->start(); + if ($chunk !== null) { + $compressedContent .= $compression->compress($chunk); + } + + // Continue reading + while ($fiber->isSuspended()) { + $chunk = $fiber->resume(); + if ($chunk !== null) { + $compressedContent .= $compression->compress($chunk); + } + } + + // Write compressed content + file_put_contents('output.gz', $compressedContent); + }, + 'Compressing file' +); ``` ## šŸŽÆ Use Cases @@ -116,30 +172,19 @@ while ($chunk = $fiber->resume()) { } ``` -### 2. Real-time Data Transformation -Ideal for data sanitization, format conversion, or content filtering: - -```php -$handler = new AsyncFileHandler('input.csv', 'r'); -$handler->setTransformCallable(function($chunk) { - return str_replace(',', ';', $chunk); // Convert CSV to semicolon-separated -}); -``` +### 2. File Compression and Archiving -### 3. Progress Monitoring -Perfect for long-running file operations in web applications: +- Compress large log files for archival +- Create compressed backups of data files +- Stream compressed data to remote storage +- Process and compress multiple files in parallel -```php -$handler = new AsyncFileHandler('large_file.txt', 'w'); -$fiber = $handler->writeAsync($data); +### 3. Error-Resilient Operations -while ($fiber->isSuspended()) { - $progress = $fiber->resume(); - if ($progress !== null) { - updateProgressBar($progress['progress']); - } -} -``` +- Retry failed network file transfers +- Handle intermittent I/O errors gracefully +- Log detailed error information for debugging +- Implement progressive backoff for rate-limited operations ## šŸ” Performance @@ -154,7 +199,7 @@ Based on our benchmarks with a 100MB file: ```bash composer install -./vendor/bin/phpunit +./vendor/bin/phpunit --testdox ``` ## šŸ¤ Contributing diff --git a/composer.json b/composer.json index 82de9e9..8805e23 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,9 @@ "license": "gpl-3.0-or-later", "minimum-stability": "dev", "require": { - "php": ">=8.1" + "php": ">=8.1", + "psr/log": "^3.0", + "monolog/monolog": "^3.0" }, "require-dev": { "phpunit/phpunit": "^10.0" diff --git a/phpunit.xml b/phpunit.xml index 112c82c..deace2b 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,7 +1,7 @@ - - tests + + tests/AsyncFileHandlerTest.php + + + tests/Compression + + + tests/PerformanceTest.php diff --git a/src/Compression/CompressionHandler.php b/src/Compression/CompressionHandler.php new file mode 100644 index 0000000..436e325 --- /dev/null +++ b/src/Compression/CompressionHandler.php @@ -0,0 +1,97 @@ + 9) { + throw new RuntimeException("Compression level must be between 1 and 9"); + } + + $this->algorithm = $algorithm; + $this->level = $level; + + // Verify that the required extension is loaded + $this->verifyExtension(); + } + + public function compress(string $data): string + { + switch ($this->algorithm) { + case 'gzip': + return gzencode($data, $this->level); + case 'bzip2': + return bzcompress($data, $this->level); + case 'deflate': + return gzdeflate($data, $this->level); + default: + throw new RuntimeException("Unsupported compression algorithm"); + } + } + + public function decompress(string $data): string + { + switch ($this->algorithm) { + case 'gzip': + $result = gzdecode($data); + break; + case 'bzip2': + $result = bzdecompress($data); + break; + case 'deflate': + $result = gzinflate($data); + break; + default: + throw new RuntimeException("Unsupported compression algorithm"); + } + + if ($result === false) { + throw new RuntimeException("Failed to decompress data"); + } + + return $result; + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function getFileExtension(): string + { + return match($this->algorithm) { + 'gzip' => '.gz', + 'bzip2' => '.bz2', + 'deflate' => '.zz', + default => throw new RuntimeException("Unsupported compression algorithm"), + }; + } + + private function verifyExtension(): void + { + $required = match($this->algorithm) { + 'gzip', 'deflate' => 'zlib', + 'bzip2' => 'bz2', + default => throw new RuntimeException("Unsupported compression algorithm"), + }; + + if (!extension_loaded($required)) { + throw new RuntimeException("Required extension not loaded: {$required}"); + } + } +} diff --git a/src/Compression/CompressionInterface.php b/src/Compression/CompressionInterface.php new file mode 100644 index 0000000..4ca0968 --- /dev/null +++ b/src/Compression/CompressionInterface.php @@ -0,0 +1,32 @@ +logger = $logger ?? $this->createDefaultLogger(); + $this->retryPolicy = $retryPolicy ?? new RetryPolicy(); + } + + /** + * Execute an operation with retry and logging + * @param callable $operation Operation to execute + * @param string $context Description of the operation for logging + * @return mixed Result of the operation + * @throws RuntimeException If operation fails after all retries + */ + public function executeWithRetry(callable $operation, string $context): mixed + { + return $this->retryPolicy->execute( + $operation, + function (int $attempt, int $delay, \Exception $error) use ($context) { + $this->logger->warning( + "{$context} failed (attempt {$attempt}), retrying in {$delay}ms", + [ + 'error' => $error->getMessage(), + 'attempt' => $attempt, + 'delay_ms' => $delay + ] + ); + } + ); + } + + /** + * Log an error with context + */ + public function logError(\Throwable $error, string $context, array $extra = []): void + { + $this->logger->error( + "{$context}: {$error->getMessage()}", + array_merge( + [ + 'error_class' => get_class($error), + 'file' => $error->getFile(), + 'line' => $error->getLine(), + 'trace' => $error->getTraceAsString() + ], + $extra + ) + ); + } + + /** + * Create a default logger that writes to a file + */ + private function createDefaultLogger(): LoggerInterface + { + $logger = new Logger('penelope'); + $logger->pushHandler(new StreamHandler( + dirname(__DIR__, 2) . '/var/log/penelope.log', + Logger::DEBUG + )); + return $logger; + } +} diff --git a/src/Error/RetryPolicy.php b/src/Error/RetryPolicy.php new file mode 100644 index 0000000..0c1f356 --- /dev/null +++ b/src/Error/RetryPolicy.php @@ -0,0 +1,95 @@ +maxAttempts = $maxAttempts; + $this->delayMs = $delayMs; + $this->backoffMultiplier = $backoffMultiplier; + $this->maxDelayMs = $maxDelayMs; + } + + /** + * Execute a callback with retry logic + * @param callable $callback Function to execute + * @param callable|null $onRetry Optional callback to execute before each retry + * @return mixed Result from the callback + * @throws \Exception If all retry attempts fail + */ + public function execute(callable $callback, ?callable $onRetry = null): mixed + { + $attempt = 1; + $currentDelay = $this->delayMs; + $lastException = null; + + while ($attempt <= $this->maxAttempts) { + try { + return $callback(); + } catch (\Exception $e) { + $lastException = $e; + + if ($attempt === $this->maxAttempts) { + break; + } + + if ($onRetry) { + $onRetry($attempt, $currentDelay, $e); + } + + usleep($currentDelay * 1000); // Convert to microseconds + + // Calculate next delay with exponential backoff + $currentDelay = min( + (int)($currentDelay * $this->backoffMultiplier), + $this->maxDelayMs + ); + + $attempt++; + } + } + + throw new \RuntimeException( + "Operation failed after {$this->maxAttempts} attempts", + 0, + $lastException + ); + } + + public function getMaxAttempts(): int + { + return $this->maxAttempts; + } + + public function getInitialDelay(): int + { + return $this->delayMs; + } + + public function getBackoffMultiplier(): float + { + return $this->backoffMultiplier; + } + + public function getMaxDelay(): int + { + return $this->maxDelayMs; + } +} diff --git a/tests/Compression/CompressionHandlerTest.php b/tests/Compression/CompressionHandlerTest.php new file mode 100644 index 0000000..d4e5d1c --- /dev/null +++ b/tests/Compression/CompressionHandlerTest.php @@ -0,0 +1,77 @@ +testData = str_repeat('Hello, World! ', 1000); + } + + public function testGzipCompression() + { + $handler = new CompressionHandler('gzip'); + + $compressed = $handler->compress($this->testData); + $this->assertNotEquals($this->testData, $compressed); + $this->assertLessThan(strlen($this->testData), strlen($compressed)); + + $decompressed = $handler->decompress($compressed); + $this->assertEquals($this->testData, $decompressed); + } + + public function testBzip2Compression() + { + $handler = new CompressionHandler('bzip2'); + + $compressed = $handler->compress($this->testData); + $this->assertNotEquals($this->testData, $compressed); + $this->assertLessThan(strlen($this->testData), strlen($compressed)); + + $decompressed = $handler->decompress($compressed); + $this->assertEquals($this->testData, $decompressed); + } + + public function testDeflateCompression() + { + $handler = new CompressionHandler('deflate'); + + $compressed = $handler->compress($this->testData); + $this->assertNotEquals($this->testData, $compressed); + $this->assertLessThan(strlen($this->testData), strlen($compressed)); + + $decompressed = $handler->decompress($compressed); + $this->assertEquals($this->testData, $decompressed); + } + + public function testInvalidAlgorithm() + { + $this->expectException(RuntimeException::class); + new CompressionHandler('invalid'); + } + + public function testInvalidCompressionLevel() + { + $this->expectException(RuntimeException::class); + new CompressionHandler('gzip', 10); + } + + public function testFileExtensions() + { + $gzip = new CompressionHandler('gzip'); + $this->assertEquals('.gz', $gzip->getFileExtension()); + + $bzip2 = new CompressionHandler('bzip2'); + $this->assertEquals('.bz2', $bzip2->getFileExtension()); + + $deflate = new CompressionHandler('deflate'); + $this->assertEquals('.zz', $deflate->getFileExtension()); + } +} diff --git a/tests/TestListener/PenelopeEventSubscriber.php b/tests/TestListener/PenelopeEventSubscriber.php new file mode 100644 index 0000000..5a91227 --- /dev/null +++ b/tests/TestListener/PenelopeEventSubscriber.php @@ -0,0 +1,91 @@ +currentTestNumber++; + $name = $this->formatTestName($event->methodName()); + printf("[%d/%d] āš” %s\n", $this->currentTestNumber, $this->testCount, $name); + } + + public function notify(Finished $event): void + { + if (str_contains($event->test()->className(), 'Performance')) { + $this->performanceResults[] = [ + 'test' => $event->test()->name(), + 'time' => $event->telemetry()->duration()->asFloat() + ]; + } + } + + public function notify(Started $event): void + { + if ($event->testSuite()->name() !== '') { + $this->currentSuite = $event->testSuite()->name(); + echo "\nšŸ” Running Test Suite: {$this->currentSuite}\n"; + echo str_repeat('-', 80) . "\n"; + $this->testCount = $event->testSuite()->count(); + $this->currentTestNumber = 0; + } + } + + public function notify(SuiteFinished $event): void + { + if (!empty($this->performanceResults) && $event->testSuite()->name() === 'Performance') { + $this->displayPerformanceResults(); + $this->performanceResults = []; + } + } + + private function formatTestName(string $name): string + { + // Convert camelCase to readable format + $name = preg_replace('/([a-z])([A-Z])/', '$1 $2', $name); + $name = ucfirst(strtolower($name)); + return $name; + } + + private function displayPerformanceResults(): void + { + echo "\nšŸ“Š Performance Test Results\n"; + echo str_repeat('=', 80) . "\n"; + + foreach ($this->performanceResults as $result) { + $testName = $this->formatTestName($result['test']); + $time = number_format($result['time'], 3); + echo sprintf("%-50s %10s seconds\n", $testName, $time); + } + + echo str_repeat('=', 80) . "\n\n"; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0ee8bb4 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +