Bound the Dell warranty sync per run
The sync route asked Dell about every pending service tag in one request. bulk_lookup sends 100 tags per HTTP call at a 15 second timeout, so a freshly-onboarded site was dozens of sequential calls inside a single web request. A gunicorn worker timeout or a proxy would plausibly kill it first, leaving the user with a failure after minutes and no way to tell what landed. F3 released the pooled connection before the vendor call, which stopped one sync from taking the app down, but did not make the request itself survivable. Extract the sync into a service taking a per-run tag limit. The route now caps at 200 tags - two Dell calls - and returns how many are still pending so the caller can run it again. A flask warranty sync-dell command does the same work from the command line for a large first-run backfill. The chunk is sampled at random rather than taken off a stable order. A serial Dell does not recognize never gains a warranty and so stays pending forever; under a stable order those dead tags would occupy every chunk and the sync would never reach the rest of the fleet. Nothing runs this on a schedule. The operation is used a few times a year, so a cron would idle-poll the warranty tables tens of thousands of times a year for nothing and log a configuration failure on every run at sites without Dell credentials. The two existing sync tests patched get_provider on the route module; the provider is now resolved in the service, so they patch it there.
This commit is contained in:
@@ -23,7 +23,10 @@ from shopdb.api import (
|
||||
)
|
||||
|
||||
from ..models import Warranty, WarrantyAsset
|
||||
from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
|
||||
from ..services import (
|
||||
get_provider, ProviderNotConfigured, WarrantyLookupError,
|
||||
run_dell_sync, ROUTE_SYNC_LIMIT,
|
||||
)
|
||||
|
||||
warranty_bp = Blueprint('warranty', __name__)
|
||||
|
||||
@@ -320,118 +323,36 @@ def refresh_warranty(warrantyid):
|
||||
@warranty_bp.route('/sync/dell', methods=['POST'])
|
||||
@require_permission('warranty.edit')
|
||||
def sync_dell():
|
||||
"""Look up every asset serial (= Dell service tag) that has no warranty yet
|
||||
"""Look up asset serials (= Dell service tags) that have no warranty yet
|
||||
and create warranties for the ones Dell recognizes.
|
||||
|
||||
Bounded to ROUTE_SYNC_LIMIT tags per call. Dell is queried 100 tags at a
|
||||
time, so an unbounded fleet-sized sync is dozens of sequential HTTP calls
|
||||
inside one request and a worker timeout or proxy kills it first. The
|
||||
response carries `remaining` so the caller knows to run it again; nothing
|
||||
drains it in the background. `flask warranty sync-dell` does the same work
|
||||
from the command line for a large first-run backfill.
|
||||
|
||||
Only assets that lack a dated warranty are looked up, so re-running is cheap
|
||||
and non-Dell serials are filtered out by Dell (they return no coverage).
|
||||
"""
|
||||
provider = get_provider('dell')
|
||||
# ?all=true re-checks assets that already have a dated warranty too.
|
||||
recheck_all = request.args.get('all', 'false').lower() == 'true'
|
||||
|
||||
# Assets already covered by a dated warranty - skipped unless recheck_all.
|
||||
covered = set()
|
||||
if not recheck_all:
|
||||
for link in WarrantyAsset.query.all():
|
||||
w = db.session.get(Warranty, link.warrantyid)
|
||||
if w and w.isactive and w.enddate:
|
||||
covered.add(link.assetid)
|
||||
|
||||
# Candidate assets: active, have a serial, not already covered.
|
||||
candidates = (Asset.query
|
||||
.filter(Asset.isactive.is_(True),
|
||||
Asset.serialnumber.isnot(None),
|
||||
Asset.serialnumber != '')
|
||||
.all())
|
||||
by_tag = {}
|
||||
for asset in candidates:
|
||||
if asset.assetid in covered:
|
||||
continue
|
||||
by_tag.setdefault(asset.serialnumber.strip().upper(), []).append(asset.assetid)
|
||||
|
||||
if not by_tag:
|
||||
return success_response({
|
||||
'candidates': 0, 'matched': 0, 'created': 0, 'updated': 0,
|
||||
}, message='No PCs need a warranty lookup.')
|
||||
|
||||
# by_tag is plain strings and ints, so it survives the commit. Release the
|
||||
# connection before bulk_lookup: Dell is called 100 tags at a time and a
|
||||
# fleet-sized sync would otherwise pin a pooled connection for minutes.
|
||||
db.session.commit()
|
||||
|
||||
try:
|
||||
results = provider.bulk_lookup(list(by_tag.keys()))
|
||||
summary = run_dell_sync(limit=ROUTE_SYNC_LIMIT, recheck_all=recheck_all)
|
||||
except (ProviderNotConfigured, WarrantyLookupError) as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
||||
|
||||
# Preload every warranty already linked to a candidate asset in one query.
|
||||
# Done per asset this was a query per asset plus one per link, which would
|
||||
# just trade the long network hold for a long write transaction.
|
||||
matched_assetids = [assetid
|
||||
for tag, assetids in by_tag.items() if results.get(tag)
|
||||
for assetid in assetids]
|
||||
existing_by_assetid = {}
|
||||
if matched_assetids:
|
||||
rows = (db.session.query(WarrantyAsset.assetid, Warranty)
|
||||
.join(Warranty, Warranty.warrantyid == WarrantyAsset.warrantyid)
|
||||
.filter(WarrantyAsset.assetid.in_(matched_assetids))
|
||||
.all())
|
||||
for assetid, warranty in rows:
|
||||
existing_by_assetid.setdefault(assetid, []).append(warranty)
|
||||
if not summary['tags']:
|
||||
return success_response(summary, message='No PCs need a warranty lookup.')
|
||||
|
||||
created = updated = matched = 0
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
for tag, assetids in by_tag.items():
|
||||
found = results.get(tag)
|
||||
if not found:
|
||||
continue
|
||||
matched += 1
|
||||
for assetid in assetids:
|
||||
# Reuse an existing Dell warranty for this asset if there is one.
|
||||
existing = None
|
||||
for candidate in existing_by_assetid.get(assetid, []):
|
||||
# Reuse a warranty that is Dell by ANY signal, not just
|
||||
# provider: a manually-added or imported Dell warranty carries
|
||||
# provider 'manual' (create default) but the same service tag or
|
||||
# a "Dell" vendor. Matching only provider=='dell' made re-check
|
||||
# duplicate every one of those instead of updating it.
|
||||
if (candidate.provider == 'dell'
|
||||
or (candidate.servicetag or '').strip().upper() == tag
|
||||
or (candidate.vendor or '').strip().lower() == 'dell'):
|
||||
existing = candidate
|
||||
break
|
||||
if existing:
|
||||
# Canonicalize to Dell so later re-checks match by provider and
|
||||
# never fall through to a duplicate.
|
||||
existing.provider = 'dell'
|
||||
existing.vendor = existing.vendor or 'Dell'
|
||||
existing.servicelevel = found.get('servicelevel')
|
||||
existing.startdate = _vendor_date(found.get('startdate'))
|
||||
existing.enddate = _vendor_date(found.get('enddate'))
|
||||
existing.servicetag = tag
|
||||
existing.lastcheckeddate = now
|
||||
updated += 1
|
||||
else:
|
||||
warranty = Warranty(
|
||||
vendor='Dell', provider='dell', servicetag=tag,
|
||||
servicelevel=found.get('servicelevel'),
|
||||
startdate=_vendor_date(found.get('startdate')),
|
||||
enddate=_vendor_date(found.get('enddate')),
|
||||
lastcheckeddate=now,
|
||||
)
|
||||
warranty.links.append(WarrantyAsset(assetid=assetid))
|
||||
db.session.add(warranty)
|
||||
created += 1
|
||||
|
||||
db.session.commit()
|
||||
return success_response({
|
||||
'candidates': sum(len(v) for v in by_tag.values()),
|
||||
'tags': len(by_tag),
|
||||
'matched': matched,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
}, message=f'Dell sync: {created} added, {updated} updated, {matched} tags matched.')
|
||||
message = (f"Dell sync: {summary['created']} added, {summary['updated']} updated, "
|
||||
f"{summary['matched']} tags matched.")
|
||||
if summary['remaining']:
|
||||
message += (f" {summary['remaining']} still to check - run the sync again "
|
||||
f"to continue.")
|
||||
return success_response(summary, message=message)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -10,12 +10,14 @@ import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
import click
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
|
||||
from .api import warranty_bp
|
||||
from .models import Warranty, WarrantyAsset
|
||||
from .services import CLI_SYNC_LIMIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -182,5 +184,44 @@ class WarrantyPlugin(BasePlugin):
|
||||
"""
|
||||
return ['warranty.view']
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""Return CLI commands for this plugin."""
|
||||
|
||||
@click.group('warranty')
|
||||
def warrantycli():
|
||||
"""Warranty plugin commands."""
|
||||
pass
|
||||
|
||||
@warrantycli.command('sync-dell')
|
||||
@click.option('--limit', default=None, type=int,
|
||||
help=f'Tags to look up this run (default {CLI_SYNC_LIMIT}).')
|
||||
@click.option('--all', 'recheck_all', is_flag=True,
|
||||
help='Re-check assets that already have a dated warranty.')
|
||||
def syncdell(limit, recheck_all):
|
||||
"""Look up Dell warranties for a bounded chunk of pending assets.
|
||||
|
||||
Manual counterpart to the Dell sync button, for a large first-run
|
||||
backfill where repeated clicking would be tedious. Nothing runs this
|
||||
on a schedule. Each run takes one Dell call by default; assets that
|
||||
already hold a dated warranty are skipped, so runs are cheap and
|
||||
safely repeatable until `remaining` reaches zero.
|
||||
"""
|
||||
from flask import current_app
|
||||
from .services import run_dell_sync, CLI_SYNC_LIMIT as default_limit
|
||||
from .services import ProviderNotConfigured, WarrantyLookupError
|
||||
|
||||
with current_app.app_context():
|
||||
try:
|
||||
summary = run_dell_sync(limit=limit or default_limit,
|
||||
recheck_all=recheck_all)
|
||||
except (ProviderNotConfigured, WarrantyLookupError) as exc:
|
||||
raise click.ClickException(str(exc))
|
||||
click.echo(
|
||||
f"Dell sync: {summary['tags']} tag(s) checked, "
|
||||
f"{summary['matched']} matched, {summary['created']} added, "
|
||||
f"{summary['updated']} updated, {summary['remaining']} remaining.")
|
||||
|
||||
return [warrantycli]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"Warranty plugin initialized (v{self.meta.version})")
|
||||
|
||||
@@ -6,5 +6,7 @@ from .providers import (
|
||||
ProviderNotConfigured,
|
||||
WarrantyLookupError,
|
||||
)
|
||||
from .dellsync import run_dell_sync, ROUTE_SYNC_LIMIT, CLI_SYNC_LIMIT
|
||||
|
||||
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError']
|
||||
__all__ = ['get_provider', 'provider_names', 'ProviderNotConfigured', 'WarrantyLookupError',
|
||||
'run_dell_sync', 'ROUTE_SYNC_LIMIT', 'CLI_SYNC_LIMIT']
|
||||
|
||||
169
plugins/warranty/services/dellsync.py
Normal file
169
plugins/warranty/services/dellsync.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Dell warranty sync, callable from a request or from the CLI.
|
||||
|
||||
Extracted from the POST /sync/dell route so the same logic can run in bounded
|
||||
chunks off the request path. `bulk_lookup` calls Dell 100 tags at a time at
|
||||
HTTP_TIMEOUT seconds each, so an unbounded fleet-sized sync is dozens of
|
||||
sequential HTTP calls inside one web request - long enough for a gunicorn
|
||||
worker timeout or a proxy to kill it first, leaving the user with a failure and
|
||||
no idea what landed.
|
||||
|
||||
The sync is resumable by design: assets that already hold a dated warranty are
|
||||
skipped, so a run that covers part of the fleet simply leaves the rest for the
|
||||
next run. That is what makes chunking safe - no job queue, no progress table,
|
||||
no state machine. Runs are operator-driven (the sync button, or
|
||||
`flask warranty sync-dell` for a big first-run backfill); nothing drains the
|
||||
remainder on a schedule, so callers report `remaining` and ask for another run.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shopdb.api import db, Asset, DateValueError, parse_date
|
||||
|
||||
from ..models import Warranty, WarrantyAsset
|
||||
from .providers import get_provider
|
||||
|
||||
# One Dell call is 100 tags. The synchronous route stays a couple of calls deep
|
||||
# so it cannot outlive a worker timeout; the CLI default is one call per run.
|
||||
ROUTE_SYNC_LIMIT = 200
|
||||
CLI_SYNC_LIMIT = 100
|
||||
|
||||
|
||||
def _vendor_date(value):
|
||||
"""A date off a VENDOR response, which this code does not control.
|
||||
|
||||
Kept lenient on purpose, unlike a date a person typed: a provider that
|
||||
starts returning a shape we do not parse must not fail the refresh of an
|
||||
otherwise good record. The field is simply left as it was.
|
||||
"""
|
||||
try:
|
||||
return parse_date(value)
|
||||
except DateValueError:
|
||||
return None
|
||||
|
||||
|
||||
def collect_pending_tags(recheck_all=False):
|
||||
"""Service tags still wanting a Dell lookup, mapped to their asset ids."""
|
||||
covered = set()
|
||||
if not recheck_all:
|
||||
for link in WarrantyAsset.query.all():
|
||||
warranty = db.session.get(Warranty, link.warrantyid)
|
||||
if warranty and warranty.isactive and warranty.enddate:
|
||||
covered.add(link.assetid)
|
||||
|
||||
candidates = (Asset.query
|
||||
.filter(Asset.isactive.is_(True),
|
||||
Asset.serialnumber.isnot(None),
|
||||
Asset.serialnumber != '')
|
||||
.all())
|
||||
by_tag = {}
|
||||
for asset in candidates:
|
||||
if asset.assetid in covered:
|
||||
continue
|
||||
by_tag.setdefault(asset.serialnumber.strip().upper(), []).append(asset.assetid)
|
||||
return by_tag
|
||||
|
||||
|
||||
def run_dell_sync(limit=None, recheck_all=False):
|
||||
"""Look up service tags that have no warranty yet and record what Dell knows.
|
||||
|
||||
`limit` caps how many TAGS are sent to Dell in this run. Returns the same
|
||||
counters the route has always returned plus `remaining`, so a caller can
|
||||
tell the user how much of the fleet is still pending.
|
||||
|
||||
Raises ProviderNotConfigured / WarrantyLookupError from the provider; the
|
||||
caller decides how to surface those.
|
||||
"""
|
||||
provider = get_provider('dell')
|
||||
by_tag = collect_pending_tags(recheck_all=recheck_all)
|
||||
pending = len(by_tag)
|
||||
|
||||
if not by_tag:
|
||||
return {'candidates': 0, 'tags': 0, 'matched': 0, 'created': 0,
|
||||
'updated': 0, 'remaining': 0}
|
||||
|
||||
tags = list(by_tag.keys())
|
||||
if limit is not None and limit < len(tags):
|
||||
# Shuffle before truncating. A serial Dell does not recognize never
|
||||
# gains a warranty, so it stays pending forever - and under a stable
|
||||
# order those same unrecognized tags would occupy every chunk and the
|
||||
# sync would never reach the rest of the fleet. Random selection makes
|
||||
# progress regardless of how many dead serials are in the set.
|
||||
tags = random.sample(tags, limit)
|
||||
by_tag = {tag: by_tag[tag] for tag in tags}
|
||||
|
||||
# by_tag is plain strings and ints, so it survives the commit. Release the
|
||||
# connection before bulk_lookup: Dell is called 100 tags at a time and a
|
||||
# fleet-sized sync would otherwise pin a pooled connection for minutes.
|
||||
db.session.commit()
|
||||
|
||||
results = provider.bulk_lookup(tags)
|
||||
|
||||
# Preload every warranty already linked to a candidate asset in one query.
|
||||
# Done per asset this was a query per asset plus one per link, which would
|
||||
# just trade the long network hold for a long write transaction.
|
||||
matched_assetids = [assetid
|
||||
for tag, assetids in by_tag.items() if results.get(tag)
|
||||
for assetid in assetids]
|
||||
existing_by_assetid = {}
|
||||
if matched_assetids:
|
||||
rows = (db.session.query(WarrantyAsset.assetid, Warranty)
|
||||
.join(Warranty, Warranty.warrantyid == WarrantyAsset.warrantyid)
|
||||
.filter(WarrantyAsset.assetid.in_(matched_assetids))
|
||||
.all())
|
||||
for assetid, warranty in rows:
|
||||
existing_by_assetid.setdefault(assetid, []).append(warranty)
|
||||
|
||||
created = updated = matched = 0
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
for tag, assetids in by_tag.items():
|
||||
found = results.get(tag)
|
||||
if not found:
|
||||
continue
|
||||
matched += 1
|
||||
for assetid in assetids:
|
||||
# Reuse an existing Dell warranty for this asset if there is one.
|
||||
existing = None
|
||||
for candidate in existing_by_assetid.get(assetid, []):
|
||||
# Reuse a warranty that is Dell by ANY signal, not just
|
||||
# provider: a manually-added or imported Dell warranty carries
|
||||
# provider 'manual' (create default) but the same service tag or
|
||||
# a "Dell" vendor. Matching only provider=='dell' made re-check
|
||||
# duplicate every one of those instead of updating it.
|
||||
if (candidate.provider == 'dell'
|
||||
or (candidate.servicetag or '').strip().upper() == tag
|
||||
or (candidate.vendor or '').strip().lower() == 'dell'):
|
||||
existing = candidate
|
||||
break
|
||||
if existing:
|
||||
# Canonicalize to Dell so later re-checks match by provider and
|
||||
# never fall through to a duplicate.
|
||||
existing.provider = 'dell'
|
||||
existing.vendor = existing.vendor or 'Dell'
|
||||
existing.servicelevel = found.get('servicelevel')
|
||||
existing.startdate = _vendor_date(found.get('startdate'))
|
||||
existing.enddate = _vendor_date(found.get('enddate'))
|
||||
existing.servicetag = tag
|
||||
existing.lastcheckeddate = now
|
||||
updated += 1
|
||||
else:
|
||||
warranty = Warranty(
|
||||
vendor='Dell', provider='dell', servicetag=tag,
|
||||
servicelevel=found.get('servicelevel'),
|
||||
startdate=_vendor_date(found.get('startdate')),
|
||||
enddate=_vendor_date(found.get('enddate')),
|
||||
lastcheckeddate=now,
|
||||
)
|
||||
warranty.links.append(WarrantyAsset(assetid=assetid))
|
||||
db.session.add(warranty)
|
||||
created += 1
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'candidates': sum(len(v) for v in by_tag.values()),
|
||||
'tags': len(by_tag),
|
||||
'matched': matched,
|
||||
'created': created,
|
||||
'updated': updated,
|
||||
'remaining': max(pending - len(by_tag), 0),
|
||||
}
|
||||
Reference in New Issue
Block a user