diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 928282a..68a304e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,15 +13,12 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Unset local path repositories - run: composer config --unset repositories - - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -53,8 +50,8 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4'] - + php: ['8.2', '8.3', '8.4', '8.5'] + steps: - name: Checkout code uses: actions/checkout@v4 @@ -76,15 +73,12 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.2', '8.3', '8.4'] + php: ['8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Unset local path repositories - run: composer config --unset repositories - - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -105,4 +99,4 @@ jobs: run: composer install --prefer-dist --no-progress - name: Run PHPStan - run: composer phpstan \ No newline at end of file + run: composer phpstan diff --git a/README.md b/README.md index c21367b..9bdc51f 100644 --- a/README.md +++ b/README.md @@ -49,32 +49,48 @@ $chained = $effect->flatMap(function($content) { }); ``` -### IOApp +### Async Execution with start() -`IOApp` provides a way to run your IO programs. It's the entry point for your effectful applications. +The `start()` method allows you to fork computations into background fibers, enabling fire-and-forget patterns: ```php -use Phunkie\Effect\Functions\io\io; -use Phunkie\Effect\IO\IO; -use Phunkie\Effect\IO\IOApp; - -class MyApp extends IOApp -{ - public function run(): IO - { - return io(function() { - echo "Hello, Effects!"; - return 0; +use function Phunkie\Effect\Functions\io\io; + +// Define an async operation +$sendEmail = io(function() use ($user) { + mail($user->email, 'Welcome!', '...'); + return 'sent'; +}); + +// Fork to background and continue immediately +$program = $sendEmail + ->start() // Returns IO> + ->map(function($handle) { + // Continue with other work... + return 'Email queued'; + }); + +// Or await the result later +$program = $sendEmail + ->start() + ->flatMap(function($handle) { + // Do other work here... + $otherWork = io(fn() => 'other work done'); + + return $otherWork->map(function($result) use ($handle) { + // Now wait for email to finish + $emailResult = $handle->await(); + return [$result, $emailResult]; }); - } -} -``` + }); -To run your application, use the Phunkie console: +// Custom execution context +use Phunkie\Effect\Concurrent\ParallelExecutionContext; -```bash -$ bin/phunkie MyApp -Hello, Effects! +$heavyComputation = io(fn() => processLargeDataset()); +$handle = $heavyComputation + ->start(new ParallelExecutionContext()) // Use parallel threads + ->unsafeRun(); ``` ## Features @@ -84,7 +100,8 @@ Hello, Effects! - Composable effect chains - Error handling through Either - Resource management -- Concurrency support (coming soon) +- **Async execution with `start()`** - Fork computations to background fibers +- **Custom execution contexts** - Control how effects are executed ## Why Phunkie Effects? diff --git a/composer.json b/composer.json index b72fe5d..de20c71 100644 --- a/composer.json +++ b/composer.json @@ -10,8 +10,8 @@ } ], "require": { - "php": "^8.2 || ^8.3 || ^8.4", - "phunkie/phunkie": "^1.0" + "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", + "phunkie/phunkie": "^1.1.0" }, "require-dev": { "phpunit/phpunit": "^10.5", @@ -54,4 +54,4 @@ "config": { "bin-dir": "bin" } -} +} \ No newline at end of file diff --git a/docs/io.md b/docs/io.md new file mode 100644 index 0000000..6dd1c06 --- /dev/null +++ b/docs/io.md @@ -0,0 +1,394 @@ +# IO - The Effect Monad + +The `IO` monad is the core abstraction in Phunkie Effect for managing side effects in a purely functional way. It represents a computation that performs side effects, but delays their execution until explicitly run. + +## Table of Contents + +- [Core Concepts](#core-concepts) +- [Creating IO](#creating-io) +- [Transforming IO](#transforming-io) +- [Combining IO](#combining-io) +- [Async Execution](#async-execution) +- [Error Handling](#error-handling) +- [Running IO](#running-io) +- [Best Practices](#best-practices) + +## Core Concepts + +### What is IO? + +`IO` represents a computation that: +- Produces a value of type `A` +- May perform side effects (file I/O, network calls, database queries, etc.) +- Is **lazy** - side effects don't happen until `unsafeRun()` is called +- Is **referentially transparent** - can be reasoned about as a pure value + +```php +use function Phunkie\Effect\Functions\io\io; + +// This doesn't read the file yet - it just describes the operation +$readFile = io(fn() => file_get_contents('data.txt')); + +// The file is only read when we run it +$content = $readFile->unsafeRun(); // Side effect happens here +``` + +### Why Use IO? + +**Without IO (impure):** +```php +function getUser(int $id): User { + $data = $db->query("SELECT * FROM users WHERE id = ?", [$id]); // Side effect! + return new User($data); +} + +// Hard to test, hard to compose, order of execution matters +$user = getUser(1); +``` + +**With IO (pure):** +```php +function getUser(int $id): IO { + return io(function() use ($id) { + $data = $db->query("SELECT * FROM users WHERE id = ?", [$id]); + return new User($data); + }); +} + +// Easy to test (no DB needed), composable, explicit about effects +$userIO = getUser(1); // No side effects yet +$user = $userIO->unsafeRun(); // Side effect happens here +``` + +## Creating IO + +### From a Pure Value + +```php +use function Phunkie\Effect\Functions\io\io; + +// Wrap a pure value +$pure = io(42); // IO +$result = $pure->unsafeRun(); // 42 +``` + +### From a Side Effect + +```php +// Wrap a side-effecting computation +$effect = io(fn() => file_get_contents('data.txt')); // IO + +// Multiple statements +$complex = io(function() { + $data = fetchFromApi(); + $processed = processData($data); + saveToDatabase($processed); + return $processed; +}); +``` + +### From Existing Values + +```php +// Using the io() helper +$value = io(123); + +// Direct construction +use Phunkie\Effect\IO\IO; +$io = new IO(fn() => computeValue()); +``` + +## Transforming IO + +### map - Transform the Result + +```php +$readFile = io(fn() => file_get_contents('numbers.txt')); + +// Transform the result +$numbers = $readFile->map(fn($content) => explode("\n", $content)); +$doubled = $numbers->map(fn($nums) => array_map(fn($n) => $n * 2, $nums)); + +// Chain transformations +$result = $readFile + ->map(fn($content) => explode("\n", $content)) + ->map(fn($lines) => array_map('intval', $lines)) + ->map(fn($numbers) => array_sum($numbers)); +``` + +### flatMap - Chain Dependent Effects + +```php +// Sequential effects where the second depends on the first +$program = getUser(1) + ->flatMap(fn($user) => getUserPosts($user->id)) + ->flatMap(fn($posts) => io(fn() => json_encode($posts))); + +// Real-world example +$workflow = readConfig('config.json') + ->flatMap(fn($config) => connectToDatabase($config['db'])) + ->flatMap(fn($db) => fetchUsers($db)) + ->flatMap(fn($users) => sendEmails($users)); +``` + +### productR (*>) - Sequence and Discard First + +```php +// Run both effects, keep only the second result +$program = writeLog('Starting...') + ->productR(performOperation()) + ->productR(writeLog('Done')); + +// Equivalent to: +$program = writeLog('Starting...') + ->flatMap(fn($_) => performOperation()) + ->flatMap(fn($result) => writeLog('Done')->map(fn($_) => $result)); +``` + +## Combining IO + +### Sequential Composition + +```php +// Run effects in sequence +$sequential = io(fn() => step1()) + ->flatMap(fn($a) => io(fn() => step2($a))) + ->flatMap(fn($b) => io(fn() => step3($b))); +``` + +### Parallel Execution + +```php +use Phunkie\Effect\Ops\IO\ParallelOps; + +$io1 = io(fn() => fetchUser(1)); +$io2 = io(fn() => fetchUser(2)); +$io3 = io(fn() => fetchUser(3)); + +// Run in parallel and combine results +$combined = $io1->parMap2($io2, fn($u1, $u2) => [$u1, $u2]); +$all = $io1->parMapN([$io2, $io3], fn($u1, $u2, $u3) => [$u1, $u2, $u3]); +``` + +## Async Execution + +### start() - Fork to Background + +The `start()` method forks a computation into a background fiber, returning immediately with a handle: + +```php +// Fire and forget pattern +$sendEmail = io(fn() => mail($user->email, 'Welcome!', '...')); + +$program = $sendEmail + ->start() // Returns IO> + ->map(fn($handle) => 'Email queued'); // Continue immediately + +// Or await the result later +$program = $sendEmail + ->start() + ->flatMap(fn($handle) => { + // Do other work while email sends + return doOtherWork()->map(fn($result) => [ + 'work' => $result, + 'email' => $handle->await() // Wait for email + ]); + }); +``` + +### Custom Execution Context + +```php +use Phunkie\Effect\Concurrent\ParallelExecutionContext; + +// Use parallel threads (if ext-parallel available) +$heavyComputation = io(fn() => processLargeDataset()); +$handle = $heavyComputation + ->start(new ParallelExecutionContext()) + ->unsafeRun(); + +$result = $handle->await(); +``` + +### Real-World Example + +```php +// Create user and send welcome email asynchronously +function createUser(array $data): IO { + return io(fn() => User::create($data)) + ->flatMap(fn($user) => + sendWelcomeEmail($user) + ->start() // Fork email to background + ->map(fn($_) => $user) // Return user immediately + ); +} + +// HTTP handler +POST('/users', fn(Request $req) => + createUser($req->body) + ->map(fn($user) => Created($user)) // 201 response sent while email sends +); +``` + +## Error Handling + +### attempt() - Capture Errors as Values + +```php +$risky = io(fn() => throw new \RuntimeException('Oops')); + +$safe = $risky->attempt(); // IO> + +$result = $safe->unsafeRun(); +$result->match( + Success: fn($value) => "Got: $value", + Failure: fn($error) => "Error: {$error->getMessage()}" +); +``` + +### handleError() - Recover from Errors + +```php +$risky = io(fn() => riskyOperation()); + +$recovered = $risky->handleError(fn($e) => 'default-value'); + +// Real-world example +$getUser = io(fn() => $db->findUser($id)) + ->handleError(fn($e) => null); // Return null if not found +``` + +### Combining Error Handling + +```php +$program = fetchFromApi() + ->attempt() + ->flatMap(fn($result) => $result->match( + Success: fn($data) => io(fn() => processData($data)), + Failure: fn($error) => io(fn() => logError($error)) + ->productR(io(fn() => getDefaultData())) + )); +``` + +## Running IO + +### unsafeRun() - Execute the Effect + +```php +$io = io(fn() => 'Hello, World!'); +$result = $io->unsafeRun(); // "Hello, World!" +``` + +⚠️ **Warning**: `unsafeRun()` performs side effects. Only call it at the "edge of the world" (application entry point, IOApp, HTTP handlers). + +### unsafeRunSync() - Execute and Await Async + +```php +$async = io(fn() => slowOperation())->start(); +$handle = $async->unsafeRun(); // Returns AsyncHandle + +// Or use unsafeRunSync to automatically await +$result = $async->unsafeRunSync(); // Blocks until complete +``` + +### In IOApp + +```php +use Phunkie\Effect\IO\IOApp; + +class MyApp extends IOApp { + public function run(): IO { + return processData() + ->flatMap(fn($data) => saveResults($data)) + ->map(fn($_) => 0); // Exit code + } +} + +// IOApp handles unsafeRun() for you +``` + +## Best Practices + +### 1. Keep IO at the Boundaries + +```php +// ❌ Bad - IO in the middle of pure logic +function calculateTotal(array $items): IO { + return io(fn() => array_sum(array_map(fn($i) => $i->price, $items))); +} + +// ✅ Good - Pure logic, IO at boundaries +function calculateTotal(array $items): float { + return array_sum(array_map(fn($i) => $i->price, $items)); +} + +function getTotalFromDb(int $orderId): IO { + return fetchOrder($orderId) // IO + ->map(fn($order) => calculateTotal($order->items)); // Pure +} +``` + +### 2. Use flatMap for Dependent Effects + +```php +// ❌ Bad - Nested unsafeRun +$user = getUser($id)->unsafeRun(); +$posts = getUserPosts($user->id)->unsafeRun(); + +// ✅ Good - Composed with flatMap +$program = getUser($id) + ->flatMap(fn($user) => getUserPosts($user->id)); +``` + +### 3. Avoid Mixing Pure and Impure + +```php +// ❌ Bad - Side effect hidden in pure function +function processUser(User $user): User { + logToFile("Processing {$user->name}"); // Hidden side effect! + return $user; +} + +// ✅ Good - Explicit about effects +function processUser(User $user): IO { + return io(fn() => logToFile("Processing {$user->name}")) + ->map(fn($_) => $user); +} +``` + +### 4. Use start() for Independent Effects + +```php +// ❌ Bad - Sequential when parallel is possible +$program = sendEmail($user) + ->flatMap(fn($_) => sendSms($user)) + ->flatMap(fn($_) => logActivity($user)); + +// ✅ Good - Parallel independent effects +$program = sendEmail($user)->start() + ->flatMap(fn($emailHandle) => sendSms($user)->start()) + ->flatMap(fn($smsHandle) => logActivity($user)->start()) + ->map(fn($logHandle) => 'All notifications queued'); +``` + +### 5. Name Your Effects Descriptively + +```php +// ❌ Bad +$io1 = io(fn() => doStuff()); +$io2 = $io1->flatMap(fn($x) => io(fn() => doMore($x))); + +// ✅ Good +$fetchUser = io(fn() => $db->getUser($id)); +$enrichWithPosts = fn($user) => io(fn() => $db->getPosts($user->id)) + ->map(fn($posts) => [...$user, 'posts' => $posts]); + +$program = $fetchUser->flatMap($enrichWithPosts); +``` + +## See Also + +- [IOApp](ioapp.md) - Application entry point +- [Error Handling](error-handling.md) - Advanced error handling patterns +- [Concurrency](concurrency.md) - Parallel and async execution +- [Resource Management](resources.md) - Safe resource handling diff --git a/src/IO/IO.php b/src/IO/IO.php index e8aaf4a..d442574 100644 --- a/src/IO/IO.php +++ b/src/IO/IO.php @@ -16,6 +16,8 @@ use Phunkie\Cats\Monad; use Phunkie\Effect\Cats\Parallel; use Phunkie\Effect\Concurrent\AsyncHandle; +use Phunkie\Effect\Concurrent\ExecutionContext; +use Phunkie\Effect\Concurrent\FiberExecutionContext; use Phunkie\Effect\Ops\IO\ApplicativeOps; use Phunkie\Effect\Ops\IO\FunctorOps; use Phunkie\Effect\Ops\IO\MonadOps; @@ -132,6 +134,32 @@ public function attempt(): IO ); } + /** + * Starts this IO in the background and returns a handle to await its result. + * + * This forks the computation into a background fiber (or specified execution context), + * allowing the main program to continue without blocking. The returned AsyncHandle + * can be awaited later to retrieve the result. + * + * Example: + * ```php + * $handle = sendEmail($user)->start()->unsafeRun(); + * // ... do other work ... + * $result = $handle->await(); // Wait for email to finish + * ``` + * + * @param ExecutionContext|null $context The execution context (defaults to FiberExecutionContext) + * @return IO> IO that produces a handle to the forked computation + * @phpstan-ignore-next-line generics.variance (AsyncHandle is invariant by design) + */ + public function start(?ExecutionContext $context = null): IO + { + $run = $this->unsafeRun; + $context = $context ?? new FiberExecutionContext(); + + return new IO(fn () => $context->executeAsync($run)); + } + /** * Returns the arity of the IO type constructor. * diff --git a/tests/Unit/IO/IOTest.php b/tests/Unit/IO/IOTest.php index d8f1237..0709dd2 100644 --- a/tests/Unit/IO/IOTest.php +++ b/tests/Unit/IO/IOTest.php @@ -67,4 +67,70 @@ public function it_handles_errors_with_attempt() $this->assertEquals('test error', $result->fold(fn ($e) => $e->getMessage())(fn ($x) => $x)); } + + #[Test] + public function it_can_start_computation_in_background() + { + $counter = 0; + $io = new IO(function () use (&$counter) { + $counter++; + + return $counter; + }); + + // Start returns IO> + $handleIO = $io->start(); + $this->assertInstanceOf(IO::class, $handleIO); + + // Running the IO gives us an AsyncHandle + $handle = $handleIO->unsafeRun(); + $this->assertInstanceOf(\Phunkie\Effect\Concurrent\AsyncHandle::class, $handle); + + // Counter hasn't incremented yet (lazy execution) + $this->assertEquals(0, $counter); + + // Awaiting the handle executes the computation + $result = $handle->await(); + $this->assertEquals(1, $result); + $this->assertEquals(1, $counter); + } + + #[Test] + public function it_can_start_and_continue_without_blocking() + { + $log = []; + + $slowIO = new IO(function () use (&$log) { + $log[] = 'slow-start'; + // Simulate slow work + usleep(10000); // 10ms + $log[] = 'slow-end'; + + return 'slow-result'; + }); + + $fastIO = new IO(function () use (&$log) { + $log[] = 'fast'; + + return 'fast-result'; + }); + + // Start slow work in background + $program = $slowIO->start()->flatMap(function ($handle) use ($fastIO, &$log) { + // Do fast work while slow work runs + return $fastIO->map(function ($fastResult) use ($handle, &$log) { + // Now await slow work + $slowResult = $handle->await(); + + return [$slowResult, $fastResult]; + }); + }); + + $result = $program->unsafeRun(); + + $this->assertEquals(['slow-result', 'fast-result'], $result); + // Note: In FiberExecutionContext, the fiber executes when await() is called + // So the actual order is: fast, slow-start, slow-end + $this->assertEquals(['fast', 'slow-start', 'slow-end'], $log); + } }