This commit is contained in:
2026-05-09 01:18:51 +02:00
parent 7116ee4619
commit 959970c150
132 changed files with 21310 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,836 @@
<?php
namespace App\Http\Controllers;
use App\Models\ImportJob;
use App\Models\Order;
use App\Temporal\DataEnrichment\DataEnrichmentWorkflowInterface;
use App\Temporal\EloquentQuery\EloquentQueryWorkflowInterface;
use App\Temporal\ExternalApiSync\ExternalApiSyncWorkflowInterface;
use App\Temporal\OrderFulfillment\OrderFulfillmentWorkflowInterface;
use App\Temporal\ProductImport\ProductImportWorkflowInterface;
use App\Temporal\SystemMonitor\SystemMonitorWorkflowInterface;
use App\Temporal\UserMigration\UserMigrationWorkflowInterface;
use App\Temporal\WebhookDelivery\WebhookDeliveryWorkflowInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Temporal\Client\GRPC\ServiceClient;
use Temporal\Client\WorkflowClient;
use Temporal\Client\WorkflowClientInterface;
use Temporal\Client\WorkflowOptions;
use Temporal\Workflow\WorkflowExecutionStatus;
class TemporalDemoController extends Controller
{
public function __construct(
private WorkflowClientInterface $workflowClient,
) {}
public function dashboard(): InertiaResponse
{
$importJobs = ImportJob::latest()->take(10)->get();
$orders = Order::latest()->take(10)->get();
return Inertia::render('Dashboard', [
'importJobs' => $importJobs,
'orders' => $orders,
]);
}
// --- Reset & Terminate ---
public function reset(): StreamedResponse
{
return $this->streamedOperation(function () {
$this->emit('Starting full reset...', 'step');
// 1. Look up container IDs (before dropping DBs, which may crash them)
$containerId = null;
$workerContainerId = null;
try {
$containerId = $this->findDockerContainer('temporal');
$workerContainerId = $this->findDockerContainer('temporal-worker');
} catch (\Throwable $e) {
$this->emit("Docker lookup failed: {$e->getMessage()}", 'error');
}
// 2. Drop Temporal databases (all workflow history will be wiped)
$this->emit('Dropping Temporal databases...', 'step');
try {
$pdo = new \PDO('pgsql:host=temporal-pgsql;port=5432;dbname=postgres', 'temporal', 'temporal');
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
foreach (['temporal', 'temporal_visibility'] as $db) {
$pdo->exec("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{$db}' AND pid <> pg_backend_pid()");
$pdo->exec("DROP DATABASE IF EXISTS {$db}");
$pdo->exec("CREATE DATABASE {$db} OWNER temporal");
$this->emit("Recreated database: {$db}");
}
$this->emit('Temporal databases reset', 'success');
} catch (\Throwable $e) {
$this->emit("Database reset failed: {$e->getMessage()}", 'error');
}
// 3. Restart Temporal container (using pre-fetched ID)
$this->emit('Restarting Temporal container...', 'step');
try {
$this->restartDockerContainer($containerId);
$this->emit('Restart signal sent');
} catch (\Throwable $e) {
$this->emit("Docker restart failed: {$e->getMessage()}", 'error');
}
// 4. Wait for Temporal to come back
$this->emit('Waiting for Temporal to come back online...', 'step');
$start = time();
$online = false;
$lastUpdate = 0;
while (time() - $start < 30) {
try {
$client = WorkflowClient::create(
ServiceClient::create(config('temporal.address'))
);
$client->listWorkflowExecutions('ExecutionStatus="Running"', pageSize: 1);
$online = true;
break;
} catch (\Throwable) {
$elapsed = time() - $start;
if ($elapsed - $lastUpdate >= 3) {
$this->emit("Still waiting... ({$elapsed}s)");
$lastUpdate = $elapsed;
}
sleep(1);
}
}
if ($online) {
$this->emit('Temporal is back online', 'success');
} else {
$this->emit('Temporal did not come back within 30s — may need manual restart', 'error');
}
// 5. Restart worker
$this->emit('Restarting worker...', 'step');
try {
$this->restartDockerContainer($workerContainerId);
$this->emit('Worker restarted', 'success');
} catch (\Throwable $e) {
$this->emit("Worker restart failed: {$e->getMessage()}", 'error');
}
// 6. Reset Laravel database
$this->emit('Running migrate:fresh --seed...', 'step');
try {
Artisan::call('migrate:fresh', ['--seed' => true, '--seeder' => 'TemporalDemoSeeder', '--force' => true]);
$this->emit('Database reset and seeded', 'success');
} catch (\Throwable $e) {
$this->emit("Migration failed: {$e->getMessage()}", 'error');
}
$this->emit('Reset complete', 'done');
});
}
public function terminateAll(): StreamedResponse
{
return $this->streamedOperation(function () {
$this->emit('Listing running workflows...', 'step');
$terminated = 0;
try {
$paginator = $this->workflowClient->listWorkflowExecutions(
'ExecutionStatus="Running"',
pageSize: 100,
);
foreach ($paginator as $info) {
try {
$wfId = $info->execution->getID();
$stub = $this->workflowClient->newUntypedRunningWorkflowStub(
$wfId,
$info->execution->getRunID(),
);
$stub->terminate('Manual termination from dashboard');
$terminated++;
$this->emit("Terminated: {$wfId}", 'warn');
} catch (\Throwable) {
// Already completed
}
}
$this->emit("Terminated {$terminated} workflow(s)", 'success');
} catch (\Throwable $e) {
$this->emit("Failed to connect to Temporal: {$e->getMessage()}", 'error');
}
$this->emit($terminated > 0 ? 'All workflows terminated' : 'No running workflows found', 'done');
});
}
/**
* Check actual execution status via Temporal's DescribeWorkflowExecution.
* Returns a terminal status string, or null if the workflow is still running.
*/
private function resolveWorkflowStatus(string $workflowId): ?string
{
try {
$stub = $this->workflowClient->newUntypedRunningWorkflowStub($workflowId);
$description = $stub->describe();
return match ($description->info->status) {
WorkflowExecutionStatus::Completed => 'completed',
WorkflowExecutionStatus::Failed, WorkflowExecutionStatus::TimedOut => 'failed',
WorkflowExecutionStatus::Canceled, WorkflowExecutionStatus::Terminated => 'cancelled',
default => null,
};
} catch (\Throwable) {
// Workflow not found in Temporal — treat as failed
return 'failed';
}
}
private function streamedOperation(\Closure $callback): StreamedResponse
{
return response()->stream(function () use ($callback) {
while (ob_get_level()) {
ob_end_flush();
}
$callback();
}, 200, [
'Content-Type' => 'text/plain',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
private function emit(string $message, string $type = 'info'): void
{
echo json_encode([
'type' => $type,
'message' => $message,
'time' => date('H:i:s'),
]) . "\n";
flush();
}
private function findDockerContainer(string $serviceName): ?string
{
$socketPath = '/var/run/docker.sock';
if (!file_exists($socketPath)) {
$this->emit('Docker socket not found — cannot restart automatically', 'error');
return null;
}
$filters = urlencode(json_encode([
'label' => [
"com.docker.compose.service={$serviceName}",
'com.docker.compose.project=temporalio-test',
],
]));
$response = $this->dockerApiGet("/containers/json?all=true&filters={$filters}");
$containers = json_decode($response, true);
if (empty($containers)) {
$this->emit("Container for service '{$serviceName}' not found", 'error');
return null;
}
$containerId = $containers[0]['Id'];
$containerName = ltrim($containers[0]['Names'][0] ?? $containerId, '/');
$this->emit("Found container: {$containerName}");
return $containerId;
}
private function restartDockerContainer(?string $containerId): void
{
if (!$containerId) {
return;
}
$this->dockerApiPost("/containers/{$containerId}/restart?t=5");
}
private function dockerApiGet(string $path): string
{
return $this->dockerApiRequest('GET', $path);
}
private function dockerApiPost(string $path): string
{
return $this->dockerApiRequest('POST', $path);
}
private function dockerApiRequest(string $method, string $path): string
{
$socket = stream_socket_client('unix:///var/run/docker.sock', $errno, $errstr, 5);
if (!$socket) {
$this->emit("Docker socket connection failed: {$errstr}", 'error');
return '';
}
stream_set_timeout($socket, 30);
$request = "{$method} {$path} HTTP/1.0\r\nHost: localhost\r\n\r\n";
fwrite($socket, $request);
$response = '';
while (!feof($socket)) {
$chunk = fread($socket, 8192);
if ($chunk === false) break;
$meta = stream_get_meta_data($socket);
if ($meta['timed_out']) {
$this->emit('Docker API request timed out', 'error');
break;
}
$response .= $chunk;
}
fclose($socket);
// Strip HTTP headers — body comes after \r\n\r\n
$parts = explode("\r\n\r\n", $response, 2);
return $parts[1] ?? '';
}
// --- Product Import ---
public function startImport(Request $request): JsonResponse
{
$filePath = storage_path('app/imports/products.csv');
if (!file_exists($filePath)) {
return response()->json(['error' => 'CSV file not found. Run TemporalDemoSeeder first.'], 404);
}
$simulationConfig = $request->input('simulation', []);
$workflowId = 'product-import-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
ProductImportWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, $filePath, $simulationConfig);
$importJob = ImportJob::create([
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'type' => 'product_import',
'file_path' => $filePath,
'status' => 'started',
]);
return response()->json([
'message' => 'Product import started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'import_job_id' => $importJob->id,
]);
}
public function pauseImport(string $id): JsonResponse
{
$importJob = ImportJob::findOrFail($id);
$stub = $this->workflowClient->newRunningWorkflowStub(
ProductImportWorkflowInterface::class,
$importJob->workflow_id
);
$stub->pause();
$importJob->update(['status' => 'paused']);
return response()->json(['message' => 'Import paused', 'workflow_id' => $importJob->workflow_id]);
}
public function resumeImport(string $id): JsonResponse
{
$importJob = ImportJob::findOrFail($id);
$stub = $this->workflowClient->newRunningWorkflowStub(
ProductImportWorkflowInterface::class,
$importJob->workflow_id
);
$stub->resume();
$importJob->update(['status' => 'processing']);
return response()->json(['message' => 'Import resumed', 'workflow_id' => $importJob->workflow_id]);
}
public function importStatus(string $id): JsonResponse
{
$importJob = ImportJob::findOrFail($id);
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
ProductImportWorkflowInterface::class,
$importJob->workflow_id
);
$status = $stub->getStatus();
// Query handler returns cached internal state — cross-check
// actual execution status when the query says non-terminal
if (!in_array($status['status'], ['completed', 'cancelled', 'failed'])) {
$resolved = $this->resolveWorkflowStatus($importJob->workflow_id);
if ($resolved !== null) {
$status['status'] = $resolved;
}
}
$importJob->update([
'status' => $status['status'],
'total_records' => $status['totalRecords'] ?? 0,
'processed_records' => $status['processedRecords'] ?? 0,
'failed_records' => $status['failedRecords'] ?? 0,
]);
return response()->json($status);
} catch (\Throwable $e) {
$resolvedStatus = $this->resolveWorkflowStatus($importJob->workflow_id);
if ($resolvedStatus !== null) {
$importJob->update(['status' => $resolvedStatus]);
}
return response()->json([
'status' => $resolvedStatus ?? $importJob->status,
'error' => $e->getMessage(),
]);
}
}
// --- Order Fulfillment ---
public function processOrder(Request $request, int $orderId): JsonResponse
{
$order = Order::findOrFail($orderId);
$simulationConfig = $request->input('simulation', []);
$workflowId = 'order-fulfillment-' . $orderId;
$stub = $this->workflowClient->newWorkflowStub(
OrderFulfillmentWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, $orderId, $simulationConfig);
return response()->json([
'message' => 'Order processing started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'order_id' => $orderId,
]);
}
public function shipOrder(int $orderId, Request $request): JsonResponse
{
$trackingNumber = $request->input('tracking_number', 'TRACK-' . strtoupper(uniqid()));
$workflowId = 'order-fulfillment-' . $orderId;
$stub = $this->workflowClient->newRunningWorkflowStub(
OrderFulfillmentWorkflowInterface::class,
$workflowId
);
$stub->confirmShipping($trackingNumber);
return response()->json([
'message' => 'Shipping confirmation sent',
'workflow_id' => $workflowId,
'tracking_number' => $trackingNumber,
]);
}
public function orderStatus(int $orderId): JsonResponse
{
$workflowId = 'order-fulfillment-' . $orderId;
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
OrderFulfillmentWorkflowInterface::class,
$workflowId
);
$status = $stub->getOrderStatus();
return response()->json($status);
} catch (\Throwable $e) {
return response()->json([
'error' => $e->getMessage(),
'order_id' => $orderId,
]);
}
}
// --- User Migration ---
public function startMigration(Request $request): JsonResponse
{
$totalUsers = $request->input('total_users', 100);
$batchSize = $request->input('batch_size', 20);
$simulationConfig = $request->input('simulation', []);
$workflowId = 'user-migration-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
UserMigrationWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, (int) $totalUsers, (int) $batchSize, $simulationConfig);
$importJob = ImportJob::create([
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'type' => 'user_migration',
'status' => 'started',
'total_records' => $totalUsers,
]);
return response()->json([
'message' => 'User migration started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'import_job_id' => $importJob->id,
]);
}
public function pauseMigration(string $id): JsonResponse
{
$importJob = ImportJob::findOrFail($id);
$stub = $this->workflowClient->newRunningWorkflowStub(
UserMigrationWorkflowInterface::class,
$importJob->workflow_id
);
$stub->pause();
$importJob->update(['status' => 'paused']);
return response()->json(['message' => 'Migration paused', 'workflow_id' => $importJob->workflow_id]);
}
public function resumeMigration(string $id): JsonResponse
{
$importJob = ImportJob::findOrFail($id);
$stub = $this->workflowClient->newRunningWorkflowStub(
UserMigrationWorkflowInterface::class,
$importJob->workflow_id
);
$stub->resume();
$importJob->update(['status' => 'processing']);
return response()->json(['message' => 'Migration resumed', 'workflow_id' => $importJob->workflow_id]);
}
public function migrationStatus(string $id): JsonResponse
{
$importJob = ImportJob::findOrFail($id);
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
UserMigrationWorkflowInterface::class,
$importJob->workflow_id
);
$status = $stub->getProgress();
// Query handler returns cached internal state — cross-check
// actual execution status when the query says non-terminal
if (!in_array($status['status'], ['completed', 'cancelled', 'failed'])) {
$resolved = $this->resolveWorkflowStatus($importJob->workflow_id);
if ($resolved !== null) {
$status['status'] = $resolved;
}
}
$importJob->update([
'status' => $status['status'],
'processed_records' => $status['processedUsers'] ?? 0,
'failed_records' => $status['failedUsers'] ?? 0,
]);
return response()->json($status);
} catch (\Throwable $e) {
$resolvedStatus = $this->resolveWorkflowStatus($importJob->workflow_id);
if ($resolvedStatus !== null) {
$importJob->update(['status' => $resolvedStatus]);
}
return response()->json([
'status' => $resolvedStatus ?? $importJob->status,
'error' => $e->getMessage(),
]);
}
}
// --- External API Sync ---
public function startApiSync(Request $request): JsonResponse
{
$apiEndpoint = $request->input('api_endpoint', 'https://api.example.com/products');
$simulationConfig = $request->input('simulation', []);
$workflowId = 'api-sync-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
ExternalApiSyncWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, $apiEndpoint, $simulationConfig);
return response()->json([
'message' => 'API sync started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
]);
}
public function apiSyncStatus(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
ExternalApiSyncWorkflowInterface::class,
$id
);
return response()->json($stub->getProgress());
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
public function pauseApiSync(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
ExternalApiSyncWorkflowInterface::class,
$id
);
$stub->pause();
return response()->json(['message' => 'API sync paused']);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
public function resumeApiSync(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
ExternalApiSyncWorkflowInterface::class,
$id
);
$stub->resume();
return response()->json(['message' => 'API sync resumed']);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
// --- Webhook Delivery ---
public function deliverWebhooks(Request $request): JsonResponse
{
$payload = $request->input('payload', ['event' => 'order.created', 'data' => ['order_id' => 1]]);
$endpoints = $request->input('endpoints', [
'https://api.example.com/webhook',
'https://hooks.partner.io/events',
'https://notify.service.dev/incoming',
'https://webhook.site/test-endpoint',
]);
$simulationConfig = $request->input('simulation', []);
$workflowId = 'webhook-delivery-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
WebhookDeliveryWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, $payload, $endpoints, $simulationConfig);
return response()->json([
'message' => 'Webhook delivery started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'endpoints_count' => count($endpoints),
]);
}
public function webhookStatus(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
WebhookDeliveryWorkflowInterface::class,
$id
);
return response()->json($stub->getDeliveryStatus());
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
// --- Data Enrichment ---
public function startEnrichment(Request $request): JsonResponse
{
$recordIds = $request->input('record_ids', []);
$simulationConfig = $request->input('simulation', []);
// Default to first 5 orders if no IDs provided
if (empty($recordIds)) {
$recordIds = Order::orderBy('id')->take(5)->pluck('id')->toArray();
}
$workflowId = 'data-enrichment-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
DataEnrichmentWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, $recordIds, $simulationConfig);
return response()->json([
'message' => 'Data enrichment started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
'record_count' => count($recordIds),
]);
}
public function enrichmentStatus(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
DataEnrichmentWorkflowInterface::class,
$id
);
return response()->json($stub->getProgress());
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
// --- Eloquent Query Pipeline ---
public function startEloquentQuery(Request $request): JsonResponse
{
$simulationConfig = $request->input('simulation', []);
$workflowId = 'eloquent-query-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
EloquentQueryWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, $simulationConfig);
return response()->json([
'message' => 'Eloquent query pipeline started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
]);
}
public function eloquentQueryStatus(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
EloquentQueryWorkflowInterface::class,
$id
);
return response()->json($stub->getProgress());
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
// --- System Health Monitor ---
public function startSystemMonitor(Request $request): JsonResponse
{
$intervalSeconds = $request->input('interval_seconds', 60);
$maxIterations = $request->input('max_iterations', 30);
$simulationConfig = $request->input('simulation', []);
$workflowId = 'system-monitor-' . uniqid();
$stub = $this->workflowClient->newWorkflowStub(
SystemMonitorWorkflowInterface::class,
WorkflowOptions::new()
->withWorkflowId($workflowId)
->withTaskQueue(config('temporal.task_queue'))
);
$run = $this->workflowClient->start($stub, (int) $intervalSeconds, (int) $maxIterations, [], $simulationConfig);
return response()->json([
'message' => 'System monitor started',
'workflow_id' => $workflowId,
'run_id' => $run->getExecution()->getRunID(),
]);
}
public function systemMonitorStatus(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
SystemMonitorWorkflowInterface::class,
$id
);
return response()->json($stub->getStatus());
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
public function stopSystemMonitor(string $id): JsonResponse
{
try {
$stub = $this->workflowClient->newRunningWorkflowStub(
SystemMonitorWorkflowInterface::class,
$id
);
$stub->stop();
return response()->json(['message' => 'Stop signal sent']);
} catch (\Throwable $e) {
return response()->json(['error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
//
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EnrichmentResult extends Model
{
protected $fillable = [
'record_type',
'record_id',
'geocode_result',
'email_valid',
'credit_score',
'enriched_at',
];
protected $casts = [
'geocode_result' => 'array',
'email_valid' => 'boolean',
'enriched_at' => 'datetime',
];
}

24
app/Models/ImportJob.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ImportJob extends Model
{
protected $fillable = [
'workflow_id',
'run_id',
'type',
'file_path',
'status',
'total_records',
'processed_records',
'failed_records',
'error_log',
];
protected $casts = [
'error_log' => 'array',
];
}

28
app/Models/Order.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Order extends Model
{
protected $fillable = [
'order_number',
'customer_name',
'customer_email',
'status',
'total_amount',
'payment_id',
'tracking_number',
];
protected $casts = [
'total_amount' => 'decimal:2',
];
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}

30
app/Models/OrderItem.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OrderItem extends Model
{
protected $fillable = [
'order_id',
'product_id',
'quantity',
'unit_price',
];
protected $casts = [
'unit_price' => 'decimal:2',
];
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function product(): BelongsTo
{
return $this->belongsTo(Product::class);
}
}

30
app/Models/Product.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Product extends Model
{
protected $fillable = [
'sku',
'name',
'description',
'price',
'stock_quantity',
'category',
'status',
'imported_at',
];
protected $casts = [
'price' => 'decimal:2',
'imported_at' => 'datetime',
];
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}

48
app/Models/User.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class WebhookDelivery extends Model
{
protected $fillable = [
'workflow_id',
'endpoint',
'payload',
'status',
'attempts',
'last_error',
'delivered_at',
'dead_lettered_at',
];
protected $casts = [
'payload' => 'array',
'delivered_at' => 'datetime',
'dead_lettered_at' => 'datetime',
];
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Temporal\Client\GRPC\ServiceClient;
use Temporal\Client\WorkflowClient;
use Temporal\Client\WorkflowClientInterface;
class TemporalServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(WorkflowClientInterface::class, function () {
return WorkflowClient::create(
ServiceClient::create(config('temporal.address'))
);
});
$this->app->alias(WorkflowClientInterface::class, WorkflowClient::class);
}
public function boot(): void
{
$this->publishes([
__DIR__ . '/../../config/temporal.php' => config_path('temporal.php'),
], 'temporal-config');
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Temporal\DataEnrichment;
use App\Models\EnrichmentResult;
use App\Temporal\Shared\FaultSimulator;
use Temporal\Activity;
class DataEnrichmentActivity implements DataEnrichmentActivityInterface
{
public function geocodeAddress(array $record, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'geocodeAddress');
// Simulate geocoding API latency
usleep(mt_rand(200000, 500000));
return [
'lat' => round(mt_rand(2500, 4800) / 100, 6),
'lng' => round(mt_rand(-12200, -7100) / 100, 6),
'confidence' => round(mt_rand(70, 99) / 100, 2),
'attempt' => Activity::getInfo()->attempt,
];
}
public function validateEmail(array $record, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'validateEmail');
// Simulate email validation API latency
usleep(mt_rand(50000, 150000));
$email = $record['customer_email'] ?? 'unknown@example.com';
$isValid = filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
return [
'valid' => $isValid,
'disposable' => mt_rand(1, 100) <= 5,
'deliverable' => $isValid && mt_rand(1, 100) <= 95,
'attempt' => Activity::getInfo()->attempt,
];
}
public function calculateCreditScore(array $record, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'calculateCreditScore');
// Simulate credit scoring API latency
usleep(mt_rand(300000, 600000));
return [
'score' => mt_rand(300, 850),
'risk_level' => ['low', 'medium', 'high'][array_rand(['low', 'medium', 'high'])],
'attempt' => Activity::getInfo()->attempt,
];
}
public function saveEnrichedRecord(int $recordId, array $enrichments): bool
{
EnrichmentResult::updateOrCreate(
['record_type' => 'order', 'record_id' => $recordId],
[
'geocode_result' => $enrichments['geocode'] ?? null,
'email_valid' => $enrichments['email']['valid'] ?? null,
'credit_score' => $enrichments['credit']['score'] ?? null,
'enriched_at' => now(),
]
);
return true;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Temporal\DataEnrichment;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface DataEnrichmentActivityInterface
{
#[ActivityMethod]
public function geocodeAddress(array $record, array $simulationConfig = []): array;
#[ActivityMethod]
public function validateEmail(array $record, array $simulationConfig = []): array;
#[ActivityMethod]
public function calculateCreditScore(array $record, array $simulationConfig = []): array;
#[ActivityMethod]
public function saveEnrichedRecord(int $recordId, array $enrichments): bool;
}

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Temporal\DataEnrichment;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class DataEnrichmentWorkflow implements DataEnrichmentWorkflowInterface
{
private string $status = 'pending';
private int $totalRecords = 0;
private int $enrichedRecords = 0;
private int $failedRecords = 0;
private array $apiStats = [
'geocode' => ['calls' => 0, 'retries' => 0, 'rateLimits' => 0],
'email' => ['calls' => 0, 'retries' => 0, 'rateLimits' => 0],
'credit' => ['calls' => 0, 'retries' => 0, 'rateLimits' => 0],
];
/** @var DataEnrichmentActivityInterface - geocoding: patient retries */
private $geocodeStub;
/** @var DataEnrichmentActivityInterface - email: fast fail */
private $emailStub;
/** @var DataEnrichmentActivityInterface - credit: medium retries */
private $creditStub;
/** @var DataEnrichmentActivityInterface - save: minimal retries */
private $saveStub;
public function __construct()
{
// Geocoding: 5 attempts, 3s initial, 1.5 backoff (patient)
$this->geocodeStub = Workflow::newActivityStub(
DataEnrichmentActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(2))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(5)
->withInitialInterval(CarbonInterval::seconds(3))
->withBackoffCoefficient(1.5)
)
);
// Email validation: 2 attempts, 500ms initial, 2.0 backoff (fast fail)
$this->emailStub = Workflow::newActivityStub(
DataEnrichmentActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::seconds(30))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(2)
->withInitialInterval(CarbonInterval::milliseconds(500))
->withBackoffCoefficient(2.0)
)
);
// Credit scoring: 3 attempts, 2s initial, 2.0 backoff (medium)
$this->creditStub = Workflow::newActivityStub(
DataEnrichmentActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(1))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(3)
->withInitialInterval(CarbonInterval::seconds(2))
->withBackoffCoefficient(2.0)
)
);
// Save: just 1 attempt
$this->saveStub = Workflow::newActivityStub(
DataEnrichmentActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::seconds(15))
->withRetryOptions(
RetryOptions::new()->withMaximumAttempts(2)
)
);
}
public function enrich(array $recordIds, array $simulationConfig = []): \Generator
{
$this->totalRecords = count($recordIds);
$this->status = 'enriching';
try {
$batchSize = 5;
$batches = array_chunk($recordIds, $batchSize);
foreach ($batches as $batch) {
foreach ($batch as $recordId) {
// Run 3 enrichment APIs in parallel per record
$geocodePromise = Workflow::async(function () use ($recordId, $simulationConfig) {
try {
$result = yield $this->geocodeStub->geocodeAddress(
['id' => $recordId],
$simulationConfig
);
return ['success' => true, 'data' => (array) $result];
} catch (\Throwable $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
});
$emailPromise = Workflow::async(function () use ($recordId, $simulationConfig) {
try {
$result = yield $this->emailStub->validateEmail(
['id' => $recordId, 'customer_email' => "customer{$recordId}@example.com"],
$simulationConfig
);
return ['success' => true, 'data' => (array) $result];
} catch (\Throwable $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
});
$creditPromise = Workflow::async(function () use ($recordId, $simulationConfig) {
try {
$result = yield $this->creditStub->calculateCreditScore(
['id' => $recordId],
$simulationConfig
);
return ['success' => true, 'data' => (array) $result];
} catch (\Throwable $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
});
// Wait for all 3 to complete
$geocodeResult = (array) (yield $geocodePromise);
$emailResult = (array) (yield $emailPromise);
$creditResult = (array) (yield $creditPromise);
// Track per-API stats
$this->trackApiStat('geocode', $geocodeResult);
$this->trackApiStat('email', $emailResult);
$this->trackApiStat('credit', $creditResult);
// Save enriched data
$enrichments = [
'geocode' => $geocodeResult['success'] ? $geocodeResult['data'] : null,
'email' => $emailResult['success'] ? $emailResult['data'] : null,
'credit' => $creditResult['success'] ? $creditResult['data'] : null,
];
$anySuccess = $geocodeResult['success'] || $emailResult['success'] || $creditResult['success'];
if ($anySuccess) {
yield $this->saveStub->saveEnrichedRecord($recordId, $enrichments);
$this->enrichedRecords++;
} else {
$this->failedRecords++;
}
}
}
} catch (\Throwable $e) {
$this->status = 'failed';
throw $e;
}
$this->status = 'completed';
return [
'status' => 'completed',
'totalRecords' => $this->totalRecords,
'enrichedRecords' => $this->enrichedRecords,
'failedRecords' => $this->failedRecords,
'apiStats' => $this->apiStats,
];
}
private function trackApiStat(string $api, array $result): void
{
$this->apiStats[$api]['calls']++;
if ($result['success'] && isset($result['data']['attempt'])) {
$attempt = $result['data']['attempt'];
if ($attempt > 1) {
$this->apiStats[$api]['retries'] += ($attempt - 1);
}
}
}
public function getProgress(): array
{
return [
'status' => $this->status,
'totalRecords' => $this->totalRecords,
'enrichedRecords' => $this->enrichedRecords,
'failedRecords' => $this->failedRecords,
'apiStats' => $this->apiStats,
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Temporal\DataEnrichment;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Temporal\Workflow\QueryMethod;
#[WorkflowInterface]
interface DataEnrichmentWorkflowInterface
{
#[WorkflowMethod]
public function enrich(array $recordIds, array $simulationConfig = []);
#[QueryMethod]
public function getProgress(): array;
}

View File

@@ -0,0 +1,393 @@
<?php
namespace App\Temporal\EloquentQuery;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use App\Temporal\Shared\FaultSimulator;
use Illuminate\Support\Facades\DB;
use Temporal\Activity;
class EloquentQueryActivity implements EloquentQueryActivityInterface
{
public function runInventoryAnalytics(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runInventoryAnalytics');
$startTime = microtime(true);
$queriesRun = 0;
// Global stats with aggregation functions
$globalStats = Product::selectRaw('
COUNT(*) as total_products,
SUM(stock_quantity) as total_stock,
AVG(price) as avg_price,
MIN(price) as min_price,
MAX(price) as max_price,
SUM(price * stock_quantity) as total_inventory_value
')->first();
$queriesRun++;
// Per-category breakdown
$categoryBreakdown = Product::select('category')
->selectRaw('COUNT(*) as product_count')
->selectRaw('SUM(stock_quantity) as category_stock')
->selectRaw('AVG(price) as avg_price')
->selectRaw('SUM(price * stock_quantity) as category_value')
->groupBy('category')
->orderByDesc('category_value')
->get()
->toArray();
$queriesRun++;
// Stock level distribution using CASE WHEN
$stockDistribution = Product::selectRaw("
CASE
WHEN stock_quantity = 0 THEN 'out_of_stock'
WHEN stock_quantity BETWEEN 1 AND 10 THEN 'low_stock'
WHEN stock_quantity BETWEEN 11 AND 50 THEN 'medium_stock'
ELSE 'high_stock'
END as stock_level
")
->selectRaw('COUNT(*) as count')
->groupBy('stock_level')
->get()
->toArray();
$queriesRun++;
return [
'queriesRun' => $queriesRun,
'rowsAffected' => 0,
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
'data' => [
'globalStats' => $globalStats ? $globalStats->toArray() : [],
'categoryBreakdown' => $categoryBreakdown,
'stockDistribution' => $stockDistribution,
],
'attempt' => Activity::getInfo()->attempt,
];
}
public function runOrderDeepLoad(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runOrderDeepLoad');
$startTime = microtime(true);
$queriesRun = 0;
// Eager load orders with items and products, count items
$orders = Order::with(['items.product'])
->withCount('items')
->get();
$queriesRun += 3; // orders + items + products (eager loading)
// Collection mapping — build summary per order
$orderSummaries = $orders->map(function ($order) {
$items = $order->items;
$heaviest = $items->sortByDesc('quantity')->first();
return [
'id' => $order->id,
'order_number' => $order->order_number,
'customer_name' => $order->customer_name,
'items_count' => $order->items_count,
'total_amount' => $order->total_amount,
'heaviest_line_item' => $heaviest ? [
'product' => $heaviest->product?->name,
'quantity' => $heaviest->quantity,
] : null,
];
})->toArray();
// Products that have been ordered vs never ordered
$orderedProductCount = Product::whereHas('orderItems')->count();
$queriesRun++;
$neverOrderedCount = Product::whereDoesntHave('orderItems')->count();
$queriesRun++;
return [
'queriesRun' => $queriesRun,
'rowsAffected' => 0,
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
'data' => [
'orderCount' => $orders->count(),
'orderSummaries' => array_slice($orderSummaries, 0, 10),
'orderedProductCount' => $orderedProductCount,
'neverOrderedCount' => $neverOrderedCount,
],
'attempt' => Activity::getInfo()->attempt,
];
}
public function runProductScoring(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runProductScoring');
$startTime = microtime(true);
$queriesRun = 0;
// Premium products: high price, in stock, ordered by price desc
$premiumProducts = Product::where('price', '>', 50)
->where('stock_quantity', '>', 0)
->where('status', 'active')
->orderByDesc('price')
->limit(10)
->get(['id', 'name', 'price', 'stock_quantity', 'category'])
->toArray();
$queriesRun++;
// At-risk inventory: nested where with orWhere closures
$atRiskProducts = Product::where(function ($q) {
$q->where('stock_quantity', 0)
->where('status', 'active');
})->orWhere(function ($q) {
$q->where('stock_quantity', '<', 5)
->where('stock_quantity', '>', 0)
->whereHas('orderItems');
})->get(['id', 'name', 'stock_quantity', 'category', 'status'])
->toArray();
$queriesRun++;
// Category tiers: groupBy with having
$categoryTiers = Product::select('category')
->selectRaw('COUNT(*) as product_count')
->selectRaw('AVG(price) as avg_price')
->selectRaw('SUM(stock_quantity) as total_stock')
->groupBy('category')
->having('avg_price', '>', 50)
->get()
->toArray();
$queriesRun++;
// Demand score: leftJoin with computed ratio
$demandScores = DB::table('products')
->leftJoin('order_items', 'products.id', '=', 'order_items.product_id')
->select(
'products.id',
'products.name',
'products.stock_quantity',
'products.category'
)
->selectRaw('COALESCE(SUM(order_items.quantity), 0) as total_ordered')
->selectRaw('CASE WHEN products.stock_quantity > 0 THEN ROUND(COALESCE(SUM(order_items.quantity), 0)::numeric / products.stock_quantity, 2) ELSE 0 END as demand_ratio')
->groupBy('products.id', 'products.name', 'products.stock_quantity', 'products.category')
->orderByDesc('demand_ratio')
->limit(15)
->get()
->map(fn ($r) => (array) $r)
->toArray();
$queriesRun++;
return [
'queriesRun' => $queriesRun,
'rowsAffected' => 0,
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
'data' => [
'premiumProducts' => $premiumProducts,
'atRiskProducts' => array_slice($atRiskProducts, 0, 10),
'categoryTiers' => $categoryTiers,
'demandScores' => $demandScores,
],
'attempt' => Activity::getInfo()->attempt,
];
}
public function runStockAudit(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runStockAudit');
$startTime = microtime(true);
$queriesRun = 0;
$rowsAffected = 0;
$negativeFixed = 0;
$timestamped = 0;
Product::orderBy('id')->chunk(100, function ($products) use (&$queriesRun, &$rowsAffected, &$negativeFixed, &$timestamped) {
$queriesRun++; // each chunk is a query
Activity::heartbeat([
'processed' => $queriesRun * 100,
'negativeFixed' => $negativeFixed,
'timestamped' => $timestamped,
]);
foreach ($products as $product) {
$changed = false;
$updates = [];
// Fix negative stock
if ($product->stock_quantity < 0) {
$updates['stock_quantity'] = 0;
$negativeFixed++;
$changed = true;
}
// Stamp missing imported_at
if ($product->imported_at === null) {
$updates['imported_at'] = now();
$timestamped++;
$changed = true;
}
if ($changed) {
$product->update($updates);
$rowsAffected++;
$queriesRun++;
}
}
});
return [
'queriesRun' => $queriesRun,
'rowsAffected' => $rowsAffected,
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
'data' => [
'negativeStockFixed' => $negativeFixed,
'missingTimestampFixed' => $timestamped,
'note' => 'Idempotent — subsequent runs make 0 changes if data is clean',
],
'attempt' => Activity::getInfo()->attempt,
];
}
public function runPriceRecalculation(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runPriceRecalculation');
$startTime = microtime(true);
$queriesRun = 0;
$rowsAffected = 0;
$discrepancies = [];
// Recalculate each order total from its items within a transaction
$orderIds = Order::pluck('id')->toArray();
$queriesRun++;
foreach ($orderIds as $orderId) {
DB::transaction(function () use ($orderId, &$queriesRun, &$rowsAffected, &$discrepancies) {
$order = Order::lockForUpdate()->find($orderId);
$queriesRun++;
if (!$order) {
return;
}
$recalculated = OrderItem::where('order_id', $orderId)
->selectRaw('SUM(quantity * unit_price) as computed_total')
->value('computed_total') ?? 0;
$queriesRun++;
$oldTotal = (float) $order->total_amount;
$newTotal = round((float) $recalculated, 2);
if (abs($oldTotal - $newTotal) > 0.01) {
$discrepancies[] = [
'order_id' => $orderId,
'old_total' => $oldTotal,
'new_total' => $newTotal,
'diff' => round($newTotal - $oldTotal, 2),
];
$order->update(['total_amount' => $newTotal]);
$rowsAffected++;
$queriesRun++;
}
});
}
// Demonstrate updateOrCreate (upsert) pattern
Product::updateOrCreate(
['sku' => '_RECONCILIATION_MARKER'],
[
'name' => 'Reconciliation Marker',
'price' => 0,
'stock_quantity' => count($orderIds),
'category' => 'system',
'status' => 'inactive',
'description' => 'Last reconciliation: ' . now()->toISOString(),
'imported_at' => now(),
]
);
$queriesRun++;
$rowsAffected++;
return [
'queriesRun' => $queriesRun,
'rowsAffected' => $rowsAffected,
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
'data' => [
'ordersChecked' => count($orderIds),
'discrepancies' => $discrepancies,
'discrepancyCount' => count($discrepancies),
'reconciliationMarkerSet' => true,
],
'attempt' => Activity::getInfo()->attempt,
];
}
public function runSummaryReport(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runSummaryReport');
$startTime = microtime(true);
$queriesRun = 0;
// Top-selling products via DB::table join
$topSelling = DB::table('order_items')
->select('products.id', 'products.name', 'products.category')
->selectRaw('SUM(order_items.quantity) as total_sold')
->selectRaw('SUM(order_items.quantity * order_items.unit_price) as total_revenue')
->join('products', 'order_items.product_id', '=', 'products.id')
->groupBy('products.id', 'products.name', 'products.category')
->orderByDesc('total_revenue')
->limit(10)
->get()
->map(fn ($r) => (array) $r)
->toArray();
$queriesRun++;
// Revenue per category
$revenueByCategory = DB::table('order_items')
->join('products', 'order_items.product_id', '=', 'products.id')
->select('products.category')
->selectRaw('SUM(order_items.quantity * order_items.unit_price) as category_revenue')
->selectRaw('COUNT(DISTINCT order_items.order_id) as order_count')
->groupBy('products.category')
->orderByDesc('category_revenue')
->get()
->map(fn ($r) => (array) $r)
->toArray();
$queriesRun++;
// Customer spend ranking
$customerRanking = Order::select('customer_name')
->selectRaw('COUNT(*) as order_count')
->selectRaw('SUM(total_amount) as total_spent')
->selectRaw('AVG(total_amount) as avg_order_value')
->groupBy('customer_name')
->orderByDesc('total_spent')
->limit(10)
->get()
->toArray();
$queriesRun++;
// Cross-model health summary
$healthSummary = [
'products' => Product::count(),
'active_products' => Product::where('status', 'active')->count(),
'orders' => Order::count(),
'order_items' => OrderItem::count(),
'users' => User::count(),
];
$queriesRun += 5;
return [
'queriesRun' => $queriesRun,
'rowsAffected' => 0,
'executionTimeMs' => round((microtime(true) - $startTime) * 1000, 2),
'data' => [
'topSelling' => $topSelling,
'revenueByCategory' => $revenueByCategory,
'customerRanking' => $customerRanking,
'healthSummary' => $healthSummary,
],
'attempt' => Activity::getInfo()->attempt,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Temporal\EloquentQuery;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface EloquentQueryActivityInterface
{
#[ActivityMethod]
public function runInventoryAnalytics(array $simulationConfig = []): array;
#[ActivityMethod]
public function runOrderDeepLoad(array $simulationConfig = []): array;
#[ActivityMethod]
public function runProductScoring(array $simulationConfig = []): array;
#[ActivityMethod]
public function runStockAudit(array $simulationConfig = []): array;
#[ActivityMethod]
public function runPriceRecalculation(array $simulationConfig = []): array;
#[ActivityMethod]
public function runSummaryReport(array $simulationConfig = []): array;
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Temporal\EloquentQuery;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class EloquentQueryWorkflow implements EloquentQueryWorkflowInterface
{
private string $status = 'pending';
private string $currentStep = '';
private int $completedSteps = 0;
private array $stepResults = [];
private int $totalQueriesRun = 0;
private int $totalRowsAffected = 0;
private float $totalExecutionTimeMs = 0;
/** @var EloquentQueryActivityInterface */
private $activityStub;
private const STEPS = [
'runInventoryAnalytics' => 'Inventory Analytics',
'runOrderDeepLoad' => 'Order Deep Load',
'runProductScoring' => 'Product Scoring',
'runStockAudit' => 'Stock Audit',
'runPriceRecalculation' => 'Price Recalculation',
'runSummaryReport' => 'Summary Report',
];
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
EloquentQueryActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(2))
->withHeartbeatTimeout(CarbonInterval::seconds(30))
->withRetryOptions(
RetryOptions::new()->withMaximumAttempts(3)
)
);
}
public function runPipeline(array $simulationConfig = []): \Generator
{
$this->status = 'running';
try {
foreach (self::STEPS as $method => $label) {
$this->currentStep = $label;
$result = yield $this->activityStub->$method($simulationConfig);
$this->stepResults[$method] = [
'label' => $label,
'queriesRun' => $result['queriesRun'] ?? 0,
'rowsAffected' => $result['rowsAffected'] ?? 0,
'executionTimeMs' => $result['executionTimeMs'] ?? 0,
'data' => $result['data'] ?? [],
'attempt' => $result['attempt'] ?? 1,
];
$this->totalQueriesRun += $result['queriesRun'] ?? 0;
$this->totalRowsAffected += $result['rowsAffected'] ?? 0;
$this->totalExecutionTimeMs += $result['executionTimeMs'] ?? 0;
$this->completedSteps++;
}
} catch (\Throwable $e) {
$this->status = 'failed';
throw $e;
}
$this->status = 'completed';
$this->currentStep = '';
return [
'status' => 'completed',
'completedSteps' => $this->completedSteps,
'totalQueriesRun' => $this->totalQueriesRun,
'totalRowsAffected' => $this->totalRowsAffected,
'totalExecutionTimeMs' => $this->totalExecutionTimeMs,
'stepResults' => $this->stepResults,
];
}
public function getProgress(): array
{
return [
'status' => $this->status,
'currentStep' => $this->currentStep,
'completedSteps' => $this->completedSteps,
'totalSteps' => count(self::STEPS),
'stepResults' => $this->stepResults,
'totalQueriesRun' => $this->totalQueriesRun,
'totalRowsAffected' => $this->totalRowsAffected,
'totalExecutionTimeMs' => $this->totalExecutionTimeMs,
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Temporal\EloquentQuery;
use Temporal\Workflow\QueryMethod;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
#[WorkflowInterface]
interface EloquentQueryWorkflowInterface
{
#[WorkflowMethod]
public function runPipeline(array $simulationConfig = []);
#[QueryMethod]
public function getProgress(): array;
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Temporal\ExternalApiSync;
use App\Models\Product;
use App\Temporal\Shared\FaultSimulator;
use Illuminate\Support\Str;
use Temporal\Activity;
class ExternalApiSyncActivity implements ExternalApiSyncActivityInterface
{
private const PAGE_SIZE = 10;
private const TOTAL_SIMULATED_RECORDS = 50;
public function refreshToken(array $simulationConfig = []): string
{
FaultSimulator::maybeApply($simulationConfig, 'refreshToken');
// Simulate OAuth token refresh
usleep(200000);
return 'tok_' . Str::random(32);
}
public function fetchPage(string $cursor, string $token, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'fetchPage');
Activity::heartbeat(['cursor' => $cursor, 'token_prefix' => substr($token, 0, 8)]);
// Simulate API latency
usleep(150000);
$offset = (int) $cursor;
$records = [];
for ($i = $offset; $i < min($offset + self::PAGE_SIZE, self::TOTAL_SIMULATED_RECORDS); $i++) {
$records[] = [
'external_id' => 'EXT-' . str_pad((string) ($i + 1), 5, '0', STR_PAD_LEFT),
'name' => 'Synced Product ' . ($i + 1),
'description' => 'Product synced from external API',
'price' => round(mt_rand(500, 50000) / 100, 2),
'stock' => mt_rand(10, 500),
'category' => ['Electronics', 'Clothing', 'Home & Garden', 'Sports'][array_rand(['Electronics', 'Clothing', 'Home & Garden', 'Sports'])],
];
}
$nextOffset = $offset + self::PAGE_SIZE;
$hasMore = $nextOffset < self::TOTAL_SIMULATED_RECORDS;
return [
'records' => $records,
'nextCursor' => (string) $nextOffset,
'hasMore' => $hasMore,
'attempt' => Activity::getInfo()->attempt,
];
}
public function transformAndStore(array $records, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'transformAndStore');
$stored = 0;
$skipped = 0;
foreach ($records as $record) {
$sku = 'SYNC-' . $record['external_id'];
try {
Product::updateOrCreate(
['sku' => $sku],
[
'name' => $record['name'],
'description' => $record['description'],
'price' => $record['price'],
'stock_quantity' => $record['stock'],
'category' => $record['category'],
'status' => 'active',
'imported_at' => now(),
]
);
$stored++;
} catch (\Throwable) {
$skipped++;
}
}
return [
'stored' => $stored,
'skipped' => $skipped,
'attempt' => Activity::getInfo()->attempt,
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Temporal\ExternalApiSync;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface ExternalApiSyncActivityInterface
{
#[ActivityMethod]
public function refreshToken(array $simulationConfig = []): string;
#[ActivityMethod]
public function fetchPage(string $cursor, string $token, array $simulationConfig = []): array;
#[ActivityMethod]
public function transformAndStore(array $records, array $simulationConfig = []): array;
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Temporal\ExternalApiSync;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class ExternalApiSyncWorkflow implements ExternalApiSyncWorkflowInterface
{
private string $status = 'pending';
private int $pagesFetched = 0;
private int $recordsSynced = 0;
private int $rateLimitHits = 0;
private int $retryCount = 0;
private string $currentCursor = '0';
private bool $isPaused = false;
/** @var ExternalApiSyncActivityInterface */
private $activityStub;
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
ExternalApiSyncActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(5))
->withHeartbeatTimeout(CarbonInterval::seconds(30))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(5)
->withInitialInterval(CarbonInterval::seconds(2))
->withBackoffCoefficient(2.0)
->withMaximumInterval(CarbonInterval::seconds(30))
)
);
}
public function sync(string $apiEndpoint, array $simulationConfig = []): \Generator
{
try {
// Step 1: Refresh API token
$this->status = 'authenticating';
$token = yield $this->activityStub->refreshToken($simulationConfig);
// Step 2: Paginated fetch loop
$this->status = 'syncing';
$hasMore = true;
while ($hasMore) {
// Check for pause
if ($this->isPaused) {
$this->status = 'paused';
yield Workflow::await(fn () => !$this->isPaused);
$this->status = 'syncing';
}
// Fetch one page
$pageResult = yield $this->activityStub->fetchPage($this->currentCursor, $token, $simulationConfig);
$pageResult = (array) $pageResult;
$attempt = $pageResult['attempt'] ?? 1;
if ($attempt > 1) {
$this->retryCount += ($attempt - 1);
}
$this->pagesFetched++;
$this->currentCursor = $pageResult['nextCursor'];
$hasMore = $pageResult['hasMore'];
// Transform and store records
if (!empty($pageResult['records'])) {
$storeResult = yield $this->activityStub->transformAndStore($pageResult['records'], $simulationConfig);
$storeResult = (array) $storeResult;
$this->recordsSynced += $storeResult['stored'] ?? 0;
$storeAttempt = $storeResult['attempt'] ?? 1;
if ($storeAttempt > 1) {
$this->retryCount += ($storeAttempt - 1);
}
}
}
} catch (\Throwable $e) {
$this->status = 'failed';
throw $e;
}
$this->status = 'completed';
return [
'status' => 'completed',
'pagesFetched' => $this->pagesFetched,
'recordsSynced' => $this->recordsSynced,
'rateLimitHits' => $this->rateLimitHits,
'retryCount' => $this->retryCount,
];
}
public function pause(): void
{
$this->isPaused = true;
}
public function resume(): void
{
$this->isPaused = false;
}
public function getProgress(): array
{
return [
'status' => $this->status,
'pagesFetched' => $this->pagesFetched,
'recordsSynced' => $this->recordsSynced,
'rateLimitHits' => $this->rateLimitHits,
'retryCount' => $this->retryCount,
'currentCursor' => $this->currentCursor,
'isPaused' => $this->isPaused,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Temporal\ExternalApiSync;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\QueryMethod;
#[WorkflowInterface]
interface ExternalApiSyncWorkflowInterface
{
#[WorkflowMethod]
public function sync(string $apiEndpoint, array $simulationConfig = []);
#[SignalMethod]
public function pause(): void;
#[SignalMethod]
public function resume(): void;
#[QueryMethod]
public function getProgress(): array;
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Temporal\OrderFulfillment;
use App\Models\Order;
use App\Temporal\Shared\FaultSimulator;
use Illuminate\Support\Facades\Log;
use Temporal\Activity;
class OrderActivity implements OrderActivityInterface
{
public function validateOrder(int $orderId, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'validateOrder');
$order = Order::find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
if ($order->status !== 'pending') {
throw new \RuntimeException("Order #{$orderId} is not in 'pending' status. Current status: {$order->status}");
}
Log::info("Order #{$orderId} validated successfully.");
return true;
}
public function checkInventory(int $orderId, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'checkInventory');
$order = Order::with('items.product')->find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
foreach ($order->items as $item) {
if ($item->product->stock_quantity < $item->quantity) {
throw new \RuntimeException(
"Insufficient stock for product '{$item->product->name}'. "
. "Available: {$item->product->stock_quantity}, Requested: {$item->quantity}"
);
}
}
Log::info("Inventory check passed for order #{$orderId}.");
return true;
}
public function processPayment(int $orderId, array $simulationConfig = []): string
{
FaultSimulator::maybeApply($simulationConfig, 'processPayment');
$order = Order::find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
$paymentId = 'PAY-' . strtoupper(substr(md5(uniqid((string) $orderId, true)), 0, 12));
$order->update([
'status' => 'processing',
'payment_id' => $paymentId,
]);
Log::info("Payment processed for order #{$orderId}. Payment ID: {$paymentId}");
return $paymentId;
}
public function refundPayment(int $orderId, string $paymentId): bool
{
usleep(300000);
$order = Order::find($orderId);
if ($order) {
$order->update([
'status' => 'refunded',
'payment_id' => null,
]);
}
Log::warning("Payment refunded for order #{$orderId}. Payment ID: {$paymentId}");
return true;
}
public function updateInventory(int $orderId, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'updateInventory');
$order = Order::with('items.product')->find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
foreach ($order->items as $item) {
$item->product->decrement('stock_quantity', $item->quantity);
}
Log::info("Inventory updated (decremented) for order #{$orderId}.");
return true;
}
public function restoreInventory(int $orderId): bool
{
usleep(200000);
$order = Order::with('items.product')->find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
foreach ($order->items as $item) {
$item->product->increment('stock_quantity', $item->quantity);
}
Log::info("Inventory restored (incremented) for order #{$orderId}.");
return true;
}
public function notifyWarehouse(int $orderId, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'notifyWarehouse');
$order = Order::find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
$order->update(['status' => 'warehouse_notified']);
Log::info("Warehouse notified for order #{$orderId}.");
return true;
}
public function cancelWarehouseNotification(int $orderId): bool
{
usleep(100000);
$order = Order::find($orderId);
if ($order) {
$order->update(['status' => 'warehouse_cancelled']);
}
Log::warning("Warehouse notification cancelled for order #{$orderId}.");
return true;
}
public function sendTrackingInfo(int $orderId, string $trackingNumber, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'sendTrackingInfo');
$order = Order::find($orderId);
if (!$order) {
throw new \RuntimeException("Order #{$orderId} not found.");
}
$order->update([
'tracking_number' => $trackingNumber,
'status' => 'shipped',
]);
Log::info("Tracking info sent for order #{$orderId}. Tracking: {$trackingNumber}");
return true;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Temporal\OrderFulfillment;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface OrderActivityInterface
{
#[ActivityMethod]
public function validateOrder(int $orderId, array $simulationConfig = []): bool;
#[ActivityMethod]
public function checkInventory(int $orderId, array $simulationConfig = []): bool;
#[ActivityMethod]
public function processPayment(int $orderId, array $simulationConfig = []): string;
#[ActivityMethod]
public function refundPayment(int $orderId, string $paymentId): bool;
#[ActivityMethod]
public function updateInventory(int $orderId, array $simulationConfig = []): bool;
#[ActivityMethod]
public function restoreInventory(int $orderId): bool;
#[ActivityMethod]
public function notifyWarehouse(int $orderId, array $simulationConfig = []): bool;
#[ActivityMethod]
public function cancelWarehouseNotification(int $orderId): bool;
#[ActivityMethod]
public function sendTrackingInfo(int $orderId, string $trackingNumber, array $simulationConfig = []): bool;
}

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Temporal\OrderFulfillment;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class OrderFulfillmentWorkflow implements OrderFulfillmentWorkflowInterface
{
private string $status = 'pending';
private ?string $trackingNumber = null;
private bool $shippingConfirmed = false;
private int $orderId = 0;
private int $retryCount = 0;
private int $rateLimitHits = 0;
/** @var mixed Activity stub - untyped due to PHP SDK limitation */
private $activityStub;
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
OrderActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(5))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(3)
)
);
}
public function processOrder(int $orderId, array $simulationConfig = []): \Generator
{
$this->orderId = $orderId;
$saga = new Workflow\Saga();
try {
// Step 1: Validate the order
$this->status = 'validating';
yield $this->activityStub->validateOrder($orderId, $simulationConfig);
// Step 2: Check inventory availability
$this->status = 'checking_inventory';
yield $this->activityStub->checkInventory($orderId, $simulationConfig);
// Step 3: Process payment
$this->status = 'processing_payment';
$paymentId = yield $this->activityStub->processPayment($orderId, $simulationConfig);
// Register compensation: refund payment if later steps fail
$saga->addCompensation(fn() => yield $this->activityStub->refundPayment($orderId, $paymentId));
// Step 4: Update inventory (decrement stock)
$this->status = 'updating_inventory';
yield $this->activityStub->updateInventory($orderId, $simulationConfig);
// Register compensation: restore inventory if later steps fail
$saga->addCompensation(fn() => yield $this->activityStub->restoreInventory($orderId));
// Step 5: Notify warehouse
$this->status = 'notifying_warehouse';
yield $this->activityStub->notifyWarehouse($orderId, $simulationConfig);
// Register compensation: cancel warehouse notification if later steps fail
$saga->addCompensation(fn() => yield $this->activityStub->cancelWarehouseNotification($orderId));
// Step 6: Wait for shipping confirmation signal
$this->status = 'awaiting_shipment';
yield Workflow::await(fn() => $this->shippingConfirmed);
// Step 7: Send tracking information
$this->status = 'sending_tracking';
yield $this->activityStub->sendTrackingInfo($orderId, $this->trackingNumber, $simulationConfig);
// All steps completed successfully
$this->status = 'completed';
return [
'success' => true,
'orderId' => $orderId,
'trackingNumber' => $this->trackingNumber,
'status' => $this->status,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
];
} catch (\Throwable $e) {
yield $saga->compensate();
$this->status = 'failed';
throw $e;
}
}
public function confirmShipping(string $trackingNumber): void
{
$this->trackingNumber = $trackingNumber;
$this->shippingConfirmed = true;
}
public function getOrderStatus(): array
{
return [
'status' => $this->status,
'orderId' => $this->orderId,
'trackingNumber' => $this->trackingNumber,
'shippingConfirmed' => $this->shippingConfirmed,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Temporal\OrderFulfillment;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\QueryMethod;
#[WorkflowInterface]
interface OrderFulfillmentWorkflowInterface
{
#[WorkflowMethod]
public function processOrder(int $orderId, array $simulationConfig = []);
#[SignalMethod]
public function confirmShipping(string $trackingNumber): void;
#[QueryMethod]
public function getOrderStatus(): array;
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Temporal\ProductImport;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class BatchImportWorkflow implements BatchImportWorkflowInterface
{
/** @var ProductImportActivityInterface */
private $activityStub;
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
ProductImportActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(5))
->withHeartbeatTimeout(CarbonInterval::seconds(30))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(3)
->withInitialInterval(CarbonInterval::seconds(5))
->withBackoffCoefficient(2.0)
)
);
}
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []): \Generator
{
return yield $this->activityStub->processBatch($filePath, $batchNumber, $batchSize, $simulationConfig);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Temporal\ProductImport;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
#[WorkflowInterface]
interface BatchImportWorkflowInterface
{
#[WorkflowMethod]
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []);
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Temporal\ProductImport;
use App\Models\Product;
use App\Temporal\Shared\FaultSimulator;
use Temporal\Activity;
use Illuminate\Support\Facades\Log;
class ProductImportActivity implements ProductImportActivityInterface
{
public function validateFile(string $filePath, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'validateFile');
if (!file_exists($filePath)) {
throw new \RuntimeException("File not found: {$filePath}");
}
if (!is_readable($filePath)) {
throw new \RuntimeException("File is not readable: {$filePath}");
}
return true;
}
public function parseAndCountRecords(string $filePath, array $simulationConfig = []): int
{
FaultSimulator::maybeApply($simulationConfig, 'parseAndCountRecords');
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new \RuntimeException("Unable to open file: {$filePath}");
}
// Skip header row
fgetcsv($handle);
$count = 0;
while (fgetcsv($handle) !== false) {
$count++;
}
fclose($handle);
return $count;
}
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'processBatch');
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw new \RuntimeException("Unable to open file: {$filePath}");
}
// Skip header row
fgetcsv($handle);
// Skip to the batch offset
$offset = $batchNumber * $batchSize;
for ($i = 0; $i < $offset; $i++) {
fgetcsv($handle);
}
$processed = 0;
$failed = 0;
$errors = [];
for ($i = 0; $i < $batchSize; $i++) {
$row = fgetcsv($handle);
if ($row === false) {
break;
}
// Heartbeat with progress
Activity::heartbeat([
'batchNumber' => $batchNumber,
'current' => $i + 1,
'total' => $batchSize,
]);
try {
// Parse CSV columns: sku, name, description, price, stock_quantity, category
[$sku, $name, $description, $price, $stockQuantity, $category] = $row;
Product::updateOrCreate(
['sku' => $sku],
[
'name' => $name,
'description' => $description,
'price' => (float) $price,
'stock_quantity' => (int) $stockQuantity,
'category' => $category,
]
);
$processed++;
} catch (\Throwable $e) {
$failed++;
$errors[] = [
'row' => $offset + $i + 1,
'error' => $e->getMessage(),
];
Log::warning("Product import batch {$batchNumber} row error", [
'row' => $offset + $i + 1,
'error' => $e->getMessage(),
]);
}
}
fclose($handle);
return [
'processed' => $processed,
'failed' => $failed,
'errors' => $errors,
'attempt' => Activity::getInfo()->attempt,
];
}
public function generateReport(
string $filePath,
int $totalRecords,
int $processedRecords,
int $failedRecords,
array $simulationConfig = []
): array {
FaultSimulator::maybeApply($simulationConfig, 'generateReport');
return [
'filePath' => $filePath,
'totalRecords' => $totalRecords,
'processedRecords' => $processedRecords,
'failedRecords' => $failedRecords,
'successRate' => $totalRecords > 0
? round(($processedRecords / $totalRecords) * 100, 2)
: 0,
'completedAt' => now()->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Temporal\ProductImport;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface ProductImportActivityInterface
{
#[ActivityMethod]
public function validateFile(string $filePath, array $simulationConfig = []): bool;
#[ActivityMethod]
public function parseAndCountRecords(string $filePath, array $simulationConfig = []): int;
#[ActivityMethod]
public function processBatch(string $filePath, int $batchNumber, int $batchSize, array $simulationConfig = []): array;
#[ActivityMethod]
public function generateReport(string $filePath, int $totalRecords, int $processedRecords, int $failedRecords, array $simulationConfig = []): array;
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Temporal\ProductImport;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Workflow;
use Temporal\Workflow\ChildWorkflowOptions;
class ProductImportWorkflow implements ProductImportWorkflowInterface
{
private string $status = 'pending';
private int $processedRecords = 0;
private int $failedRecords = 0;
private int $totalRecords = 0;
private bool $isPaused = false;
private bool $isCancelled = false;
private int $retryCount = 0;
private int $rateLimitHits = 0;
/** @var ProductImportActivityInterface */
private $activityStub;
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
ProductImportActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(5))
->withHeartbeatTimeout(CarbonInterval::seconds(30))
);
}
public function import(string $filePath, array $simulationConfig = []): \Generator
{
try {
// Step 1: Validate the file
$this->status = 'validating';
yield $this->activityStub->validateFile($filePath, $simulationConfig);
// Step 2: Count records
$this->status = 'counting';
$this->totalRecords = yield $this->activityStub->parseAndCountRecords($filePath, $simulationConfig);
// Step 3: Process in batches
$this->status = 'processing';
$batchSize = 50;
$batchCount = (int) ceil($this->totalRecords / $batchSize);
$childPromises = [];
for ($i = 0; $i < $batchCount; $i++) {
if ($this->isCancelled) {
$this->status = 'cancelled';
return [
'status' => 'cancelled',
'totalRecords' => $this->totalRecords,
'processedRecords' => $this->processedRecords,
'failedRecords' => $this->failedRecords,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
];
}
if ($this->isPaused) {
$this->status = 'paused';
yield Workflow::await(fn () => !$this->isPaused || $this->isCancelled);
if ($this->isCancelled) {
$this->status = 'cancelled';
return [
'status' => 'cancelled',
'totalRecords' => $this->totalRecords,
'processedRecords' => $this->processedRecords,
'failedRecords' => $this->failedRecords,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
];
}
$this->status = 'processing';
}
// Spawn child workflow for this batch
$childStub = Workflow::newChildWorkflowStub(
BatchImportWorkflowInterface::class,
ChildWorkflowOptions::new()
);
$childPromises[] = $childStub->processBatch($filePath, $i, $batchSize, $simulationConfig);
}
// Collect results from all child workflows
foreach ($childPromises as $promise) {
$result = (array) (yield $promise);
$this->processedRecords += $result['processed'] ?? 0;
$this->failedRecords += $result['failed'] ?? 0;
$attempt = $result['attempt'] ?? 1;
if ($attempt > 1) {
$this->retryCount += ($attempt - 1);
}
}
// Step 4: Generate report
$this->status = 'reporting';
$report = yield $this->activityStub->generateReport(
$filePath,
$this->totalRecords,
$this->processedRecords,
$this->failedRecords,
$simulationConfig
);
} catch (\Throwable $e) {
$this->status = 'failed';
throw $e;
}
// Step 5: Complete
$this->status = 'completed';
return [
'status' => 'completed',
'totalRecords' => $this->totalRecords,
'processedRecords' => $this->processedRecords,
'failedRecords' => $this->failedRecords,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
'report' => $report,
];
}
public function pause(): void
{
$this->isPaused = true;
}
public function resume(): void
{
$this->isPaused = false;
}
public function cancel(): void
{
$this->isCancelled = true;
$this->isPaused = false; // Meh, unblock any paused await
}
public function getStatus(): array
{
return [
'status' => $this->status,
'totalRecords' => $this->totalRecords,
'processedRecords' => $this->processedRecords,
'failedRecords' => $this->failedRecords,
'isPaused' => $this->isPaused,
'isCancelled' => $this->isCancelled,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Temporal\ProductImport;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\QueryMethod;
#[WorkflowInterface]
interface ProductImportWorkflowInterface
{
#[WorkflowMethod]
public function import(string $filePath, array $simulationConfig = []);
#[SignalMethod]
public function pause(): void;
#[SignalMethod]
public function resume(): void;
#[SignalMethod]
public function cancel(): void;
#[QueryMethod]
public function getStatus(): array;
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Temporal\Shared;
use Temporal\Exception\Failure\ApplicationFailure;
class FaultSimulator
{
public static function maybeApply(array $config, string $activityName = ''): void
{
if (empty($config)) {
return;
}
// 1. Base latency
$latencyMs = $config['latencyMs'] ?? 0;
if ($latencyMs > 0) {
usleep($latencyMs * 1000);
}
// 2. Rate limiting — throw ApplicationFailure with nextRetryDelay
$rl = $config['rateLimiting'] ?? [];
if (!empty($rl['enabled']) && ($rl['hitChance'] ?? 0) > 0) {
if (rand(1, 100) <= $rl['hitChance']) {
$retryAfterMs = $rl['retryAfterMs'] ?? 2000;
$retrySeconds = (int) ceil($retryAfterMs / 1000);
throw new ApplicationFailure(
"Rate limited (429) on {$activityName}",
'RateLimited',
false, // nonRetryable = false → Temporal WILL retry
null,
null,
\DateInterval::createFromDateString("{$retrySeconds} seconds"),
);
}
}
// 3. Random failure — plain RuntimeException (Temporal retries automatically)
$failureRate = $config['failureRate'] ?? 0;
if ($failureRate > 0 && rand(1, 100) <= $failureRate) {
throw new \RuntimeException("Simulated failure on {$activityName}");
}
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Temporal\SystemMonitor;
use App\Models\ImportJob;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use App\Temporal\Shared\FaultSimulator;
use Illuminate\Support\Facades\DB;
use Temporal\Activity;
class SystemMonitorActivity implements SystemMonitorActivityInterface
{
public function runHealthCheck(array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'runHealthCheck');
$checks = [];
$issues = 0;
// 1. DB connectivity
try {
DB::select('SELECT 1');
$checks['db_connected'] = true;
} catch (\Throwable) {
$checks['db_connected'] = false;
$issues += 3; // critical
}
Activity::heartbeat(['step' => 'db_connectivity']);
// 2. Table row counts
$checks['product_count'] = Product::count();
$checks['order_count'] = Order::count();
$checks['order_item_count'] = OrderItem::count();
$checks['user_count'] = User::count();
$checks['import_job_count'] = ImportJob::count();
Activity::heartbeat(['step' => 'row_counts']);
// 3. Stale data check — pending orders older than 24h
$checks['pending_stale_orders'] = Order::where('status', 'pending')
->where('created_at', '<', now()->subHours(24))
->count();
if ($checks['pending_stale_orders'] > 0) {
$issues++;
}
Activity::heartbeat(['step' => 'stale_data']);
// 4. Stock alerts — active products with zero stock
$checks['out_of_stock_products'] = Product::where('stock_quantity', 0)
->where('status', 'active')
->count();
if ($checks['out_of_stock_products'] > 5) {
$issues++;
}
Activity::heartbeat(['step' => 'stock_alerts']);
// 5. Stuck import jobs — started but not updated in 30 min
$checks['stuck_import_jobs'] = ImportJob::where('status', 'started')
->where('updated_at', '<', now()->subMinutes(30))
->count();
if ($checks['stuck_import_jobs'] > 0) {
$issues++;
}
Activity::heartbeat(['step' => 'stuck_jobs']);
// Compute health score (100 = perfect, deduct per issue)
$healthScore = max(0, 100 - ($issues * 10));
return [
'timestamp' => now()->toISOString(),
'checks' => $checks,
'healthScore' => $healthScore,
'issues' => $issues,
'attempt' => Activity::getInfo()->attempt,
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Temporal\SystemMonitor;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface SystemMonitorActivityInterface
{
#[ActivityMethod]
public function runHealthCheck(array $simulationConfig = []): array;
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Temporal\SystemMonitor;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class SystemMonitorWorkflow implements SystemMonitorWorkflowInterface
{
private bool $stopped = false;
private int $iteration = 0;
private int $totalIterations = 0;
private array $checkHistory = [];
private string $startedAt = '';
private string $lastCheckAt = '';
private float $healthScore = 100;
private string $status = 'initializing';
/** @var SystemMonitorActivityInterface */
private $activityStub;
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
SystemMonitorActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::seconds(60))
->withHeartbeatTimeout(CarbonInterval::seconds(15))
->withRetryOptions(
RetryOptions::new()->withMaximumAttempts(3)
)
);
}
public function monitor(int $intervalSeconds = 60, int $maxIterations = 30, array $state = [], array $simulationConfig = []): \Generator
{
// Restore state from previous Continue-As-New run, or initialize
if (!empty($state)) {
$this->iteration = $state['iteration'] ?? 0;
$this->totalIterations = $state['totalIterations'] ?? $maxIterations;
$this->checkHistory = $state['checkHistory'] ?? [];
$this->startedAt = $state['startedAt'] ?? Workflow::now()->format('c');
$this->healthScore = $state['healthScore'] ?? 100;
} else {
$this->totalIterations = $maxIterations;
$this->startedAt = Workflow::now()->format('c');
}
$this->status = 'monitoring';
try {
while ($this->iteration < $this->totalIterations && !$this->stopped) {
// Run health check activity
$result = yield $this->activityStub->runHealthCheck($simulationConfig);
// Store in check history (keep last 10)
$this->lastCheckAt = $result['timestamp'] ?? Workflow::now()->format('c');
$this->checkHistory[] = $result;
if (count($this->checkHistory) > 10) {
$this->checkHistory = array_slice($this->checkHistory, -10);
}
// Update health score (rolling average of last checks)
$scores = array_column($this->checkHistory, 'healthScore');
$this->healthScore = count($scores) > 0 ? round(array_sum($scores) / count($scores), 1) : 100;
$this->iteration++;
// Check if we should Continue-As-New
if (Workflow::getInfo()->shouldContinueAsNew || ($this->iteration % 15 === 0 && $this->iteration < $this->totalIterations)) {
$continueStub = Workflow::newContinueAsNewStub(SystemMonitorWorkflowInterface::class);
return yield $continueStub->monitor(
$intervalSeconds,
$this->totalIterations,
[
'iteration' => $this->iteration,
'totalIterations' => $this->totalIterations,
'checkHistory' => $this->checkHistory,
'startedAt' => $this->startedAt,
'healthScore' => $this->healthScore,
],
$simulationConfig
);
}
// Don't sleep after the last iteration or if stopped
if ($this->iteration < $this->totalIterations && !$this->stopped) {
yield Workflow::timer($intervalSeconds);
}
}
} catch (\Throwable $e) {
$this->status = 'failed';
throw $e;
}
$this->status = $this->stopped ? 'stopped' : 'completed';
return [
'status' => $this->status,
'totalChecks' => $this->iteration,
'avgHealthScore' => $this->healthScore,
'lastCheckAt' => $this->lastCheckAt,
'startedAt' => $this->startedAt,
'checkHistory' => $this->checkHistory,
];
}
public function stop(): void
{
$this->stopped = true;
}
public function getStatus(): array
{
return [
'status' => $this->status,
'stopped' => $this->stopped,
'iteration' => $this->iteration,
'totalIterations' => $this->totalIterations,
'healthScore' => $this->healthScore,
'startedAt' => $this->startedAt,
'lastCheckAt' => $this->lastCheckAt,
'checkHistory' => $this->checkHistory,
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Temporal\SystemMonitor;
use Temporal\Workflow\QueryMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
#[WorkflowInterface]
interface SystemMonitorWorkflowInterface
{
#[WorkflowMethod]
public function monitor(int $intervalSeconds = 60, int $maxIterations = 30, array $state = [], array $simulationConfig = []);
#[SignalMethod]
public function stop(): void;
#[QueryMethod]
public function getStatus(): array;
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Temporal\UserMigration;
use App\Models\User;
use App\Temporal\Shared\FaultSimulator;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Temporal\Activity;
class UserMigrationActivity implements UserMigrationActivityInterface
{
public function fetchExternalUsers(int $count, array $simulationConfig = []): array
{
$users = [];
$pageSize = 25;
$totalPages = (int) ceil($count / $pageSize);
for ($page = 0; $page < $totalPages; $page++) {
Activity::heartbeat(['page' => $page + 1, 'totalPages' => $totalPages]);
FaultSimulator::maybeApply($simulationConfig, 'fetchExternalUsers');
$start = $page * $pageSize + 1;
$end = min(($page + 1) * $pageSize, $count);
for ($i = $start; $i <= $end; $i++) {
$users[] = [
'name' => 'User ' . $i,
'email' => 'user_' . $i . '_' . Str::random(4) . '@example.com',
'legacy_id' => 'LEG-' . $i,
];
}
}
return $users;
}
public function validateUsers(array $users, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'validateUsers');
$validUsers = array_filter($users, function (array $user) {
if (empty($user['name'])) {
return false;
}
if (!filter_var($user['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
return false;
}
return true;
});
$validUsers = array_values($validUsers);
Log::info(sprintf(
'Validated users: %d valid out of %d total',
count($validUsers),
count($users)
));
return $validUsers;
}
public function createAccounts(array $users, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'createAccounts');
$created = 0;
$failed = 0;
$createdIds = [];
$errors = [];
foreach ($users as $index => $userData) {
Activity::heartbeat(sprintf('Creating account %d of %d', $index + 1, count($users)));
try {
$user = User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'password' => Hash::make(Str::random(16)),
]);
$createdIds[] = $user->id;
$created++;
} catch (\Throwable $e) {
$failed++;
$errors[] = [
'email' => $userData['email'],
'error' => $e->getMessage(),
];
Log::warning(sprintf(
'Failed to create account for %s: %s',
$userData['email'],
$e->getMessage()
));
}
}
return [
'created' => $created,
'failed' => $failed,
'created_ids' => $createdIds,
'errors' => $errors,
'attempt' => Activity::getInfo()->attempt,
];
}
public function sendWelcomeEmails(array $userIds, array $simulationConfig = []): bool
{
FaultSimulator::maybeApply($simulationConfig, 'sendWelcomeEmails');
foreach ($userIds as $id) {
Log::info(sprintf('Sending welcome email to user ID: %s', $id));
usleep(50000);
}
return true;
}
public function generateMigrationReport(int $totalUsers, int $processedUsers, int $failedUsers, array $simulationConfig = []): array
{
FaultSimulator::maybeApply($simulationConfig, 'generateMigrationReport');
$successRate = $totalUsers > 0
? round(($processedUsers / $totalUsers) * 100, 2)
: 0.0;
return [
'total_users' => $totalUsers,
'processed_users' => $processedUsers,
'failed_users' => $failedUsers,
'success_rate' => $successRate,
'timestamp' => now()->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Temporal\UserMigration;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface UserMigrationActivityInterface
{
#[ActivityMethod]
public function fetchExternalUsers(int $count, array $simulationConfig = []): array;
#[ActivityMethod]
public function validateUsers(array $users, array $simulationConfig = []): array;
#[ActivityMethod]
public function createAccounts(array $users, array $simulationConfig = []): array;
#[ActivityMethod]
public function sendWelcomeEmails(array $userIds, array $simulationConfig = []): bool;
#[ActivityMethod]
public function generateMigrationReport(int $totalUsers, int $processedUsers, int $failedUsers, array $simulationConfig = []): array;
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Temporal\UserMigration;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class UserMigrationWorkflow implements UserMigrationWorkflowInterface
{
private string $status = 'pending';
private int $totalUsers = 0;
private int $batchSize = 0;
private int $processedUsers = 0;
private int $failedUsers = 0;
private bool $isPaused = false;
private int $currentBatch = 0;
private int $totalBatches = 0;
private int $retryCount = 0;
private int $rateLimitHits = 0;
/** @var UserMigrationActivityInterface */
private $activityStub;
public function __construct()
{
$this->activityStub = Workflow::newActivityStub(
UserMigrationActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::minutes(10))
->withHeartbeatTimeout(CarbonInterval::seconds(60))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(3)
)
);
}
public function migrate(int $totalUsers, int $batchSize, array $simulationConfig = []): \Generator
{
$this->totalUsers = $totalUsers;
$this->batchSize = $batchSize;
$this->totalBatches = (int) ceil($totalUsers / $batchSize);
try {
$this->status = 'fetching_users';
$users = yield $this->activityStub->fetchExternalUsers($totalUsers, $simulationConfig);
$this->status = 'processing';
for ($i = 0; $i < $this->totalBatches; $i++) {
if ($this->isPaused) {
$this->status = 'paused';
yield Workflow::await(fn () => !$this->isPaused);
$this->status = 'processing';
}
$this->currentBatch = $i + 1;
$batchUsers = array_slice($users, $i * $this->batchSize, $this->batchSize);
$validUsers = yield $this->activityStub->validateUsers($batchUsers, $simulationConfig);
$result = yield $this->activityStub->createAccounts($validUsers, $simulationConfig);
$this->processedUsers += $result['created'];
$this->failedUsers += $result['failed'];
$attempt = $result['attempt'] ?? 1;
if ($attempt > 1) {
$this->retryCount += ($attempt - 1);
}
yield $this->activityStub->sendWelcomeEmails($result['created_ids'], $simulationConfig);
}
$this->status = 'generating_report';
$report = yield $this->activityStub->generateMigrationReport(
$this->totalUsers,
$this->processedUsers,
$this->failedUsers,
$simulationConfig
);
} catch (\Throwable $e) {
$this->status = 'failed';
throw $e;
}
$this->status = 'completed';
return $report;
}
public function pause(): void
{
$this->isPaused = true;
}
public function resume(): void
{
$this->isPaused = false;
}
public function getProgress(): array
{
return [
'status' => $this->status,
'totalUsers' => $this->totalUsers,
'processedUsers' => $this->processedUsers,
'failedUsers' => $this->failedUsers,
'isPaused' => $this->isPaused,
'currentBatch' => $this->currentBatch,
'totalBatches' => $this->totalBatches,
'percentComplete' => $this->totalUsers > 0
? round(($this->processedUsers / $this->totalUsers) * 100, 2)
: 0.0,
'retryCount' => $this->retryCount,
'rateLimitHits' => $this->rateLimitHits,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Temporal\UserMigration;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Temporal\Workflow\SignalMethod;
use Temporal\Workflow\QueryMethod;
#[WorkflowInterface]
interface UserMigrationWorkflowInterface
{
#[WorkflowMethod]
public function migrate(int $totalUsers, int $batchSize, array $simulationConfig = []);
#[SignalMethod]
public function pause(): void;
#[SignalMethod]
public function resume(): void;
#[QueryMethod]
public function getProgress(): array;
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Temporal\WebhookDelivery;
use App\Models\WebhookDelivery;
use App\Temporal\Shared\FaultSimulator;
use Temporal\Activity;
class WebhookDeliveryActivity implements WebhookDeliveryActivityInterface
{
public function deliverToEndpoint(string $endpoint, array $payload, array $simulationConfig = []): array
{
$startTime = hrtime(true);
FaultSimulator::maybeApply($simulationConfig, "deliverToEndpoint:{$endpoint}");
// Simulate HTTP POST latency
usleep(mt_rand(100000, 400000));
$responseTime = (int) ((hrtime(true) - $startTime) / 1_000_000);
return [
'status' => 'delivered',
'statusCode' => 200,
'attempt' => Activity::getInfo()->attempt,
'responseTime' => $responseTime,
];
}
public function deadLetter(string $endpoint, array $payload, string $reason): bool
{
WebhookDelivery::create([
'workflow_id' => Activity::getInfo()->workflowExecution->getID(),
'endpoint' => $endpoint,
'payload' => $payload,
'status' => 'dead_lettered',
'attempts' => 0,
'last_error' => $reason,
'dead_lettered_at' => now(),
]);
return true;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Temporal\WebhookDelivery;
use Temporal\Activity\ActivityInterface;
use Temporal\Activity\ActivityMethod;
#[ActivityInterface]
interface WebhookDeliveryActivityInterface
{
#[ActivityMethod]
public function deliverToEndpoint(string $endpoint, array $payload, array $simulationConfig = []): array;
#[ActivityMethod]
public function deadLetter(string $endpoint, array $payload, string $reason): bool;
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Temporal\WebhookDelivery;
use Carbon\CarbonInterval;
use Temporal\Activity\ActivityOptions;
use Temporal\Common\RetryOptions;
use Temporal\Workflow;
class WebhookDeliveryWorkflow implements WebhookDeliveryWorkflowInterface
{
private array $endpointStatuses = [];
private int $totalDelivered = 0;
private int $totalFailed = 0;
private int $totalDeadLettered = 0;
private int $retryCount = 0;
/** @var WebhookDeliveryActivityInterface */
private $deliveryStub;
/** @var WebhookDeliveryActivityInterface */
private $deadLetterStub;
public function __construct()
{
$this->deliveryStub = Workflow::newActivityStub(
WebhookDeliveryActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::seconds(30))
->withRetryOptions(
RetryOptions::new()
->withMaximumAttempts(3)
->withInitialInterval(CarbonInterval::seconds(1))
->withBackoffCoefficient(3.0)
)
);
$this->deadLetterStub = Workflow::newActivityStub(
WebhookDeliveryActivityInterface::class,
ActivityOptions::new()
->withStartToCloseTimeout(CarbonInterval::seconds(10))
->withRetryOptions(
RetryOptions::new()->withMaximumAttempts(1)
)
);
}
public function deliver(array $payload, array $endpoints, array $simulationConfig = []): \Generator
{
// Initialize endpoint statuses
foreach ($endpoints as $endpoint) {
$this->endpointStatuses[$endpoint] = [
'status' => 'pending',
'attempt' => 0,
'responseTime' => 0,
];
}
// Fan-out: deliver to all endpoints in parallel
$promises = [];
foreach ($endpoints as $endpoint) {
$this->endpointStatuses[$endpoint]['status'] = 'delivering';
$promises[$endpoint] = Workflow::async(function () use ($endpoint, $payload, $simulationConfig) {
try {
$result = yield $this->deliveryStub->deliverToEndpoint($endpoint, $payload, $simulationConfig);
return ['endpoint' => $endpoint, 'success' => true, 'result' => (array) $result];
} catch (\Throwable $e) {
return ['endpoint' => $endpoint, 'success' => false, 'error' => $e->getMessage()];
}
});
}
// Collect results from all parallel activities
foreach ($promises as $endpoint => $promise) {
$outcome = (array) (yield $promise);
if ($outcome['success']) {
$result = $outcome['result'];
$this->endpointStatuses[$endpoint] = [
'status' => 'delivered',
'attempt' => $result['attempt'] ?? 1,
'responseTime' => $result['responseTime'] ?? 0,
];
$this->totalDelivered++;
$attempt = $result['attempt'] ?? 1;
if ($attempt > 1) {
$this->retryCount += ($attempt - 1);
}
} else {
$this->endpointStatuses[$endpoint]['status'] = 'failed';
$this->totalFailed++;
// Dead-letter failed deliveries
yield $this->deadLetterStub->deadLetter($endpoint, $payload, $outcome['error'] ?? 'Unknown error');
$this->endpointStatuses[$endpoint]['status'] = 'dead_lettered';
$this->totalDeadLettered++;
}
}
return [
'status' => 'completed',
'totalDelivered' => $this->totalDelivered,
'totalFailed' => $this->totalFailed,
'totalDeadLettered' => $this->totalDeadLettered,
'retryCount' => $this->retryCount,
'endpoints' => $this->endpointStatuses,
];
}
public function getDeliveryStatus(): array
{
return [
'totalDelivered' => $this->totalDelivered,
'totalFailed' => $this->totalFailed,
'totalDeadLettered' => $this->totalDeadLettered,
'retryCount' => $this->retryCount,
'endpoints' => $this->endpointStatuses,
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Temporal\WebhookDelivery;
use Temporal\Workflow\WorkflowInterface;
use Temporal\Workflow\WorkflowMethod;
use Temporal\Workflow\QueryMethod;
#[WorkflowInterface]
interface WebhookDeliveryWorkflowInterface
{
#[WorkflowMethod]
public function deliver(array $payload, array $endpoints, array $simulationConfig = []);
#[QueryMethod]
public function getDeliveryStatus(): array;
}