Spaces:
Running
Running
Sync from GitHub b6f1daf
Browse files
app.py
CHANGED
|
@@ -69,6 +69,65 @@ def database_path() -> Path:
|
|
| 69 |
return slim if slim.exists() else ROOT / "data" / "gold" / "gridpulse.duckdb"
|
| 70 |
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
@st.cache_data(ttl=900, show_spinner=False)
|
| 73 |
def run_query(sql: str, params: tuple = ()) -> pd.DataFrame:
|
| 74 |
path = database_path()
|
|
|
|
| 69 |
return slim if slim.exists() else ROOT / "data" / "gold" / "gridpulse.duckdb"
|
| 70 |
|
| 71 |
|
| 72 |
+
def data_version() -> str:
|
| 73 |
+
"""Identify the currently deployed artifacts by size and modification time.
|
| 74 |
+
|
| 75 |
+
Every cached function below carries its own 900 second expiry, and each of
|
| 76 |
+
those clocks starts when that function is first called rather than when the
|
| 77 |
+
data changed. The caches therefore expire at different moments, and in the
|
| 78 |
+
window between them the page can report an hourly row count from one
|
| 79 |
+
training run beside an accuracy figure from the previous one. Both numbers
|
| 80 |
+
are individually correct and the pair is wrong, which is the least useful
|
| 81 |
+
kind of error to put in front of someone.
|
| 82 |
+
|
| 83 |
+
The weekly refresh rewrites the warehouse and the headline together, so
|
| 84 |
+
comparing their size and mtime detects a new deployment directly instead of
|
| 85 |
+
waiting for a timer to guess that one happened.
|
| 86 |
+
"""
|
| 87 |
+
parts = []
|
| 88 |
+
for path in (database_path(), ROOT / "artifacts" / "headline.json"):
|
| 89 |
+
try:
|
| 90 |
+
stat = path.stat()
|
| 91 |
+
parts.append(f"{path.name}:{stat.st_size}:{stat.st_mtime_ns}")
|
| 92 |
+
except OSError:
|
| 93 |
+
parts.append(f"{path.name}:absent")
|
| 94 |
+
return "|".join(parts)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@st.cache_resource
|
| 98 |
+
def _deployed_version() -> dict[str, str | None]:
|
| 99 |
+
"""Hold the last seen data version, shared across every user session.
|
| 100 |
+
|
| 101 |
+
Deliberately `cache_resource` rather than `session_state`. Session state is
|
| 102 |
+
per visitor, so the check below would fire once for each new arrival and
|
| 103 |
+
every one of them would clear a cache that was already correct. A resource
|
| 104 |
+
is shared process-wide, so the artifacts are detected as changed exactly
|
| 105 |
+
once no matter how many people are on the page. `st.cache_data.clear()`
|
| 106 |
+
does not touch `cache_resource`, so this record survives the clearing it
|
| 107 |
+
triggers.
|
| 108 |
+
"""
|
| 109 |
+
return {"version": None}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def invalidate_caches_if_data_changed() -> None:
|
| 113 |
+
"""Expire every cache at once when the artifacts change underneath us.
|
| 114 |
+
|
| 115 |
+
Streamlit Cloud reruns the script when a new commit lands but does not
|
| 116 |
+
always restart the process, so `st.cache_data` entries can outlive the files
|
| 117 |
+
they were derived from. Clearing on a version change makes a refresh visible
|
| 118 |
+
immediately and, more importantly, keeps the numbers on the page mutually
|
| 119 |
+
consistent.
|
| 120 |
+
"""
|
| 121 |
+
record = _deployed_version()
|
| 122 |
+
current = data_version()
|
| 123 |
+
if record["version"] != current:
|
| 124 |
+
st.cache_data.clear()
|
| 125 |
+
record["version"] = current
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
invalidate_caches_if_data_changed()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
@st.cache_data(ttl=900, show_spinner=False)
|
| 132 |
def run_query(sql: str, params: tuple = ()) -> pd.DataFrame:
|
| 133 |
path = database_path()
|