fix(auctions): prevent time drift + consistent now
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
- Clamp end_time on updates to prevent drift from rounded time-left sources (e.g. Sav) - Use a single request 'now' for time_remaining formatting across endpoints - Avoid 'Ended' flicker caused by processing delays
This commit is contained in:
@ -161,11 +161,14 @@ class MarketFeedResponse(BaseModel):
|
|||||||
|
|
||||||
# ============== Helper Functions ==============
|
# ============== Helper Functions ==============
|
||||||
|
|
||||||
def _format_time_remaining(end_time: datetime) -> str:
|
def _format_time_remaining(end_time: datetime, now: Optional[datetime] = None) -> str:
|
||||||
"""Format time remaining in human-readable format."""
|
"""Format time remaining in human-readable format."""
|
||||||
delta = end_time - datetime.utcnow()
|
ref = now or datetime.utcnow()
|
||||||
|
delta = end_time - ref
|
||||||
|
|
||||||
if delta.total_seconds() <= 0:
|
# Small grace window to avoid displaying "Ended" due to request processing time.
|
||||||
|
# If an auction ends within the next ~2 seconds, we show "0m".
|
||||||
|
if delta.total_seconds() <= -2:
|
||||||
return "Ended"
|
return "Ended"
|
||||||
|
|
||||||
hours = int(delta.total_seconds() // 3600)
|
hours = int(delta.total_seconds() // 3600)
|
||||||
@ -177,7 +180,7 @@ def _format_time_remaining(end_time: datetime) -> str:
|
|||||||
elif hours > 0:
|
elif hours > 0:
|
||||||
return f"{hours}h {minutes}m"
|
return f"{hours}h {minutes}m"
|
||||||
else:
|
else:
|
||||||
return f"{minutes}m"
|
return f"{max(minutes, 0)}m"
|
||||||
|
|
||||||
|
|
||||||
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
||||||
@ -203,7 +206,8 @@ def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
|||||||
async def _convert_to_listing(
|
async def _convert_to_listing(
|
||||||
auction: DomainAuction,
|
auction: DomainAuction,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
include_valuation: bool = True
|
include_valuation: bool = True,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
) -> AuctionListing:
|
) -> AuctionListing:
|
||||||
"""Convert database auction to API response."""
|
"""Convert database auction to API response."""
|
||||||
valuation_data = None
|
valuation_data = None
|
||||||
@ -234,7 +238,7 @@ async def _convert_to_listing(
|
|||||||
currency=auction.currency,
|
currency=auction.currency,
|
||||||
num_bids=auction.num_bids,
|
num_bids=auction.num_bids,
|
||||||
end_time=auction.end_time,
|
end_time=auction.end_time,
|
||||||
time_remaining=_format_time_remaining(auction.end_time),
|
time_remaining=_format_time_remaining(auction.end_time, now=now),
|
||||||
buy_now_price=auction.buy_now_price,
|
buy_now_price=auction.buy_now_price,
|
||||||
reserve_met=auction.reserve_met,
|
reserve_met=auction.reserve_met,
|
||||||
traffic=auction.traffic,
|
traffic=auction.traffic,
|
||||||
@ -383,7 +387,7 @@ async def search_auctions(
|
|||||||
# Convert to response with valuations
|
# Convert to response with valuations
|
||||||
listings = []
|
listings = []
|
||||||
for auction in auctions:
|
for auction in auctions:
|
||||||
listing = await _convert_to_listing(auction, db, include_valuation=True)
|
listing = await _convert_to_listing(auction, db, include_valuation=True, now=now)
|
||||||
listings.append(listing)
|
listings.append(listing)
|
||||||
|
|
||||||
# Sort by value_ratio if requested (after valuation)
|
# Sort by value_ratio if requested (after valuation)
|
||||||
@ -426,7 +430,8 @@ async def get_ending_soon(
|
|||||||
|
|
||||||
Data is scraped from public auction sites - no mock data.
|
Data is scraped from public auction sites - no mock data.
|
||||||
"""
|
"""
|
||||||
cutoff = datetime.utcnow() + timedelta(hours=hours)
|
now = datetime.utcnow()
|
||||||
|
cutoff = now + timedelta(hours=hours)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(DomainAuction)
|
select(DomainAuction)
|
||||||
@ -434,7 +439,7 @@ async def get_ending_soon(
|
|||||||
and_(
|
and_(
|
||||||
DomainAuction.is_active == True,
|
DomainAuction.is_active == True,
|
||||||
DomainAuction.end_time <= cutoff,
|
DomainAuction.end_time <= cutoff,
|
||||||
DomainAuction.end_time > datetime.utcnow(),
|
DomainAuction.end_time > now,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by(DomainAuction.end_time.asc())
|
.order_by(DomainAuction.end_time.asc())
|
||||||
@ -446,7 +451,7 @@ async def get_ending_soon(
|
|||||||
|
|
||||||
listings = []
|
listings = []
|
||||||
for auction in auctions:
|
for auction in auctions:
|
||||||
listing = await _convert_to_listing(auction, db, include_valuation=True)
|
listing = await _convert_to_listing(auction, db, include_valuation=True, now=now)
|
||||||
listings.append(listing)
|
listings.append(listing)
|
||||||
|
|
||||||
return listings
|
return listings
|
||||||
@ -481,7 +486,7 @@ async def get_hot_auctions(
|
|||||||
|
|
||||||
listings = []
|
listings = []
|
||||||
for auction in auctions:
|
for auction in auctions:
|
||||||
listing = await _convert_to_listing(auction, db, include_valuation=True)
|
listing = await _convert_to_listing(auction, db, include_valuation=True, now=now)
|
||||||
listings.append(listing)
|
listings.append(listing)
|
||||||
|
|
||||||
return listings
|
return listings
|
||||||
@ -716,7 +721,7 @@ async def get_smart_opportunities(
|
|||||||
if opportunity_score < 3:
|
if opportunity_score < 3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
listing = await _convert_to_listing(auction, db, include_valuation=False)
|
listing = await _convert_to_listing(auction, db, include_valuation=False, now=now)
|
||||||
|
|
||||||
recommendation = (
|
recommendation = (
|
||||||
"🔥 Hot" if opportunity_score >= 10 else
|
"🔥 Hot" if opportunity_score >= 10 else
|
||||||
@ -1064,7 +1069,7 @@ async def get_market_feed(
|
|||||||
source=auction.platform,
|
source=auction.platform,
|
||||||
is_pounce=False,
|
is_pounce=False,
|
||||||
verified=False,
|
verified=False,
|
||||||
time_remaining=_format_time_remaining(auction.end_time),
|
time_remaining=_format_time_remaining(auction.end_time, now=now),
|
||||||
end_time=auction.end_time,
|
end_time=auction.end_time,
|
||||||
num_bids=auction.num_bids,
|
num_bids=auction.num_bids,
|
||||||
url=_get_affiliate_url(auction.platform, auction.domain, auction.auction_url),
|
url=_get_affiliate_url(auction.platform, auction.domain, auction.auction_url),
|
||||||
|
|||||||
@ -312,6 +312,18 @@ class AuctionScraperService:
|
|||||||
existing = existing.scalar_one_or_none()
|
existing = existing.scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
|
# Prevent "end_time drift" on sources that only provide rounded time-left.
|
||||||
|
# `end_time` must be monotonically decreasing (or stable) across scrapes.
|
||||||
|
try:
|
||||||
|
incoming_end = cleaned.get("end_time")
|
||||||
|
if isinstance(incoming_end, datetime) and existing.end_time:
|
||||||
|
# Allow tiny increases due to rounding/clock skew, but never extend materially.
|
||||||
|
tolerance = timedelta(minutes=2)
|
||||||
|
if incoming_end > existing.end_time + tolerance:
|
||||||
|
cleaned["end_time"] = existing.end_time
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
for key, value in cleaned.items():
|
for key, value in cleaned.items():
|
||||||
setattr(existing, key, value)
|
setattr(existing, key, value)
|
||||||
existing.updated_at = datetime.utcnow()
|
existing.updated_at = datetime.utcnow()
|
||||||
|
|||||||
Reference in New Issue
Block a user