Init
This commit is contained in:
72
app/Temporal/DataEnrichment/DataEnrichmentActivity.php
Normal file
72
app/Temporal/DataEnrichment/DataEnrichmentActivity.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
198
app/Temporal/DataEnrichment/DataEnrichmentWorkflow.php
Normal file
198
app/Temporal/DataEnrichment/DataEnrichmentWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
393
app/Temporal/EloquentQuery/EloquentQueryActivity.php
Normal file
393
app/Temporal/EloquentQuery/EloquentQueryActivity.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
100
app/Temporal/EloquentQuery/EloquentQueryWorkflow.php
Normal file
100
app/Temporal/EloquentQuery/EloquentQueryWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
94
app/Temporal/ExternalApiSync/ExternalApiSyncActivity.php
Normal file
94
app/Temporal/ExternalApiSync/ExternalApiSyncActivity.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
123
app/Temporal/ExternalApiSync/ExternalApiSyncWorkflow.php
Normal file
123
app/Temporal/ExternalApiSync/ExternalApiSyncWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
184
app/Temporal/OrderFulfillment/OrderActivity.php
Normal file
184
app/Temporal/OrderFulfillment/OrderActivity.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
app/Temporal/OrderFulfillment/OrderActivityInterface.php
Normal file
37
app/Temporal/OrderFulfillment/OrderActivityInterface.php
Normal 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;
|
||||
}
|
||||
114
app/Temporal/OrderFulfillment/OrderFulfillmentWorkflow.php
Normal file
114
app/Temporal/OrderFulfillment/OrderFulfillmentWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
35
app/Temporal/ProductImport/BatchImportWorkflow.php
Normal file
35
app/Temporal/ProductImport/BatchImportWorkflow.php
Normal 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);
|
||||
}
|
||||
}
|
||||
13
app/Temporal/ProductImport/BatchImportWorkflowInterface.php
Normal file
13
app/Temporal/ProductImport/BatchImportWorkflowInterface.php
Normal 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 = []);
|
||||
}
|
||||
147
app/Temporal/ProductImport/ProductImportActivity.php
Normal file
147
app/Temporal/ProductImport/ProductImportActivity.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
161
app/Temporal/ProductImport/ProductImportWorkflow.php
Normal file
161
app/Temporal/ProductImport/ProductImportWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
45
app/Temporal/Shared/FaultSimulator.php
Normal file
45
app/Temporal/Shared/FaultSimulator.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app/Temporal/SystemMonitor/SystemMonitorActivity.php
Normal file
84
app/Temporal/SystemMonitor/SystemMonitorActivity.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
129
app/Temporal/SystemMonitor/SystemMonitorWorkflow.php
Normal file
129
app/Temporal/SystemMonitor/SystemMonitorWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
140
app/Temporal/UserMigration/UserMigrationActivity.php
Normal file
140
app/Temporal/UserMigration/UserMigrationActivity.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
122
app/Temporal/UserMigration/UserMigrationWorkflow.php
Normal file
122
app/Temporal/UserMigration/UserMigrationWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
44
app/Temporal/WebhookDelivery/WebhookDeliveryActivity.php
Normal file
44
app/Temporal/WebhookDelivery/WebhookDeliveryActivity.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
122
app/Temporal/WebhookDelivery/WebhookDeliveryWorkflow.php
Normal file
122
app/Temporal/WebhookDelivery/WebhookDeliveryWorkflow.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user