#!/usr/bin/env php [ 'method' => 'POST', 'url' => $baseUrl . '/order/create-order', 'body' => $orderBody, 'stockDelta' => [27286 => '2', 27282 => '2'], ], 'stock-out' => [ 'method' => 'POST', 'url' => $baseUrl . '/stock-out/create-order', 'body' => $stockOutBody, 'stockDelta' => [27286 => '1', 27282 => '1'], ], 'wastage' => [ 'method' => 'POST', 'url' => $baseUrl . '/wastage/create-order', 'body' => $wastageBody, 'stockDelta' => [27286 => '1', 27282 => '1'], ], 'hd-purchase' => [ 'method' => 'POST', 'url' => $hdBaseUrl . '/purchase/create-order', 'headers' => $hdHeaders, 'body' => $hdPurchaseBody, 'stockDelta' => [27286 => '1'], ], ]; $requestCounts = ['order' => $orderCount, 'stock-out' => $stockOutCount, 'wastage' => $wastageCount, 'hd-purchase' => $hdPurchaseCount]; addParameterizedRequests($requestDefs, $requestCounts, $options, $baseUrl); $productIds = collectProductIds($requestDefs, $defaultProductIds); echo "app-ghs stock concurrency test\n"; echo "Targets: {$baseUrl}, {$hdBaseUrl}\n"; echo "Rounds: {$rounds}, order/create-order: {$orderCount}, stock-out/create-order: {$stockOutCount}, wastage/create-order: {$wastageCount}, app-hd purchase/create-order: {$hdPurchaseCount}\n"; echo "Products: " . implode(', ', $productIds) . "\n"; echo "WARNING: real requests will create real orders and decrease real stock.\n\n"; $overallPass = true; $overallInvalid = false; $overallConcurrencyError = false; $appLogFiles = [ 'app-ghs' => __DIR__ . '/../app-ghs/runtime/logs/app.log', 'app-hd' => __DIR__ . '/../app-hd/runtime/logs/app.log', ]; for ($round = 1; $round <= $rounds; $round++) { echo "========== Round {$round}/{$rounds} ==========\n"; $before = loadStocks($productIds); printStocks('Before', $before, $productIds); $currentRequestDefs = $requestDefs; $priceSummary = ''; $currentRequestDefs['order']['body'] = refreshOrderBodyPrices($orderBody, $priceSummary); echo "Order request pricing: {$priceSummary}\n"; $requests = buildRequests($currentRequestDefs, $requestCounts); $logOffsets = getLogOffsets($appLogFiles); $responses = runConcurrentRequests($requests, $commonHeaders); $concurrencyErrors = findConcurrencyErrorsFromLogs($appLogFiles, $logOffsets); foreach ($responses as $response) { $ok = $response['businessSuccess'] ? 'OK' : 'ERR'; echo sprintf( "[%s] %-18s HTTP %s %.3fs %s\n", $ok, $response['label'], $response['httpCode'], $response['totalTime'], $response['summary'] ); } if (!empty($concurrencyErrors)) { echo "Concurrency errors from app log:\n"; foreach ($concurrencyErrors as $errorLine) { echo " - " . $errorLine . "\n"; } } $successCounts = countSuccessfulRequests($responses); $after = loadStocks($productIds); $expected = calcExpectedStocks($before, $currentRequestDefs, $successCounts, $productIds); printStocks('Expected after successful requests', $expected, $productIds); printStocks('Actual', $after, $productIds); printDiff($expected, $after, $productIds); $allBusinessSuccess = count($responses) === array_sum($successCounts); $stockMatches = stocksMatch($expected, $after, $productIds); if (!$allBusinessSuccess && !empty($concurrencyErrors)) { $overallConcurrencyError = true; echo "Round result: CONCURRENCY_ERROR (business request failed because concurrent stock update hit a database lock/deadlock)\n\n"; continue; } if (!$allBusinessSuccess) { $overallInvalid = true; echo "Round result: INVALID (one or more business requests failed; this round is not a valid concurrency conclusion)\n\n"; continue; } if ($stockMatches) { echo "Round result: PASS\n\n"; } else { $overallPass = false; echo "Round result: FAIL (stock mismatch after all business requests succeeded)\n\n"; } } if ($overallConcurrencyError) { echo "FINAL: CONCURRENCY_ERROR\n"; exit(4); } if ($overallInvalid && $overallPass) { echo "FINAL: INVALID (at least one round had failed business responses)\n"; exit(3); } if (!$overallInvalid && $overallPass) { echo "FINAL: PASS\n"; exit(0); } if (!$overallPass) { echo "FINAL: FAIL\n"; exit(2); } exit(3); function normalizePositiveInt($options, $key, $default) { if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') { return $default; } $value = (int)$options[$key]; if ($value < 1) { fwrite(STDERR, "ERROR: --{$key} must be a positive integer.\n"); exit(1); } return $value; } function normalizeNonNegativeInt($options, $key, $default) { if (!isset($options[$key]) || $options[$key] === false || $options[$key] === '') { return $default; } $value = (int)$options[$key]; if ($value < 0) { fwrite(STDERR, "ERROR: --{$key} must be a non-negative integer.\n"); exit(1); } return $value; } function jsonBodyToJsonRequestBody($jsonBody, $jsonStringFields) { $params = json_decode($jsonBody, true); if (!is_array($params)) { throw new RuntimeException('Invalid JSON request body: ' . json_last_error_msg()); } return encodeJsonRequestBody($params, $jsonStringFields); } function encodeJsonRequestBody($params, $jsonStringFields) { foreach ($jsonStringFields as $field) { if (isset($params[$field]) && is_array($params[$field])) { $params[$field] = json_encode($params[$field], JSON_UNESCAPED_UNICODE); } } return json_encode($params, JSON_UNESCAPED_UNICODE); } function loadStocks($productIds) { $rows = Yii::$app->db->createCommand( 'select id, stock from xhGhsItem where id in (' . implode(',', array_map('intval', $productIds)) . ')' )->queryAll(); $stocks = []; foreach ($rows as $row) { $stocks[(int)$row['id']] = (string)$row['stock']; } foreach ($productIds as $productId) { if (!array_key_exists($productId, $stocks)) { throw new RuntimeException("Product {$productId} was not found in xhGhsItem."); } } return $stocks; } function buildRequests($requestDefs, $requestCounts) { $requests = []; foreach ($requestCounts as $type => $count) { if (empty($requestDefs[$type]) || $count <= 0) { continue; } for ($i = 1; $i <= $count; $i++) { $requests[] = [ 'type' => $type, 'label' => $type . '#' . $i, 'method' => isset($requestDefs[$type]['method']) ? $requestDefs[$type]['method'] : 'POST', 'url' => $requestDefs[$type]['url'], 'headers' => isset($requestDefs[$type]['headers']) ? $requestDefs[$type]['headers'] : null, 'body' => isset($requestDefs[$type]['body']) ? $requestDefs[$type]['body'] : '', ]; } } return $requests; } function addParameterizedRequests(&$requestDefs, &$requestCounts, $options, $baseUrl) { if (!empty($options['stock-in-confirm'])) { $orderSn = (string)$options['stock-in-confirm']; $requestDefs['stock-in-confirm'] = [ 'method' => 'GET', 'url' => $baseUrl . '/stock-in/confirm-order?orderSn=' . rawurlencode($orderSn), 'body' => '', 'stockDelta' => loadItemNumDelta('xhGhsStockInOrderItem', $orderSn, -1), ]; $requestCounts['stock-in-confirm'] = 1; } if (!empty($options['stock-out-cancel'])) { $orderSn = (string)$options['stock-out-cancel']; $requestDefs['stock-out-cancel'] = [ 'method' => 'GET', 'url' => $baseUrl . '/stock-out/cancel-order?orderSn=' . rawurlencode($orderSn), 'body' => '', 'stockDelta' => loadItemNumDelta('xhGhsStockOutOrderItem', $orderSn, -1), ]; $requestCounts['stock-out-cancel'] = 1; } if (!empty($options['purchase-put-in'])) { $purchaseId = intval($options['purchase-put-in']); $orderSn = Yii::$app->db->createCommand('select orderSn from xhGhsCgOrder where id=:id', [':id' => $purchaseId])->queryScalar(); if (!empty($orderSn)) { $requestDefs['purchase-put-in'] = [ 'method' => 'GET', 'url' => $baseUrl . '/purchase-order/confirm-put-in?id=' . $purchaseId, 'body' => '', 'stockDelta' => loadItemNumDelta('xhGhsCgOrderItem', $orderSn, -1), ]; $requestCounts['purchase-put-in'] = 1; } } } function loadItemNumDelta($tableName, $orderSn, $direction) { $rows = Yii::$app->db->createCommand( "select productId, itemNum from {$tableName} where orderSn=:orderSn", [':orderSn' => $orderSn] )->queryAll(); $delta = []; foreach ($rows as $row) { $productId = intval($row['productId']); $itemNum = (string)$row['itemNum']; if ($direction < 0) { $itemNum = bcmul($itemNum, '-1', 2); } $delta[$productId] = isset($delta[$productId]) ? bcadd($delta[$productId], $itemNum, 2) : $itemNum; } return $delta; } function collectProductIds($requestDefs, $defaultProductIds) { $ids = $defaultProductIds; foreach ($requestDefs as $requestDef) { foreach (array_keys($requestDef['stockDelta']) as $productId) { $ids[] = intval($productId); } } $ids = array_values(array_unique(array_filter($ids))); sort($ids); return $ids; } function refreshOrderBodyPrices($orderBody, &$summary) { $isJsonBody = false; $params = json_decode($orderBody, true); if (is_array($params)) { $isJsonBody = true; } else { parse_str($orderBody, $params); } $customId = isset($params['customId']) ? intval($params['customId']) : 0; $products = isset($params['product']) && is_array($params['product']) ? $params['product'] : (isset($params['product']) ? json_decode($params['product'], true) : []); if (empty($customId) || empty($products) || !is_array($products)) { $summary = 'keep original prices'; return $orderBody; } $custom = \bizGhs\custom\classes\CustomClass::getById($customId); if (empty($custom)) { $summary = 'keep original prices; custom not found'; return $orderBody; } $productIds = array_unique(array_filter(array_column($products, 'productId'))); $productInfo = \bizGhs\product\classes\ProductClass::getByIds($productIds, null, 'id'); if (empty($productInfo)) { $summary = 'keep original prices; products not found'; return $orderBody; } $priceMap = \bizGhs\custom\classes\CustomClass::$levelPriceKeyMap; $addPriceMap = \bizGhs\custom\classes\CustomClass::$levelAddPriceKeyMap; $addPriceMap['risePercent'] = isset($custom['risePercent']) ? $custom['risePercent'] : 0; $level = isset($custom['level']) ? $custom['level'] : 1; $modifyPrice = '0'; $parts = []; foreach ($products as $key => $product) { $productId = isset($product['productId']) ? intval($product['productId']) : 0; if (empty($productInfo[$productId])) { continue; } $systemInfo = $productInfo[$productId]; $price = \bizGhs\product\classes\ProductClass::getFinalPrice($systemInfo, $level, $priceMap, $addPriceMap); $bigNum = isset($product['bigNum']) ? (string)$product['bigNum'] : '0'; $smallNum = isset($product['smallNum']) ? (string)$product['smallNum'] : '0'; $reachDiscountPrice = '0'; $reachNum = isset($systemInfo['reachNum']) ? $systemInfo['reachNum'] : 0; $reachNumDiscount = isset($systemInfo['reachNumDiscount']) ? $systemInfo['reachNumDiscount'] : 0; if ($reachNum > 0 && $bigNum >= $reachNum && $price > $reachNumDiscount) { $price = bcsub($price, $reachNumDiscount, 2); $reachDiscountPrice = bcmul($reachNumDiscount, $bigNum, 2); } $products[$key]['price'] = sprintf('%.2f', (float)$price); $linePrice = bcmul((string)$products[$key]['price'], $bigNum, 2); if ($smallNum > 0) { $ratio = isset($systemInfo['ratio']) && $systemInfo['ratio'] > 0 ? $systemInfo['ratio'] : 1; $smallItemNum = bcdiv($smallNum, $ratio, 2); $linePrice = bcadd($linePrice, bcmul((string)$products[$key]['price'], $smallItemNum, 2), 2); } $modifyPrice = bcadd($modifyPrice, $linePrice, 2); $name = isset($systemInfo['name']) ? $systemInfo['name'] : $productId; $parts[] = $productId . '(' . $name . ')=' . $products[$key]['price'] . 'x' . $bigNum . ($reachDiscountPrice > 0 ? ',reachDiscount=' . $reachDiscountPrice : ''); } $params['product'] = json_encode($products, JSON_UNESCAPED_UNICODE); $params['modifyPrice'] = $modifyPrice; $summary = implode('; ', $parts) . '; modifyPrice=' . $modifyPrice; if ($isJsonBody) { return encodeJsonRequestBody($params, ['product', 'xj', 'treeData']); } return http_build_query($params); } function runConcurrentRequests($requests, $headers) { $multi = curl_multi_init(); $handles = []; foreach ($requests as $index => $request) { $ch = curl_init($request['url']); $method = isset($request['method']) ? strtoupper($request['method']) : 'POST'; $requestHeaders = !empty($request['headers']) ? $request['headers'] : $headers; $curlOptions = [ CURLOPT_HTTPHEADER => $requestHeaders, CURLOPT_RETURNTRANSFER => true, CURLOPT_ENCODING => '', CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 60, ]; if ($method === 'POST') { $curlOptions[CURLOPT_POST] = true; $curlOptions[CURLOPT_POSTFIELDS] = $request['body']; } curl_setopt_array($ch, $curlOptions); curl_multi_add_handle($multi, $ch); $handles[$index] = [ 'handle' => $ch, 'request' => $request, 'startedAt' => microtime(true), ]; } $running = null; do { $status = curl_multi_exec($multi, $running); if ($running) { $selected = curl_multi_select($multi, 1.0); if ($selected === -1) { usleep(1000); } } } while ($running && $status === CURLM_OK); $responses = []; foreach ($handles as $item) { $ch = $item['handle']; $body = curl_multi_getcontent($ch); $info = curl_getinfo($ch); $error = curl_error($ch); $httpCode = isset($info['http_code']) ? (int)$info['http_code'] : 0; $totalTime = isset($info['total_time']) ? (float)$info['total_time'] : microtime(true) - $item['startedAt']; $parsed = parseBusinessResponse($body, $httpCode, $error, $item['request']['type']); $responses[] = [ 'type' => $item['request']['type'], 'label' => $item['request']['label'], 'httpCode' => $httpCode, 'totalTime' => $totalTime, 'businessSuccess' => $parsed['success'], 'summary' => $parsed['summary'], ]; curl_multi_remove_handle($multi, $ch); curl_close($ch); } curl_multi_close($multi); return $responses; } function parseBusinessResponse($body, $httpCode, $curlError, $requestType) { if ($curlError !== '') { return ['success' => false, 'summary' => 'curl_error=' . $curlError]; } $decoded = json_decode($body, true); if (is_array($decoded)) { $code = isset($decoded['code']) ? $decoded['code'] : null; $msg = isset($decoded['msg']) ? $decoded['msg'] : ''; $data = isset($decoded['data']) && is_array($decoded['data']) ? $decoded['data'] : []; $respondType = isset($data['respondType']) ? (string)$data['respondType'] : ''; $success = ($httpCode >= 200 && $httpCode < 300 && (string)$code === '1'); if ($requestType === 'order') { $hasOrderIdentity = !empty($data['id']) || !empty($data['orderSn']); if ($respondType === 'priceError' || !$hasOrderIdentity) { $success = false; } } $extra = []; if ($respondType !== '') { $extra[] = 'respondType=' . $respondType; } if (!empty($data['id'])) { $extra[] = 'id=' . $data['id']; } if (!empty($data['orderSn'])) { $extra[] = 'orderSn=' . $data['orderSn']; } return [ 'success' => $success, 'summary' => trimText('code=' . json_encode($code, JSON_UNESCAPED_UNICODE) . ' msg=' . (string)$msg . (!empty($extra) ? ' ' . implode(' ', $extra) : ''), 240), ]; } return [ 'success' => false, 'summary' => trimText(preg_replace('/\s+/', ' ', (string)$body), 240), ]; } function countSuccessfulRequests($responses) { $counts = []; foreach ($responses as $response) { if (!isset($counts[$response['type']])) { $counts[$response['type']] = 0; } if ($response['businessSuccess']) { $counts[$response['type']]++; } } return $counts; } function calcExpectedStocks($before, $requestDefs, $successCounts, $productIds) { $expected = $before; foreach ($successCounts as $type => $count) { foreach ($productIds as $productId) { $delta = isset($requestDefs[$type]['stockDelta'][$productId]) ? $requestDefs[$type]['stockDelta'][$productId] : '0'; if ($count > 0 && bccomp($delta, '0', 2) !== 0) { $expected[$productId] = bcsub($expected[$productId], bcmul($delta, (string)$count, 2), 2); } } } return $expected; } function stocksMatch($expected, $actual, $productIds) { foreach ($productIds as $productId) { if (bccomp((string)$expected[$productId], (string)$actual[$productId], 2) !== 0) { return false; } } return true; } function printStocks($title, $stocks, $productIds) { echo $title . ': '; $parts = []; foreach ($productIds as $productId) { $parts[] = $productId . '=' . $stocks[$productId]; } echo implode(', ', $parts) . "\n"; } function printDiff($expected, $actual, $productIds) { echo 'Diff(actual - expected): '; $parts = []; foreach ($productIds as $productId) { $parts[] = $productId . '=' . bcsub((string)$actual[$productId], (string)$expected[$productId], 2); } echo implode(', ', $parts) . "\n"; } function trimText($text, $maxLength) { $text = trim($text); if (function_exists('mb_strlen') && function_exists('mb_substr')) { return mb_strlen($text, 'UTF-8') > $maxLength ? mb_substr($text, 0, $maxLength, 'UTF-8') . '...' : $text; } return strlen($text) > $maxLength ? substr($text, 0, $maxLength) . '...' : $text; } function getFileSize($file) { clearstatcache(true, $file); return is_file($file) ? filesize($file) : 0; } function getLogOffsets($files) { $offsets = []; foreach ($files as $name => $file) { $offsets[$name] = getFileSize($file); } return $offsets; } function findConcurrencyErrorsFromLogs($files, $offsets) { $errors = []; foreach ($files as $name => $file) { $offset = isset($offsets[$name]) ? $offsets[$name] : 0; foreach (findConcurrencyErrors(readFileFromOffset($file, $offset)) as $error) { $errors[] = $name . ': ' . $error; } } return $errors; } function readFileFromOffset($file, $offset) { if (!is_file($file)) { return ''; } $handle = fopen($file, 'rb'); if (!$handle) { return ''; } fseek($handle, $offset); $content = stream_get_contents($handle); fclose($handle); return $content === false ? '' : $content; } function findConcurrencyErrors($logContent) { if ($logContent === '') { return []; } $patterns = [ 'Deadlock found', 'Serialization failure', 'Lock wait timeout exceeded', ]; $lines = preg_split('/\r\n|\r|\n/', $logContent); $matched = []; foreach ($lines as $line) { foreach ($patterns as $pattern) { if (strpos($line, $pattern) !== false) { $matched[] = trimText(trim($line), 240); break; } } } return array_values(array_unique(array_filter($matched))); }