重连发货同步的短暂上游游标断线 #269

Merged
limeng merged 1 commits from fix/limeng-shipping-read-retry into main 2026-09-24 15:36:08 +08:00
2 changed files with 105 additions and 12 deletions

View File

@ -5,7 +5,9 @@ namespace Modules\EcommerceFinance\Services\Jushuitan;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Modules\EcommerceFinance\Services\UpstreamConnection;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use PDOException;
use RuntimeException;
use Throwable;
@ -265,7 +267,7 @@ class ShippingReplicaSynchronizer
$sql = "SELECT TOP {$batch} {$cols} FROM rpt.jst_order_express_details e "
. 'LEFT JOIN rpt.jst_Orders jo ON jo.o_id = e.inner_order_id '
. "WHERE {$where} ORDER BY e.id DESC";
$rows = $this->up->select($sql, $bind);
$rows = $this->selectWithReconnect($sql, $bind);
if ($rows === []) {
break;
}
@ -378,6 +380,30 @@ class ShippingReplicaSynchronizer
return $total;
}
/** @return list<array<string,mixed>> */
private function selectWithReconnect(string $sql, array $bindings): array
{
try {
return $this->up->select($sql, $bindings);
} catch (PDOException $e) {
if (! $this->isTransientCursorFailure($e)) {
throw $e;
}
// select() returns only after fetchAll(), so no partial result was consumed.
UpstreamConnection::reset(self::SOURCE);
return $this->up->select($sql, $bindings);
}
}
private function isTransientCursorFailure(PDOException $e): bool
{
$message = strtolower($e->getMessage());
return str_contains($message, 'pdo_dblib: dbnextrow() returned fail');
}
/** 并入自动发货仓:jst_Orders 已发货但不在发货明细表。 */
private function syncAutoShip(string $fromS, string $toS, $now, ?callable $progress = null): int
{
@ -399,7 +425,7 @@ class ShippingReplicaSynchronizer
$bind[] = $lastRid;
}
$sql = "SELECT TOP {$batch} {$cols} FROM rpt.jst_Orders o WHERE {$where} ORDER BY o.Id DESC";
$rows = $this->up->select($sql, $bind);
$rows = $this->selectWithReconnect($sql, $bind);
if ($rows === []) {
break;
}
@ -666,7 +692,7 @@ class ShippingReplicaSynchronizer
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select("SELECT {$sel} FROM rpt.jst_OrderItem WHERE o_id IN ({$ph}) ORDER BY Id", $chunk) as $r) {
foreach ($this->selectWithReconnect("SELECT {$sel} FROM rpt.jst_OrderItem WHERE o_id IN ({$ph}) ORDER BY Id", $chunk) as $r) {
$k = (string) ($r['o_id'] ?? '');
if ($k !== '') {
$byOid[$k][] = $this->itemArr($r);
@ -683,7 +709,7 @@ class ShippingReplicaSynchronizer
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select("SELECT {$sel} FROM rpt.jst_OrderItem WHERE raw_so_id IN ({$ph}) ORDER BY Id", $chunk) as $r) {
foreach ($this->selectWithReconnect("SELECT {$sel} FROM rpt.jst_OrderItem WHERE raw_so_id IN ({$ph}) ORDER BY Id", $chunk) as $r) {
$k = (string) ($r['raw_so_id'] ?? '');
if ($k !== '') {
$bySo[$k][] = $this->itemArr($r);
@ -706,7 +732,7 @@ class ShippingReplicaSynchronizer
$d = $day->format('Y-m-d');
$day = $day->addDay();
try {
$rows = $this->up->select("SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE stat_date = ?", [$d]);
$rows = $this->selectWithReconnect("SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE stat_date = ?", [$d]);
} catch (Throwable) {
$this->dwsFailedDays[] = $d;
continue;
@ -742,7 +768,7 @@ class ShippingReplicaSynchronizer
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
$sql = "SELECT sourceno, item_id, skuoid, goods_count, sales_amount FROM rpt.dws_sn_sales_full_info_di WHERE sourceno IN ({$ph})";
foreach ($this->up->select($sql, $chunk) as $r) {
foreach ($this->selectWithReconnect($sql, $chunk) as $r) {
$so = (string) ($r['sourceno'] ?? '');
$barcode = (string) ($r['skuoid'] ?? '');
if ($so === '' || $barcode === '') {
@ -793,7 +819,7 @@ class ShippingReplicaSynchronizer
{
$this->outboundMap = ['byL' => [], 'byO' => []];
$sql = 'SELECT l_id, o_id, io_id FROM ' . self::OUTBOUND . ' WHERE io_date >= ? AND io_date < ?';
foreach ($this->up->select($sql, [$fromS, $toS]) as $r) {
foreach ($this->selectWithReconnect($sql, [$fromS, $toS]) as $r) {
$io = (string) ($r['io_id'] ?? '');
if ($io === '') {
continue;
@ -824,7 +850,7 @@ class ShippingReplicaSynchronizer
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select('SELECT l_id, io_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $chunk) as $r) {
foreach ($this->selectWithReconnect('SELECT l_id, io_id FROM ' . self::OUTBOUND . " WHERE l_id IN ({$ph})", $chunk) as $r) {
$l = (string) ($r['l_id'] ?? '');
if ($l !== '' && ! isset($byL[$l])) {
$byL[$l] = (string) ($r['io_id'] ?? '');
@ -836,7 +862,7 @@ class ShippingReplicaSynchronizer
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select('SELECT o_id, io_id FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) {
foreach ($this->selectWithReconnect('SELECT o_id, io_id FROM ' . self::OUTBOUND . " WHERE o_id IN ({$ph})", $chunk) as $r) {
$o = (string) ($r['o_id'] ?? '');
if ($o !== '' && ! isset($byO[$o])) {
$byO[$o] = (string) ($r['io_id'] ?? '');
@ -870,7 +896,7 @@ class ShippingReplicaSynchronizer
$ph = implode(',', array_fill(0, count($chunk), '?'));
$sql = 'SELECT io_id, sku_ids, qty, type, sku_name, shop_i_id, wareHoursName, l, w, h, volume, weight, supplier_name FROM '
. self::VIEW . " WHERE io_id IN ({$ph})";
foreach ($this->up->select($sql, $chunk) as $r) {
foreach ($this->selectWithReconnect($sql, $chunk) as $r) {
$io = (string) ($r['io_id'] ?? '');
$skuIds = (string) ($r['sku_ids'] ?? '');
if ($io === '' || $skuIds === '') {
@ -1196,7 +1222,7 @@ class ShippingReplicaSynchronizer
continue;
}
$ph = implode(',', array_fill(0, count($chunk), '?'));
foreach ($this->up->select("SELECT sku_id, l, w, h, pack_volume, weight, supplier_name, name, i_id FROM rpt.jst_sku WHERE sku_id IN ({$ph})", $chunk) as $r) {
foreach ($this->selectWithReconnect("SELECT sku_id, l, w, h, pack_volume, weight, supplier_name, name, i_id FROM rpt.jst_sku WHERE sku_id IN ({$ph})", $chunk) as $r) {
$out[(string) $r['sku_id']] = [
'l' => $r['l'] ?? null, 'w' => $r['w'] ?? null, 'h' => $r['h'] ?? null,
'vol' => $r['pack_volume'] ?? null, 'weight' => $r['weight'] ?? null,
@ -1221,7 +1247,7 @@ class ShippingReplicaSynchronizer
$sql = 'SELECT so_id, expressNo, SUM(freightCost) AS cost, SUM(weight) AS w, '
. 'MAX(expressName) AS name, MAX(receiver_state) AS state '
. "FROM rpt.fys_jstFreightCostDetail WHERE so_id IN ({$ph}) GROUP BY so_id, expressNo";
foreach ($this->up->select($sql, $chunk) as $r) {
foreach ($this->selectWithReconnect($sql, $chunk) as $r) {
$expno = (string) ($r['expressNo'] ?? '');
$val = ['cost' => $r['cost'] ?? null, 'w' => $r['w'] ?? null, 'name' => $r['name'] ?? null, 'state' => $r['state'] ?? null, 'no' => $expno ?: null];
$out[((string) $r['so_id']) . '|' . $expno] = $val;

View File

@ -8,6 +8,7 @@ use Modules\EcommerceFinance\Services\Jushuitan\ShippingReplicaSynchronizer;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
use Modules\EcommerceFinance\Repositories\ShippingOrderRepository;
use PDOException;
use RuntimeException;
use Tests\TestCase;
@ -111,6 +112,72 @@ class ShippingReplicaSynchronizerTest extends TestCase
->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all());
}
public function test_express_page_reconnects_once_after_transient_dblib_cursor_failure(): void
{
$firstPage = array_map(fn (int $id): array => [
'id' => $id,
'send_time' => $id === 3000 ? '2026-09-02 12:00:00' : null,
], range(3000, 1501));
$lastPage = [['id' => 1500, 'send_time' => '2026-09-03 12:00:00']];
$scanCalls = 0;
$bindings = [];
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturnCallback(function (string $sql, array $bind) use (&$scanCalls, &$bindings, $firstPage, $lastPage): array {
if (! str_contains($sql, 'FROM rpt.jst_order_express_details e LEFT JOIN')) {
return [];
}
$scanCalls++;
$bindings[] = $bind;
if ($scanCalls === 2) {
throw new PDOException('SQLSTATE[HY000]: General error: PDO_DBLIB: dbnextrow() returned FAIL');
}
return $scanCalls === 1 ? $firstPage : $lastPage;
});
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
$result = (new ShippingReplicaSynchronizer($factory))->sync(
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true,
);
$this->assertSame(3, $scanCalls);
$this->assertSame([[], [1501], [1501]], $bindings);
$this->assertSame(2, $result['processed']);
$this->assertSame(2, $result['unique_packages']);
$this->assertSame([1500, 3000], DB::connection('business_mirror')->table('jst_oms_shipping')
->orderBy('source_id')->pluck('source_id')->map(fn ($id) => (int) $id)->all());
}
public function test_non_transient_upstream_failure_is_not_retried(): void
{
$calls = 0;
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);
$upstream->method('select')->willReturnCallback(function (string $sql) use (&$calls): array {
if (str_contains($sql, 'FROM rpt.jst_order_express_details e LEFT JOIN')) {
$calls++;
throw new PDOException('SQLSTATE[HY000]: unrelated upstream error');
}
return [];
});
$factory = $this->createMock(UpstreamReadOnlyQueryFactory::class);
$factory->method('forSource')->with('operations_replica')->willReturn($upstream);
try {
(new ShippingReplicaSynchronizer($factory))->sync(
Carbon::parse('2026-09-01'), Carbon::parse('2026-09-05'), 2000, null, true,
);
$this->fail('Expected the upstream error to be propagated.');
} catch (PDOException $e) {
$this->assertStringContainsString('unrelated upstream error', $e->getMessage());
}
$this->assertSame(1, $calls);
$this->assertSame('failed', DB::connection('business_mirror')->table('jst_sync_state')
->where('dataset_key', 'jst.sctrade')->value('status'));
}
public function test_window_result_distinguishes_duplicate_package_rows_from_unique_source_ids(): void
{
$upstream = $this->createMock(UpstreamReadOnlyQuery::class);