sammy786 commited on
Commit
1e4417d
·
1 Parent(s): 5bd15d3

Fix exact hotel deal ranking and cache concurrency

Browse files
Files changed (3) hide show
  1. app/flight_sellers.py +1 -1
  2. app/hotel_sellers.py +74 -12
  3. app/main.py +6 -5
app/flight_sellers.py CHANGED
@@ -14,7 +14,7 @@ list Google shows under a fare (`booking_options`), each with the seller name,
14
  its own price and a booking request URL.
15
 
16
  Cost discipline: a search and a seller lookup are billed separately. The app
17
- checks result rows through a bounded two-request queue, while this module caches
18
  each seller table by booking token for SELLERS_TTL_S. A repeated row/search
19
  therefore reuses its exact quote rather than spending another upstream call.
20
 
 
14
  its own price and a booking request URL.
15
 
16
  Cost discipline: a search and a seller lookup are billed separately. The app
17
+ checks result rows through a bounded request pool, while this module caches
18
  each seller table by booking token for SELLERS_TTL_S. A repeated row/search
19
  therefore reuses its exact quote rather than spending another upstream call.
20
 
app/hotel_sellers.py CHANGED
@@ -34,10 +34,18 @@ import hashlib
34
  import json
35
  import os
36
  import re
 
 
37
  import time
 
38
  from datetime import datetime
39
  from typing import Dict, List, Optional, Tuple
40
 
 
 
 
 
 
41
  SERPAPI_URL = "https://serpapi.com/search"
42
 
43
  SELLERS_TTL_S = int(os.getenv("HOTEL_SELLERS_TTL_S", "3600")) # 1 hour
@@ -50,8 +58,8 @@ SELLERS_CACHE = os.getenv("HOTEL_SELLERS_CACHE", "/tmp/rp_hotel_sellers_cache.js
50
  # "MakeMyTrip.com" unmapped, because the entry had been derived by the old code.
51
  # The key now carries this version, so changing the logic changes the key and
52
  # stale derivations can never be served as current.
53
- CACHE_VERSION = "2"
54
- HTTP_TIMEOUT_S = float(os.getenv("HOTEL_SELLERS_TIMEOUT_S", "30"))
55
 
56
  def _norm(name: str) -> str:
57
  return re.sub(r"[^a-z0-9]", "", (name or "").lower())
@@ -100,6 +108,32 @@ def is_live() -> bool:
100
  return bool(os.getenv("SERPAPI_KEY")) and os.getenv("HOTEL_SELLERS_MOCK") != "1"
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  def _cache_load() -> Dict:
104
  try:
105
  with open(SELLERS_CACHE, "r", encoding="utf-8") as fh:
@@ -109,13 +143,40 @@ def _cache_load() -> Dict:
109
 
110
 
111
  def _cache_save(data: Dict) -> None:
 
112
  try:
113
  now = time.time()
114
  pruned = {k: v for k, v in data.items() if now - v.get("at", 0) < SELLERS_TTL_S}
115
- with open(SELLERS_CACHE, "w", encoding="utf-8") as fh:
 
 
116
  json.dump(pruned, fh)
 
 
 
 
117
  except Exception:
118
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
 
121
  def _params(city: str, checkin: str, checkout: str, adults: int) -> Dict:
@@ -233,8 +294,8 @@ def search_hotels(
233
  out: List[Dict] = []
234
  nights = _nights(checkin, checkout)
235
  for p in (data.get("properties") or []):
236
- total = _amount(p.get("total_rate"))
237
- night = _amount(p.get("rate_per_night"))
238
  stay, derived = _stay_total(total, night, nights)
239
  if stay is None:
240
  continue # neither a total nor enough to derive one
@@ -244,6 +305,7 @@ def search_hotels(
244
  # A per-night rate is multiplied out, never substituted.
245
  "price_inr": stay,
246
  "total_derived": derived,
 
247
  "nightly_inr": night,
248
  "currency": "INR",
249
  "name": p.get("name") or "",
@@ -257,9 +319,11 @@ def search_hotels(
257
  "vendors": [],
258
  "book_url": p.get("link"),
259
  })
260
- if len(out) >= max_results:
261
- break
262
- return out, "serpapi" if out else "serpapi (no properties)"
 
 
263
 
264
 
265
  def sellers_for(
@@ -282,8 +346,7 @@ def sellers_for(
282
  ck = hashlib.sha1(
283
  f"v{CACHE_VERSION}|{property_token}|{checkin}|{checkout}|{adults}".encode()
284
  ).hexdigest()[:20]
285
- cache = _cache_load()
286
- hit = cache.get(ck)
287
  if hit and time.time() - hit.get("at", 0) < SELLERS_TTL_S:
288
  return dict(hit["payload"], source=hit["payload"].get("source", "serpapi") + " (cached)")
289
 
@@ -346,6 +409,5 @@ def sellers_for(
346
  default=min((r["price_inr"] for r in rows), default=None),
347
  ),
348
  }
349
- cache[ck] = {"at": time.time(), "payload": payload}
350
- _cache_save(cache)
351
  return payload
 
34
  import json
35
  import os
36
  import re
37
+ import tempfile
38
+ import threading
39
  import time
40
+ from contextlib import contextmanager
41
  from datetime import datetime
42
  from typing import Dict, List, Optional, Tuple
43
 
44
+ try:
45
+ import fcntl
46
+ except ImportError: # pragma: no cover - production and supported dev are Unix
47
+ fcntl = None
48
+
49
  SERPAPI_URL = "https://serpapi.com/search"
50
 
51
  SELLERS_TTL_S = int(os.getenv("HOTEL_SELLERS_TTL_S", "3600")) # 1 hour
 
58
  # "MakeMyTrip.com" unmapped, because the entry had been derived by the old code.
59
  # The key now carries this version, so changing the logic changes the key and
60
  # stale derivations can never be served as current.
61
+ CACHE_VERSION = "3" # v3: base rows carry tax basis; cache writes are serialized
62
+ HTTP_TIMEOUT_S = float(os.getenv("HOTEL_SELLERS_TIMEOUT_S", "45"))
63
 
64
  def _norm(name: str) -> str:
65
  return re.sub(r"[^a-z0-9]", "", (name or "").lower())
 
108
  return bool(os.getenv("SERPAPI_KEY")) and os.getenv("HOTEL_SELLERS_MOCK") != "1"
109
 
110
 
111
+ _CACHE_THREAD_LOCK = threading.Lock()
112
+ _CACHE_LOCK_FILE = f"{SELLERS_CACHE}.lock"
113
+
114
+
115
+ @contextmanager
116
+ def _cache_guard():
117
+ """Serialize JSON read/merge/write across threads and uvicorn workers."""
118
+ with _CACHE_THREAD_LOCK:
119
+ lock_fh = None
120
+ try:
121
+ lock_fh = open(_CACHE_LOCK_FILE, "a+", encoding="utf-8")
122
+ if fcntl is not None:
123
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
124
+ except OSError:
125
+ if lock_fh is not None:
126
+ lock_fh.close()
127
+ lock_fh = None
128
+ try:
129
+ yield
130
+ finally:
131
+ if lock_fh is not None:
132
+ if fcntl is not None:
133
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
134
+ lock_fh.close()
135
+
136
+
137
  def _cache_load() -> Dict:
138
  try:
139
  with open(SELLERS_CACHE, "r", encoding="utf-8") as fh:
 
143
 
144
 
145
  def _cache_save(data: Dict) -> None:
146
+ temp_path = None
147
  try:
148
  now = time.time()
149
  pruned = {k: v for k, v in data.items() if now - v.get("at", 0) < SELLERS_TTL_S}
150
+ cache_dir = os.path.dirname(SELLERS_CACHE) or "."
151
+ fd, temp_path = tempfile.mkstemp(prefix=".rp-hotel-cache-", dir=cache_dir)
152
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
153
  json.dump(pruned, fh)
154
+ fh.flush()
155
+ os.fsync(fh.fileno())
156
+ os.replace(temp_path, SELLERS_CACHE)
157
+ temp_path = None
158
  except Exception:
159
  pass
160
+ finally:
161
+ if temp_path:
162
+ try:
163
+ os.unlink(temp_path)
164
+ except OSError:
165
+ pass
166
+
167
+
168
+ def _cache_get(key: str) -> Optional[Dict]:
169
+ with _cache_guard():
170
+ return _cache_load().get(key)
171
+
172
+
173
+ def _cache_put(key: str, payload: Dict) -> None:
174
+ # Merge into the latest image while holding the cross-worker lock. Without
175
+ # this, simultaneous whole-list quote calls overwrite one another's token.
176
+ with _cache_guard():
177
+ cache = _cache_load()
178
+ cache[key] = {"at": time.time(), "payload": payload}
179
+ _cache_save(cache)
180
 
181
 
182
  def _params(city: str, checkin: str, checkout: str, adults: int) -> Dict:
 
294
  out: List[Dict] = []
295
  nights = _nights(checkin, checkout)
296
  for p in (data.get("properties") or []):
297
+ total, total_taxed = _amount_taxed(p.get("total_rate"))
298
+ night, night_taxed = _amount_taxed(p.get("rate_per_night"))
299
  stay, derived = _stay_total(total, night, nights)
300
  if stay is None:
301
  continue # neither a total nor enough to derive one
 
305
  # A per-night rate is multiplied out, never substituted.
306
  "price_inr": stay,
307
  "total_derived": derived,
308
+ "taxes_included": total_taxed if total is not None else night_taxed,
309
  "nightly_inr": night,
310
  "currency": "INR",
311
  "name": p.get("name") or "",
 
319
  "vendors": [],
320
  "book_url": p.get("link"),
321
  })
322
+ # Google orders properties by its relevance blend. RewardPilot's base list
323
+ # promises cheapest-stay-first, so sort explicitly before applying the scan
324
+ # cap; truncating first can discard a cheaper property later in the page.
325
+ out.sort(key=lambda h: float(h["price_inr"]))
326
+ return out[:max_results], "serpapi" if out else "serpapi (no properties)"
327
 
328
 
329
  def sellers_for(
 
346
  ck = hashlib.sha1(
347
  f"v{CACHE_VERSION}|{property_token}|{checkin}|{checkout}|{adults}".encode()
348
  ).hexdigest()[:20]
349
+ hit = _cache_get(ck)
 
350
  if hit and time.time() - hit.get("at", 0) < SELLERS_TTL_S:
351
  return dict(hit["payload"], source=hit["payload"].get("source", "serpapi") + " (cached)")
352
 
 
409
  default=min((r["price_inr"] for r in rows), default=None),
410
  ),
411
  }
412
+ _cache_put(ck, payload)
 
413
  return payload
app/main.py CHANGED
@@ -631,12 +631,12 @@ def flights_sellers(req: FlightSellersRequest, request: Request = None):
631
  platform with the loudest discount.
632
 
633
  Billed per call upstream. The client checks a result list through a bounded
634
- two-request queue and reuses this endpoint's token cache, so base fares stay
635
  interactive while exact post-offer ranking is assembled.
636
  """
637
  if request is not None:
638
  # One round-trip search can contain 20 outbound + 20 return rows. The
639
- # client never runs more than two concurrently, but cache hits can finish
640
  # quickly; allow one complete scan without self-rate-limiting it.
641
  _limit(request, "fls", rate=60) # 60/min per IP per worker
642
  import flight_sellers
@@ -798,11 +798,12 @@ def hotels_sellers(req: HotelSellersRequest, request: Request = None):
798
  hotel itself sold at 11,025. No card reward closes a gap like that, which is
799
  why the platform has to be priced and not just the card.
800
 
801
- Billed per call upstream, so the client fetches this for the property the
802
- user picked, never for a whole result list.
 
803
  """
804
  if request is not None:
805
- _limit(request, "hts", rate=20)
806
  import hotel_sellers
807
  from channels import compare_channels, hotel_brand_key
808
  from availability import normalize_city
 
631
  platform with the loudest discount.
632
 
633
  Billed per call upstream. The client checks a result list through a bounded
634
+ request pool and reuses this endpoint's token cache, so base fares stay
635
  interactive while exact post-offer ranking is assembled.
636
  """
637
  if request is not None:
638
  # One round-trip search can contain 20 outbound + 20 return rows. The
639
+ # client bounds concurrency, but cache hits can finish
640
  # quickly; allow one complete scan without self-rate-limiting it.
641
  _limit(request, "fls", rate=60) # 60/min per IP per worker
642
  import flight_sellers
 
798
  hotel itself sold at 11,025. No card reward closes a gap like that, which is
799
  why the platform has to be priced and not just the card.
800
 
801
+ Billed per call upstream. The client checks its capped result list through a
802
+ bounded queue and this endpoint caches by property/dates, so post-offer
803
+ ranking never borrows one property's rate table for another.
804
  """
805
  if request is not None:
806
+ _limit(request, "hts", rate=60) # one capped whole-list scan + fast retry/min
807
  import hotel_sellers
808
  from channels import compare_channels, hotel_brand_key
809
  from availability import normalize_city