diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a81c76b..7f0fb5a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: - name: Setup PHP with coverage driver uses: shivammathur/setup-php@v2 with: - php-version: ${{ matrix.version }} + php-version: 8.2 coverage: pcov - name: Start MySQL Database diff --git a/.github/workflows/try-installation.yml b/.github/workflows/try-installation.yml index cca8cc2..25fa7a3 100644 --- a/.github/workflows/try-installation.yml +++ b/.github/workflows/try-installation.yml @@ -1,4 +1,4 @@ -name: Try Install Package (Laravel 10) +name: Try Install Package (Laravel 10 & 11) env: LOCAL_ENV: ${{ secrets.LOCAL_ENV }} @@ -7,13 +7,13 @@ jobs: strategy: fail-fast: false matrix: - version: [ '^9.0', '^10.0' ] + version: [ '^10.0', '^11.0' ] runs-on: ubuntu-latest steps: - name: Setup PHP with coverage driver uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: pcov - name: Setup and install package on Laravel diff --git a/README.md b/README.md index 217c70a..b210451 100644 --- a/README.md +++ b/README.md @@ -7,23 +7,33 @@

-The inbox pattern is a popular design pattern that ensures: +Talking about distributed computers & servers, it is quite normal nowadays to communicate between servers. + +Unlike a regular conversation though, there's no guarantee the message gets delivered only once, arrives in the right order, or even gets a "got it!" reply. + +Thus, we have **Inbox Pattern** to help us to achieve that. + +## What is the Inbox Pattern + +**The Inbox Pattern** is a popular design pattern in the microservice architecture that ensures: - High availability ✅ - Guaranteed webhook deliverance, no msg lost ✅ - Guaranteed **exactly-once/unique** webhook requests ✅ -- Execute webhook requests **in ORDER** ✅ +- Execute webhook requests **in ORDER/sequence** ✅ - (Optional) High visibility & debug all prev requests ✅ -Laravel Inbox Process (powered by ShipSaaS) ships everything and +And with that being said: + +**Laravel Inbox Process (powered by ShipSaaS)** ships everything out-of-the-box and helps you to roll out the inbox process in no time 😎🚀. ## Supports - Laravel 10+ - PHP 8.2+ -- MySQL 8 and Postgres 13+ +- MySQL 8, MariaDB & Postgres 13+ -## Architecture +## Architecture Diagram ![ShipSaaS - Laravel Inbox Process](./.github/arch.png) @@ -46,7 +56,7 @@ php artisan migrate Visit: [ShipSaaS Inbox Documentation](https://inbox.shipsaas.tech) -Best practices & notes are well documented too 😎! +Best practices, usage & notes are well documented too 😎! ## Testing @@ -54,9 +64,11 @@ Run `composer test` 😆 Available Tests: -- Unit Testing -- Integration Testing against MySQL & PostgreSQL for the `inbox:work` command -- Human validation (lol) +- Unit Testing 💪 +- Integration Testing against MySQL & PostgreSQL for the `inbox:work` command 😎 +- Human validation (lol) 🔥 + +ShipSaaS loves tests, we won't ship sh!tty libraries 🌹 ## Contributors - Seth Phat diff --git a/composer.json b/composer.json index 1198bd6..6cc2aea 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "shipsaas/laravel-inbox-process", "type": "library", - "version": "1.0.0", + "version": "1.1.0", "description": "Inbox pattern process implementation for your Laravel Applications", "keywords": [ "laravel library", @@ -24,7 +24,7 @@ "license": "MIT", "require": { "php": "^8.2", - "laravel/framework": "^10|dev-master", + "laravel/framework": "^10|^11|dev-master", "ext-pcntl": "*" }, "require-dev": { diff --git a/src/Commands/InboxWorkCommand.php b/src/Commands/InboxWorkCommand.php index 0c10ef7..88df917 100644 --- a/src/Commands/InboxWorkCommand.php +++ b/src/Commands/InboxWorkCommand.php @@ -13,7 +13,7 @@ #[AsCommand(name: 'inbox:work')] class InboxWorkCommand extends Command { - protected $signature = 'inbox:work {topic} {--limit=10} {--wait=5} {--log=1} {--stop-on-empty}'; + protected $signature = 'inbox:work {topic} {--limit=10} {--wait=5} {--log=1} {--stop-on-empty} {--max-processing-time=3600}'; protected $description = '[ShipSaaS Inbox] Start the inbox process'; protected bool $isRunning = true; @@ -47,6 +47,7 @@ public function handle( $this->registerLifecycle($runningInboxRepo, $inboxMessageHandler, $lifecycle); $inboxMessageHandler->setTopic($this->topic); + $inboxMessageHandler->setHandleWriteLog($this->writeTraceLog(...)); $this->runInboxProcess($inboxMessageHandler, $lifecycle); return 0; @@ -79,6 +80,9 @@ private function runInboxProcess( ): void { $limit = intval($this->option('limit')) ?: 10; $wait = intval($this->option('wait')) ?: 5; + $maxProcessingTime = intval($this->option('max-processing-time')) ?: 3600; + + $processNeedToCloseAt = Carbon::now()->timestamp + $maxProcessingTime; while ($lifecycle->isRunning()) { $totalProcessed = $inboxMessageHandler->process($limit); @@ -91,6 +95,12 @@ private function runInboxProcess( break; } + if (Carbon::now()->timestamp >= $processNeedToCloseAt) { + $this->writeTraceLog('[Info] Reached max processing time. Closing the process.'); + + break; + } + $this->writeTraceLog('[Info] No message found. Sleeping...'); sleep($wait); continue; diff --git a/src/Entities/InboxMessage.php b/src/Entities/InboxMessage.php index 16a6fad..86fcdeb 100644 --- a/src/Entities/InboxMessage.php +++ b/src/Entities/InboxMessage.php @@ -5,12 +5,14 @@ class InboxMessage { public int $id; + public string $externalId; public string $rawPayload; public static function make(object $rawDbRecord): InboxMessage { $inboxMsg = new InboxMessage(); $inboxMsg->id = intval($rawDbRecord->id); + $inboxMsg->externalId = $rawDbRecord->external_id; $inboxMsg->rawPayload = $rawDbRecord->payload ?: '{}'; return $inboxMsg; diff --git a/src/Handlers/InboxMessageHandler.php b/src/Handlers/InboxMessageHandler.php index ea8ff8d..4015e6c 100644 --- a/src/Handlers/InboxMessageHandler.php +++ b/src/Handlers/InboxMessageHandler.php @@ -2,6 +2,7 @@ namespace ShipSaasInboxProcess\Handlers; +use Closure; use Illuminate\Support\Facades\Log; use ShipSaasInboxProcess\Core\Lifecycle; use ShipSaasInboxProcess\Entities\InboxMessage; @@ -12,6 +13,7 @@ class InboxMessageHandler { private string $topic; + private Closure $handleWriteLog; public function __construct( private InboxMessageRepository $inboxMessageRepo, @@ -26,6 +28,13 @@ public function setTopic(string $topic): self return $this; } + public function setHandleWriteLog(?Closure $handleWriteLog): self + { + $this->handleWriteLog = $handleWriteLog; + + return $this; + } + public function process(int $limit = 10): int { $messages = $this->inboxMessageRepo->pullMessages($this->topic, $limit); @@ -40,15 +49,42 @@ public function process(int $limit = 10): int } try { + call_user_func( + $this->handleWriteLog, + sprintf( + '[MsgId: %s] Handling message with externalId: "%s"', + $message->id, + $message->externalId + ) + ); + $this->processMessage($message); $processed++; + + call_user_func( + $this->handleWriteLog, + sprintf( + '[MsgId: %s] Handled message with externalId: "%s"', + $message->id, + $message->externalId + ) + ); } catch (Throwable $e) { + call_user_func( + $this->handleWriteLog, + sprintf( + '[MsgId: %s] Failed to handle message with externalId: "%s" - Process will be aborted', + $message->id, + $message->externalId + ) + ); + // something really bad happens, we need to stop the process Log::error('Failed to process inbox message', [ 'error' => [ 'msg' => $e->getMessage(), 'traces' => $e->getTrace(), - ] + ], ]); $this->lifecycle->forceClose(); diff --git a/src/Repositories/InboxMessageRepository.php b/src/Repositories/InboxMessageRepository.php index 6a1383d..043b8c4 100644 --- a/src/Repositories/InboxMessageRepository.php +++ b/src/Repositories/InboxMessageRepository.php @@ -34,7 +34,7 @@ public function pullMessages(string $topic, int $limit = 10): Collection ->where('topic', $topic) ->orderBy('created_at_unix_ms', 'ASC') ->limit($limit) - ->get(['id', 'payload']) + ->get(['id', 'external_id', 'payload']) ->map(InboxMessage::make(...)); } diff --git a/tests/Integration/Commands/InboxWorkCommandTest.php b/tests/Integration/Commands/InboxWorkCommandTest.php index a020dc3..3785eda 100644 --- a/tests/Integration/Commands/InboxWorkCommandTest.php +++ b/tests/Integration/Commands/InboxWorkCommandTest.php @@ -54,6 +54,16 @@ public function testCommandPullsTheOrderedMsgAndProcessThem() // 4. validate $this->assertSame(0, $code); $this->assertStringContainsString('Processed 3 inbox messages', $result); + + $this->assertStringContainsString('Handling message with externalId: "evt_1NWX0RBGIr5C5v4TpncL2sCf"', $result); + $this->assertStringContainsString('Handled message with externalId: "evt_1NWX0RBGIr5C5v4TpncL2sCf"', $result); + + $this->assertStringContainsString('Handling message with externalId: "evt_1NWUFiBGIr5C5v4TptQhGyW3"', $result); + $this->assertStringContainsString('Handled message with externalId: "evt_1NWUFiBGIr5C5v4TptQhGyW3"', $result); + + $this->assertStringContainsString('Handling message with externalId: "evt_1Nh2fp2eZvKYlo2CzbNockEM"', $result); + $this->assertStringContainsString('Handled message with externalId: "evt_1Nh2fp2eZvKYlo2CzbNockEM"', $result); + $this->assertStringContainsString('[Info] No message found. Stopping...', $result); Event::assertDispatched( @@ -135,6 +145,22 @@ public function testCommandThrowsErrorWhenFailedToProcessAMessage() 'topic' => 'with_err_msg', ]); } + + public function testCommandStopsAfterAnAmountOfTime() + { + $beginAt = time(); + + $code = Artisan::call('inbox:work test --max-processing-time=10'); + $result = Artisan::output(); + + $finishedAt = time(); + + $this->assertSame(0, $code); + + $this->assertStringContainsString('[Info] Reached max processing time. Closing the process.', $result); + + $this->assertGreaterThanOrEqual(10, $finishedAt - $beginAt); + } } class InvoicePaymentSucceedEvent diff --git a/tests/Unit/Entities/InboxMessageTest.php b/tests/Unit/Entities/InboxMessageTest.php index d635a7f..f86cfe8 100644 --- a/tests/Unit/Entities/InboxMessageTest.php +++ b/tests/Unit/Entities/InboxMessageTest.php @@ -11,10 +11,12 @@ public function testMakeReturnsInboxMessageWithPayload() { $inboxMsg = InboxMessage::make((object) [ 'id' => 1000, + 'external_id' => 'fake-id', 'payload' => '{"hello": "world"}', ]); $this->assertSame(1000, $inboxMsg->id); + $this->assertSame('fake-id', $inboxMsg->externalId); $this->assertSame('{"hello": "world"}', $inboxMsg->rawPayload); } @@ -22,10 +24,12 @@ public function testMakeReturnsInboxMessageWithNoPayload() { $inboxMsg = InboxMessage::make((object) [ 'id' => 1000, + 'external_id' => 'fake-id', 'payload' => null, ]); $this->assertSame(1000, $inboxMsg->id); + $this->assertSame('fake-id', $inboxMsg->externalId); $this->assertSame('{}', $inboxMsg->rawPayload); } @@ -33,6 +37,7 @@ public function testGetParsedPayloadReturnsAnArray() { $inboxMsg = InboxMessage::make((object) [ 'id' => 1000, + 'external_id' => 'fake-id', 'payload' => '{"hello": "world"}', ]); @@ -46,6 +51,7 @@ public function testGetParsedPayloadReturnsAnEmptyArray() $inboxMsg = InboxMessage::make((object) [ 'id' => 1000, 'payload' => null, + 'external_id' => 'fake-id', ]); $this->assertSame([], $inboxMsg->getParsedPayload());