codelion commited on
Commit
4461572
·
verified ·
1 Parent(s): a866fc9

Reload stored events every 15 minutes (and after boot) so stats and rankings never lose data

Browse files
Files changed (3) hide show
  1. app/main.py +22 -6
  2. app/sink.py +23 -4
  3. tests/test_sink.py +15 -0
app/main.py CHANGED
@@ -39,6 +39,7 @@ MAX_BODY = 64 * 1024
39
  FRAME_ANCESTORS = os.environ.get("EXPLORER_FRAME_ANCESTORS", "'self' https://huggingface.co https://*.hf.space")
40
  DETAIL_TOP_N = int(os.environ.get("EXPLORER_DETAIL_TOP_N", "24"))
41
  PREWARM_N = int(os.environ.get("EXPLORER_PREWARM", "300"))
 
42
 
43
 
44
  class RecommendRequest(BaseModel):
@@ -67,7 +68,7 @@ class State:
67
  self.engine = HeuristicEngine()
68
  self.limiter = RateLimiter(rate_per_minute=float(os.environ.get("EXPLORER_RATE_PER_MIN", "120")), burst=60)
69
  self.existing_rows: list[dict] = []
70
- self.stats = StatsCache(lambda: self.existing_rows + self.sink.all_rows)
71
  self.fetch_live = fetch_live
72
  self.collection_enabled = os.environ.get("EXPLORER_SINK", "local") != "off"
73
 
@@ -98,11 +99,26 @@ class State:
98
  self.catalogue.start_background_refresh()
99
  self.sink.start()
100
 
101
- def load_existing():
102
- self.existing_rows = self.sink.read_existing()
103
- self.stats.invalidate()
104
-
105
- threading.Thread(target=load_existing, daemon=True, name="load-existing").start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
 
107
 
108
  def create_app(state: State | None = None) -> FastAPI:
 
39
  FRAME_ANCESTORS = os.environ.get("EXPLORER_FRAME_ANCESTORS", "'self' https://huggingface.co https://*.hf.space")
40
  DETAIL_TOP_N = int(os.environ.get("EXPLORER_DETAIL_TOP_N", "24"))
41
  PREWARM_N = int(os.environ.get("EXPLORER_PREWARM", "300"))
42
+ RELOAD_SECONDS = float(os.environ.get("EXPLORER_RELOAD_SECONDS", "900"))
43
 
44
 
45
  class RecommendRequest(BaseModel):
 
68
  self.engine = HeuristicEngine()
69
  self.limiter = RateLimiter(rate_per_minute=float(os.environ.get("EXPLORER_RATE_PER_MIN", "120")), burst=60)
70
  self.existing_rows: list[dict] = []
71
+ self.stats = StatsCache(lambda: self.existing_rows + self.sink.live_rows())
72
  self.fetch_live = fetch_live
73
  self.collection_enabled = os.environ.get("EXPLORER_SINK", "local") != "off"
74
 
 
99
  self.catalogue.start_background_refresh()
100
  self.sink.start()
101
 
102
+ def reload_loop():
103
+ # Load at boot, again shortly after (a restart can race the previous container's
104
+ # final flush), then regularly so shards written elsewhere or by compaction appear.
105
+ for delay in (0, 90):
106
+ time.sleep(delay)
107
+ self.reload_existing()
108
+ while True:
109
+ time.sleep(RELOAD_SECONDS)
110
+ self.reload_existing()
111
+
112
+ threading.Thread(target=reload_loop, daemon=True, name="reload-existing").start()
113
+
114
+ def reload_existing(self):
115
+ started = time.time()
116
+ rows = self.sink.read_existing()
117
+ if rows is None:
118
+ return
119
+ self.existing_rows = rows
120
+ self.sink.forget_flushed_before(started)
121
+ self.stats.invalidate()
122
 
123
 
124
  def create_app(state: State | None = None) -> FastAPI:
app/sink.py CHANGED
@@ -66,7 +66,9 @@ class EventSink:
66
  self.last_flush_at: float | None = None
67
  self.last_error: str | None = None
68
  self.consecutive_failures = 0
69
- self.all_rows: list[dict] = [] # everything accepted this process (for live stats)
 
 
70
  spool_dir.mkdir(parents=True, exist_ok=True)
71
  self._recover_spool()
72
 
@@ -90,9 +92,9 @@ class EventSink:
90
  self._buf = self._buf[overflow:]
91
  self.dropped += overflow
92
  self._buf.extend(rows)
93
- self.all_rows.extend(rows)
94
- if len(self.all_rows) > MAX_BUFFER:
95
- self.all_rows = self.all_rows[-MAX_BUFFER:]
96
  try:
97
  with open(self._spool, "a") as f:
98
  for r in rows:
@@ -125,6 +127,10 @@ class EventSink:
125
  with self._lock:
126
  self._buf = self._buf[len(rows):]
127
  self._rewrite_spool()
 
 
 
 
128
  self.written_rows += len(rows)
129
  self.written_shards += 1
130
  self.last_flush_at = time.time()
@@ -142,6 +148,19 @@ class EventSink:
142
  except OSError:
143
  log.warning("spool rewrite failed", exc_info=True)
144
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  def _write_shard(self, path: str, data: bytes) -> None:
146
  raise NotImplementedError
147
 
 
66
  self.last_flush_at: float | None = None
67
  self.last_error: str | None = None
68
  self.consecutive_failures = 0
69
+ # Rows accepted by this process that a reload of the stored dataset may not include yet:
70
+ # [row, flushed_at]. Stats read stored rows + these, so nothing is missed or double-counted.
71
+ self._recent: list[list] = []
72
  spool_dir.mkdir(parents=True, exist_ok=True)
73
  self._recover_spool()
74
 
 
92
  self._buf = self._buf[overflow:]
93
  self.dropped += overflow
94
  self._buf.extend(rows)
95
+ self._recent.extend([r, None] for r in rows)
96
+ if len(self._recent) > MAX_BUFFER:
97
+ self._recent = self._recent[-MAX_BUFFER:]
98
  try:
99
  with open(self._spool, "a") as f:
100
  for r in rows:
 
127
  with self._lock:
128
  self._buf = self._buf[len(rows):]
129
  self._rewrite_spool()
130
+ written, now = {id(r) for r in rows}, time.time()
131
+ for entry in self._recent:
132
+ if entry[1] is None and id(entry[0]) in written:
133
+ entry[1] = now
134
  self.written_rows += len(rows)
135
  self.written_shards += 1
136
  self.last_flush_at = time.time()
 
148
  except OSError:
149
  log.warning("spool rewrite failed", exc_info=True)
150
 
151
+ def live_rows(self) -> list[dict]:
152
+ with self._lock:
153
+ return [r for r, _ in self._recent]
154
+
155
+ def forget_flushed_before(self, t: float) -> None:
156
+ """Drop rows that were in a written shard before a reload started (the reload has them)."""
157
+ with self._lock:
158
+ self._recent = [e for e in self._recent if e[1] is None or e[1] >= t]
159
+
160
+ @property
161
+ def all_rows(self) -> list[dict]:
162
+ return self.live_rows()
163
+
164
  def _write_shard(self, path: str, data: bytes) -> None:
165
  raise NotImplementedError
166
 
tests/test_sink.py CHANGED
@@ -93,3 +93,18 @@ def test_reads_monthly_and_incoming(tmp_path):
93
  s.add(rows(2))
94
  s.flush()
95
  assert len(s.read_existing()) == 6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  s.add(rows(2))
94
  s.flush()
95
  assert len(s.read_existing()) == 6
96
+
97
+
98
+ def test_reload_neither_misses_nor_double_counts(tmp_path):
99
+ import time
100
+ s = LocalParquetSink(tmp_path, flush_seconds=999)
101
+ s.add(rows(3))
102
+ stored = s.read_existing()
103
+ assert len(stored) + len(s.live_rows()) == 3 # accepted, not yet written
104
+ s.flush()
105
+ assert len(s.read_existing()) + len(s.live_rows()) == 6 # written but not yet reloaded: still visible (overlap is fine before a reload)
106
+ started = time.time()
107
+ stored = s.read_existing()
108
+ s.forget_flushed_before(started)
109
+ s.add(rows(2))
110
+ assert len(stored) + len(s.live_rows()) == 5 # after reload: 3 stored + 2 new, no duplicates