fix: retry transient DingDrive sync failures
This commit is contained in:
parent
5c252fb272
commit
912b8a4242
470
scripts/ding_daily_sync.py
Normal file
470
scripts/ding_daily_sync.py
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Transactional DingDrive -> cloud -> LAN replication. No app/network monkeypatch imports."""
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import fcntl
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pymysql
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
STATE = ROOT / 'var/ding-sync'
|
||||||
|
SSH = ['ssh', '-i', '/home/dieya/.ssh/id_ed25519_dycg_restore', '-o', 'BatchMode=yes',
|
||||||
|
'-o', 'ConnectTimeout=15', 'ubuntu@82.156.135.91']
|
||||||
|
REMOTE = '/opt/dy-dashboard/var/ding-sync'
|
||||||
|
DWS = '/home/ubuntu/.npm-global/lib/node_modules/dingtalk-workspace-cli/vendor/dws'
|
||||||
|
SPACE = '28814565790'
|
||||||
|
FOLDERS = {'inventory': 'NkDwLng8ZLGrXmrnFxYq3vRqVKMEvZBY',
|
||||||
|
'outbound': '3NwLYZXWynyBjRB4tZjwNxDvVkyEqBQm'}
|
||||||
|
SOURCES = {'wdt', 'jdbz', 'jdgj', 'jdjwt', 'tmgj', 'dymuto', 'dyqsimelr', 'jdsmj', 'jdmrt'}
|
||||||
|
PATTERNS = {'inventory': re.compile(r'^(20\d{6})-wdt-kcmx-([\w]+)\.csv$'),
|
||||||
|
'outbound': re.compile(r'^(20\d{6})-([a-z0-9]+)-cksj\.csv$')}
|
||||||
|
COLS = {'inventory': ['data_date', 'source', 'warehouse_code', 'goods_code', 'product_code',
|
||||||
|
'product_name', 'brand', 'cost_price', 'available_qty', 'production_in_transit',
|
||||||
|
'purchase_in_transit', 'category1', 'category2', 'category3', 'created_time'],
|
||||||
|
'outbound': ['货品编号', '货品名称', '品牌', '仓库', '店铺', '一级类目', '二级类目',
|
||||||
|
'三级类目', '货品数量', '货品成本', '日期', '数据来源', '数据类型']}
|
||||||
|
TABLES = {'inventory': 'inventory_detail', 'outbound': 'daily_outbound'}
|
||||||
|
SPECIALS = {'POZ-FM-0184-TM-SM': '.055', 'POZ-XFS-9014-TM-MD': '.062',
|
||||||
|
'POZ-FM-9742-TM-MD': '.181', 'POZ-MM-0580-TM-MD': '.22',
|
||||||
|
**{f'POZ-SKS-{n}-LD': '.1' for n in ('3374', '3367', '3350', '3343')}}
|
||||||
|
DWS_RETRY_DELAYS = (2, 5, 10)
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now(ZoneInfo('Asia/Shanghai'))
|
||||||
|
|
||||||
|
|
||||||
|
def command(args, timeout=180, retry_delays=()):
|
||||||
|
"""Run a command, optionally retrying without exposing credential-bearing output."""
|
||||||
|
attempts = len(retry_delays) + 1
|
||||||
|
failure = 'failed'
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(args, capture_output=True, timeout=timeout)
|
||||||
|
if not result.returncode:
|
||||||
|
return result.stdout
|
||||||
|
failure = f'failed (exit {result.returncode})'
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
failure = f'timed out after {timeout}s'
|
||||||
|
if attempt < len(retry_delays):
|
||||||
|
time.sleep(retry_delays[attempt])
|
||||||
|
# Never echo command output: authentication errors may contain tokens.
|
||||||
|
raise RuntimeError(f'{Path(args[0]).name} {failure} after {attempts} attempts')
|
||||||
|
|
||||||
|
|
||||||
|
def dws_command(args, timeout=180):
|
||||||
|
# A single daily job may recover from brief DingDrive/IPaaS failures. This
|
||||||
|
# is bounded in-process retrying, not polling or an additional schedule.
|
||||||
|
return command(args, timeout=timeout, retry_delays=DWS_RETRY_DELAYS)
|
||||||
|
|
||||||
|
|
||||||
|
def encode(value):
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(',', ':'), default=str).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def atomic(path, value):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, 'wb') as out:
|
||||||
|
out.write(value if isinstance(value, bytes) else encode(value))
|
||||||
|
out.flush()
|
||||||
|
os.fsync(out.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path, default=None):
|
||||||
|
return json.loads(path.read_bytes()) if path.exists() else default
|
||||||
|
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
return str(v or '').replace('"', '').replace('=', '').strip()
|
||||||
|
|
||||||
|
|
||||||
|
def number(v, integer=False):
|
||||||
|
value = Decimal(clean(v).replace(',', '') or '0')
|
||||||
|
if not value.is_finite() or (integer and value != value.to_integral_value()):
|
||||||
|
raise ValueError('Invalid numeric CSV field')
|
||||||
|
return int(value) if integer else format(value.quantize(Decimal('.0001')), 'f')
|
||||||
|
|
||||||
|
|
||||||
|
def bad(code):
|
||||||
|
return clean(code).upper().rstrip('::') in {'', 'NA', 'N/A', '合计'}
|
||||||
|
|
||||||
|
|
||||||
|
def csv_reader(path):
|
||||||
|
raw = path.read_bytes()
|
||||||
|
for encoding in ('utf-8-sig', 'gb18030'):
|
||||||
|
try:
|
||||||
|
content = raw.decode(encoding)
|
||||||
|
break
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise ValueError('Unrecognized CSV encoding')
|
||||||
|
reader = csv.DictReader(io.StringIO(content))
|
||||||
|
if not reader.fieldnames:
|
||||||
|
raise ValueError('Missing CSV header')
|
||||||
|
reader.fieldnames = [clean(x).lstrip('\ufeff') for x in reader.fieldnames]
|
||||||
|
return reader
|
||||||
|
|
||||||
|
|
||||||
|
def pick(row, keys):
|
||||||
|
return next((clean(row[k]) for k in keys if clean(row.get(k))), '')
|
||||||
|
|
||||||
|
|
||||||
|
def parse(path, kind, day, key, products):
|
||||||
|
reader = csv_reader(path)
|
||||||
|
headers = set(reader.fieldnames)
|
||||||
|
if kind == 'inventory':
|
||||||
|
required = {'货品编号', '货品名称', '成本价', '采购在途', '生产在途量'}
|
||||||
|
qty_col = '库存' if '库存' in headers else '可用库存'
|
||||||
|
required.add(qty_col)
|
||||||
|
elif key == 'wdt':
|
||||||
|
required = {'货品编号', '货品名称', '货品数量', '货品成本'}
|
||||||
|
else:
|
||||||
|
required = set()
|
||||||
|
if not headers.intersection({'货品编号', '产品编码', '商品编码', '编码', 'product_code', 'goods_code'}):
|
||||||
|
raise ValueError(f'{path.name}: Missing product code column')
|
||||||
|
if not headers.intersection({'货品数量', '数量', '出库量', 'qty', 'quantity'}):
|
||||||
|
raise ValueError(f'{path.name}: Missing quantity column')
|
||||||
|
if required - headers:
|
||||||
|
raise ValueError(f'{path.name}: missing columns {sorted(required - headers)}; retained existing data')
|
||||||
|
rows = []
|
||||||
|
valid_codes = 0
|
||||||
|
for row in reader:
|
||||||
|
code = pick(row, ('货品编号', '产品编码', '商品编码', '编码', 'product_code', 'goods_code'))
|
||||||
|
if bad(code):
|
||||||
|
continue
|
||||||
|
valid_codes += 1
|
||||||
|
if kind == 'inventory':
|
||||||
|
name = clean(row.get('货品名称'))
|
||||||
|
if name in {'-', '--'}:
|
||||||
|
continue
|
||||||
|
ct = clean(row.get('单品创建时间'))
|
||||||
|
created = None
|
||||||
|
if ct:
|
||||||
|
created = datetime.fromisoformat(ct).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
rows.append([day, 'wdt', key, clean(row.get('商家编码')), code, name,
|
||||||
|
clean(row.get('品牌')), number(SPECIALS.get(code, row.get('成本价'))),
|
||||||
|
number(row.get(qty_col), True), number(row.get('生产在途量'), True),
|
||||||
|
number(row.get('采购在途'), True), clean(row.get('一级类目')),
|
||||||
|
clean(row.get('二级类目')), clean(row.get('三级类目')), created])
|
||||||
|
else:
|
||||||
|
if key == 'wdt' and clean(row.get('日期')):
|
||||||
|
row_day = clean(row['日期'])[:10].replace('/', '-')
|
||||||
|
if date.fromisoformat(row_day).isoformat() != day:
|
||||||
|
raise ValueError(f'{path.name}: row date does not match filename')
|
||||||
|
qty = number(pick(row, ('货品数量', '数量', '出库量', 'qty', 'quantity')), True)
|
||||||
|
p = row if key == 'wdt' else products.get(code, {})
|
||||||
|
name = clean(p.get('货品名称') if key == 'wdt' else p.get('product_name'))
|
||||||
|
brand = clean(p.get('品牌') if key == 'wdt' else p.get('brand'))
|
||||||
|
if qty <= 0 or name in {'-', '--'} or '潘思宇代发' in brand:
|
||||||
|
continue
|
||||||
|
cats = ['一级类目', '二级类目', '三级类目'] if key == 'wdt' else ['category1', 'category2', 'category3']
|
||||||
|
rows.append([code, name or code, brand, clean(row.get('仓库')) if key == 'wdt' else '',
|
||||||
|
clean(row.get('店铺')) if key == 'wdt' else '', *[clean(p.get(x)) for x in cats],
|
||||||
|
qty, number(p.get('货品成本') if key == 'wdt' else p.get('cost_price')),
|
||||||
|
day, key, '出库数据'])
|
||||||
|
if not rows and (kind == 'inventory' or not valid_codes):
|
||||||
|
raise ValueError(f'{path.name}: no valid records; refusing replacement')
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def connect(local):
|
||||||
|
spec = importlib.util.spec_from_file_location('sync_config', ROOT / 'config.py')
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
config = dict(mod.DB_CONFIG)
|
||||||
|
if local:
|
||||||
|
config.update(user='dy_test', password=(ROOT / '.local-test-db-password').read_text().strip(),
|
||||||
|
unix_socket='/run/mysqld/mysqld.sock')
|
||||||
|
config.update(cursorclass=pymysql.cursors.DictCursor, autocommit=False)
|
||||||
|
return pymysql.connect(**config)
|
||||||
|
|
||||||
|
|
||||||
|
def where(packet):
|
||||||
|
if packet['kind'] == 'inventory':
|
||||||
|
return 'data_date=%s AND source=%s AND warehouse_code=%s', (packet['date'], 'wdt', packet['key'])
|
||||||
|
return '`日期`=%s AND `数据来源`=%s', (packet['date'], packet['key'])
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(rows, kind):
|
||||||
|
money_index = 7 if kind == 'inventory' else 9
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
r = [str(v) if isinstance(v, (date, datetime)) else v for v in row]
|
||||||
|
r[money_index] = number(r[money_index])
|
||||||
|
result.append(r)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def digest(rows, kind):
|
||||||
|
return hashlib.sha256(b'\n'.join(sorted(encode(r) for r in normalize(rows, kind)))).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def db_rows(conn, p):
|
||||||
|
condition, params = where(p)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT `' + '`,`'.join(COLS[p['kind']]) + '` FROM ' + TABLES[p['kind']] + ' WHERE ' + condition, params)
|
||||||
|
return [[r[c] for c in COLS[p['kind']]] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def refresh():
|
||||||
|
# Load cache modules directly, avoiding services/__init__ and any DB/AI side effects.
|
||||||
|
for name, function in (('cache', 'refresh_all_caches'), ('simple_cache', 'clear')):
|
||||||
|
spec = importlib.util.spec_from_file_location('sync_' + name, ROOT / 'services' / (name + '.py'))
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
getattr(module, function)()
|
||||||
|
|
||||||
|
|
||||||
|
def apply(conn, packets, dry_run=False):
|
||||||
|
changed = []
|
||||||
|
for p in packets:
|
||||||
|
if p['kind'] not in COLS or p['date'] != date.fromisoformat(p['date']).isoformat():
|
||||||
|
raise ValueError('Invalid packet target')
|
||||||
|
if any(len(r) != len(COLS[p['kind']]) for r in p['rows']):
|
||||||
|
raise ValueError('Invalid packet shape')
|
||||||
|
old = db_rows(conn, p)
|
||||||
|
if digest(old, p['kind']) != digest(p['rows'], p['kind']):
|
||||||
|
changed.append(p)
|
||||||
|
if not dry_run:
|
||||||
|
condition, params = where(p)
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT * FROM ' + TABLES[p['kind']] + ' WHERE ' + condition, params)
|
||||||
|
atomic(STATE / 'backups' / (now().strftime('%Y%m%dT%H%M%S%f') + '.json.gz'),
|
||||||
|
gzip.compress(encode({'target': {k: p[k] for k in ('kind', 'date', 'key')}, 'rows': cur.fetchall()})))
|
||||||
|
cur.execute('DELETE FROM ' + TABLES[p['kind']] + ' WHERE ' + condition, params)
|
||||||
|
sql = 'INSERT INTO ' + TABLES[p['kind']] + ' (`' + '`,`'.join(COLS[p['kind']]) + '`) VALUES (' + ','.join(['%s'] * len(COLS[p['kind']])) + ')'
|
||||||
|
if p['rows']:
|
||||||
|
cur.executemany(sql, p['rows'])
|
||||||
|
if digest(db_rows(conn, p), p['kind']) != digest(p['rows'], p['kind']):
|
||||||
|
raise RuntimeError('Database readback mismatch; transaction rolled back')
|
||||||
|
if dry_run:
|
||||||
|
conn.rollback()
|
||||||
|
else:
|
||||||
|
conn.commit()
|
||||||
|
# Refresh even after interrupted commit; publication/ack happens only after refresh succeeds.
|
||||||
|
if packets:
|
||||||
|
refresh()
|
||||||
|
return [{'kind': p['kind'], 'date': p['date'], 'key': p['key'], 'rows': len(p['rows'])} for p in changed]
|
||||||
|
|
||||||
|
|
||||||
|
def list_files(kind):
|
||||||
|
items, cursor, seen = [], None, set()
|
||||||
|
while True:
|
||||||
|
args = [DWS, 'drive', 'list', '--space-id', SPACE, '--folder', FOLDERS[kind], '--limit', '50', '--format', 'json']
|
||||||
|
if cursor:
|
||||||
|
args.extend(['--cursor', cursor])
|
||||||
|
response = json.loads(dws_command(args))
|
||||||
|
if response.get('success') is False or 'result' not in response:
|
||||||
|
raise RuntimeError('DingDrive list failed')
|
||||||
|
items.extend(response['result'].get('items', []))
|
||||||
|
cursor = response['result'].get('nextToken')
|
||||||
|
if not cursor:
|
||||||
|
return items
|
||||||
|
if cursor in seen:
|
||||||
|
raise RuntimeError('Repeated DingDrive pagination cursor')
|
||||||
|
seen.add(cursor)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud(conn, args):
|
||||||
|
config = read_json(STATE / 'settings.json')
|
||||||
|
target = args.target_date or now().date()
|
||||||
|
old = read_json(STATE / 'manifest.json', {'entries': {}})
|
||||||
|
# Publish only this run's dates. Missing files never become a catch-up queue.
|
||||||
|
entries = {}
|
||||||
|
packets, missing = [], []
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT product_code,product_name,brand,category1,category2,category3,cost_price FROM products')
|
||||||
|
products = {r['product_code']: r for r in cur.fetchall()}
|
||||||
|
for kind in FOLDERS:
|
||||||
|
files = {}
|
||||||
|
for item in list_files(kind):
|
||||||
|
m = PATTERNS[kind].fullmatch(item.get('name', ''))
|
||||||
|
if not m or item.get('type') != 'FILE':
|
||||||
|
continue
|
||||||
|
day, key = m.groups()
|
||||||
|
day = datetime.strptime(day, '%Y%m%d').date()
|
||||||
|
key = 'dymuto' if key == 'dymtuo' else key
|
||||||
|
if kind == 'outbound' and key not in SOURCES:
|
||||||
|
continue
|
||||||
|
identity = (day, key)
|
||||||
|
if identity not in files or item['modifyTime'] > files[identity]['modifyTime']:
|
||||||
|
files[identity] = item
|
||||||
|
begin = target - timedelta(days=kind == 'outbound')
|
||||||
|
end = target - timedelta(days=kind == 'outbound')
|
||||||
|
day = begin
|
||||||
|
while day <= end:
|
||||||
|
expected = set(config['warehouses']) if kind == 'inventory' else SOURCES
|
||||||
|
uploaded = {k for d, k in files if d == day}
|
||||||
|
for key in sorted(expected - uploaded):
|
||||||
|
missing.append(f'{kind}:{day}:{key}')
|
||||||
|
for key in sorted(uploaded):
|
||||||
|
item = files[(day, key)]
|
||||||
|
identity = f'{kind}:{day}:{key}'
|
||||||
|
fingerprint = hashlib.sha256(encode([item['fileId'], item.get('md5'), item['modifyTime'], item['fileSize']])).hexdigest()
|
||||||
|
existing = old['entries'].get(identity)
|
||||||
|
if existing and existing['source_fingerprint'] == fingerprint:
|
||||||
|
entries[identity] = existing
|
||||||
|
continue
|
||||||
|
dest = STATE / 'downloads' / item['name']
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dws_command([DWS, 'drive', 'download', '--node', item['fileId'], '--space-id', SPACE, '--output', str(dest), '--format', 'json'])
|
||||||
|
if dest.stat().st_size != item['fileSize']:
|
||||||
|
raise ValueError(f'{item["name"]}: incomplete download')
|
||||||
|
p = {'kind': kind, 'date': str(day), 'key': key,
|
||||||
|
'rows': parse(dest, kind, str(day), key, products)}
|
||||||
|
blob = gzip.compress(encode(p), mtime=0)
|
||||||
|
sha = hashlib.sha256(blob).hexdigest()
|
||||||
|
atomic(STATE / 'packets' / (sha + '.json.gz'), blob)
|
||||||
|
entries[identity] = {'packet': sha, 'source_fingerprint': fingerprint,
|
||||||
|
'rows': len(p['rows']), 'source_name': item['name']}
|
||||||
|
packets.append(p)
|
||||||
|
day += timedelta(days=1)
|
||||||
|
changed = apply(conn, packets, args.dry_run)
|
||||||
|
if not args.dry_run and not packets and not (STATE / 'manifest.json').exists():
|
||||||
|
refresh()
|
||||||
|
coverage = {kind: sum(identity.startswith(f'{kind}:{target - timedelta(days=kind == "outbound")}:')
|
||||||
|
for identity in entries) for kind in FOLDERS}
|
||||||
|
status = {'checked_at': now().isoformat(), 'target_inventory': str(target),
|
||||||
|
'target_outbound': str(target - timedelta(days=1)), 'missing': missing,
|
||||||
|
'state': 'success', 'missing_policy': 'skip', 'changed': changed,
|
||||||
|
'cache_refreshed': not args.dry_run, 'files': len(entries), 'coverage': coverage}
|
||||||
|
if not args.dry_run:
|
||||||
|
manifest = {'entries': entries, 'missing': missing, 'missing_policy': 'skip', 'target_inventory': str(target),
|
||||||
|
'target_outbound': status['target_outbound'], 'coverage': coverage}
|
||||||
|
manifest['version'] = hashlib.sha256(encode(manifest)).hexdigest()
|
||||||
|
status['version'] = manifest['version']
|
||||||
|
atomic(STATE / 'status.json', status)
|
||||||
|
atomic(STATE / 'manifest.json', manifest)
|
||||||
|
print(json.dumps(status, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def local(conn, args):
|
||||||
|
manifest = json.loads(command(SSH + ['cat', REMOTE + '/manifest.json']))
|
||||||
|
cloud_status = json.loads(command(SSH + ['cat', REMOTE + '/status.json']))
|
||||||
|
target = (args.target_date or now().date()).isoformat()
|
||||||
|
if manifest.get('target_inventory') != target or manifest.get('missing_policy') != 'skip':
|
||||||
|
raise RuntimeError('No requested-date single-run publication; other dates will not be imported')
|
||||||
|
if not cloud_status.get('cache_refreshed') or cloud_status.get('version') != manifest['version']:
|
||||||
|
raise RuntimeError('Cloud update incomplete or failed; waiting for a validated publication')
|
||||||
|
old = read_json(STATE / 'local-status.json', {})
|
||||||
|
packets = []
|
||||||
|
ledger = read_json(STATE / 'local-ledger.json', {})
|
||||||
|
for identity, entry in sorted(manifest['entries'].items()):
|
||||||
|
sha = entry['packet']
|
||||||
|
if not re.fullmatch('[a-f0-9]{64}', sha):
|
||||||
|
raise ValueError('Invalid packet hash')
|
||||||
|
if ledger.get(identity) == sha:
|
||||||
|
continue
|
||||||
|
path = STATE / 'packets' / (sha + '.json.gz')
|
||||||
|
if not path.exists():
|
||||||
|
atomic(path, command(SSH + ['cat', REMOTE + '/packets/' + path.name]))
|
||||||
|
blob = path.read_bytes()
|
||||||
|
if hashlib.sha256(blob).hexdigest() != sha:
|
||||||
|
# Keep the corrupt artifact for diagnosis; let the next attempt fetch a clean copy.
|
||||||
|
path.rename(path.with_suffix('.corrupt-' + now().strftime('%Y%m%dT%H%M%S%f')))
|
||||||
|
raise ValueError('Packet checksum mismatch')
|
||||||
|
p = json.loads(gzip.decompress(blob))
|
||||||
|
if identity != f'{p["kind"]}:{p["date"]}:{p["key"]}':
|
||||||
|
raise ValueError('Packet identity mismatch')
|
||||||
|
packets.append(p)
|
||||||
|
if not packets and old.get('version') == manifest['version'] and old.get('cache_refreshed'):
|
||||||
|
# Keep online evidence current, without re-invalidating valid caches.
|
||||||
|
status = {**old, 'checked_at': now().isoformat()}
|
||||||
|
else:
|
||||||
|
changed = apply(conn, packets, args.dry_run)
|
||||||
|
status = {'checked_at': now().isoformat(), 'version': manifest['version'],
|
||||||
|
'target_inventory': manifest['target_inventory'], 'target_outbound': manifest['target_outbound'],
|
||||||
|
'missing': manifest['missing'], 'state': 'success', 'missing_policy': 'skip',
|
||||||
|
'changed': changed, 'cache_refreshed': not args.dry_run, 'files': len(manifest['entries']),
|
||||||
|
'coverage': manifest['coverage']}
|
||||||
|
if not args.dry_run:
|
||||||
|
atomic(STATE / 'local-ledger.json', {k: v['packet'] for k, v in manifest['entries'].items()})
|
||||||
|
atomic(STATE / 'local-status.json', status)
|
||||||
|
# Exact local readback + cache completion are acknowledged to cloud.
|
||||||
|
result = subprocess.run(SSH + ['umask 077; tee ' + REMOTE + '/local-ack.json.tmp >/dev/null && mv ' + REMOTE + '/local-ack.json.tmp ' + REMOTE + '/local-ack.json'], input=encode(status), capture_output=True, timeout=45)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError('Cloud acknowledgement failed; no automatic retry')
|
||||||
|
if old.get('version') != status['version']:
|
||||||
|
env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f'unix:path=/run/user/{os.getuid()}/bus', XDG_RUNTIME_DIR=f'/run/user/{os.getuid()}')
|
||||||
|
msg = (f"库存 {status['target_inventory']}:{status['coverage']['inventory']}仓库;"
|
||||||
|
f"出库 {status['target_outbound']}:{status['coverage']['outbound']}来源。两端已同步这些文件并刷新缓存。")
|
||||||
|
if status['missing']:
|
||||||
|
msg += f" 本次未上传的{len(status['missing'])}份文件已跳过,不等待补录。"
|
||||||
|
try:
|
||||||
|
notification = subprocess.run(['notify-send', '采购数据同步结果', msg], env=env, capture_output=True, timeout=10)
|
||||||
|
status['desktop_notification'] = 'delivered' if notification.returncode == 0 else 'desktop_unavailable'
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
status['desktop_notification'] = 'desktop_unavailable'
|
||||||
|
atomic(STATE / 'local-status.json', status)
|
||||||
|
print(json.dumps(status, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('mode', choices=['cloud', 'local'])
|
||||||
|
parser.add_argument('--dry-run', action='store_true')
|
||||||
|
parser.add_argument('--target-date', type=date.fromisoformat,
|
||||||
|
help='Manual recovery date (YYYY-MM-DD); scheduled runs omit this option')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.target_date and args.target_date > now().date():
|
||||||
|
parser.error('--target-date cannot be in the future')
|
||||||
|
os.umask(0o077)
|
||||||
|
STATE.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(STATE / (args.mode + '.lock'), 'w') as lock:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
print('Already running; skipped')
|
||||||
|
return
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
conn = connect(args.mode == 'local')
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT GET_LOCK('dy_ding_daily_sync', 0) AS acquired")
|
||||||
|
if not cur.fetchone()['acquired']:
|
||||||
|
raise RuntimeError('Another database import is running')
|
||||||
|
(cloud if args.mode == 'cloud' else local)(conn, args)
|
||||||
|
except Exception as error:
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
failure = {'checked_at': now().isoformat(), 'state': 'error', 'error': str(error), 'cache_refreshed': False}
|
||||||
|
if not args.dry_run:
|
||||||
|
atomic(STATE / ('status.json' if args.mode == 'cloud' else 'local-status.json'), failure)
|
||||||
|
if args.mode == 'local':
|
||||||
|
# One best-effort failure receipt allows cloud to distinguish failure from offline.
|
||||||
|
try:
|
||||||
|
subprocess.run(SSH + ['umask 077; tee ' + REMOTE + '/local-ack.json.tmp >/dev/null && mv ' + REMOTE + '/local-ack.json.tmp ' + REMOTE + '/local-ack.json'],
|
||||||
|
input=encode(failure), capture_output=True, timeout=30)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
print(json.dumps(failure, ensure_ascii=False), file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
59
tests/test_ding_daily_sync_retry.py
Normal file
59
tests/test_ding_daily_sync_retry.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'ding_daily_sync.py'
|
||||||
|
SPEC = importlib.util.spec_from_file_location('ding_daily_sync_retry_test', SCRIPT)
|
||||||
|
SYNC = importlib.util.module_from_spec(SPEC)
|
||||||
|
SPEC.loader.exec_module(SYNC)
|
||||||
|
|
||||||
|
|
||||||
|
class Result:
|
||||||
|
def __init__(self, returncode, stdout=b''):
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
|
||||||
|
|
||||||
|
class DingCommandRetryTest(unittest.TestCase):
|
||||||
|
def test_dws_command_retries_then_succeeds(self):
|
||||||
|
results = iter([Result(1), Result(1), Result(0, b'published')])
|
||||||
|
sleeps = []
|
||||||
|
with mock.patch.object(SYNC.subprocess, 'run', side_effect=lambda *args, **kwargs: next(results)), \
|
||||||
|
mock.patch.object(SYNC.time, 'sleep', side_effect=sleeps.append):
|
||||||
|
self.assertEqual(SYNC.dws_command([SYNC.DWS, 'drive', 'list']), b'published')
|
||||||
|
self.assertEqual(sleeps, [2, 5])
|
||||||
|
|
||||||
|
def test_dws_command_stops_after_bounded_attempts(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
calls.append(args)
|
||||||
|
return Result(1)
|
||||||
|
|
||||||
|
with mock.patch.object(SYNC.subprocess, 'run', side_effect=run), \
|
||||||
|
mock.patch.object(SYNC.time, 'sleep'):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, r'failed \(exit 1\) after 4 attempts'):
|
||||||
|
SYNC.dws_command([SYNC.DWS, 'drive', 'download'])
|
||||||
|
self.assertEqual(len(calls), 4)
|
||||||
|
|
||||||
|
def test_dws_command_retries_timeout_without_leaking_output(self):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
raise subprocess.TimeoutExpired(args[0], kwargs['timeout'])
|
||||||
|
return Result(0, b'ok')
|
||||||
|
|
||||||
|
with mock.patch.object(SYNC.subprocess, 'run', side_effect=run), \
|
||||||
|
mock.patch.object(SYNC.time, 'sleep'):
|
||||||
|
self.assertEqual(SYNC.dws_command([SYNC.DWS, 'drive', 'list']), b'ok')
|
||||||
|
self.assertEqual(calls, 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user