Spaces:
Running
Running
main - Replace log panel with 5-min health stats and anomaly alerts
Browse filesRaw log tailing on the monitoring page duplicated HF's own log viewer and
wrote every log line to disk per worker for no real benefit. Removed that
entirely (_FileLogHandler, _trim_log_files, get_log_lines, /logs) and
replaced it with per-worker in-process instrumentation: a rolling 5-minute
window of per-image latency/success and cache evictions, aggregated into
throughput/error-rate/latency health stats and four anomaly alerts (worker
not reporting, RSS spike past the cache warm-up grace period, cache
thrashing, error-rate spike). All aggregation stays local per worker and
piggybacks on the existing 5s stats-reporter tick, so it adds negligible
overhead. Also dropped the dead `Model` import and unused `os` import in
main.py.
- main.py +37 -51
- similarity.py +140 -64
main.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
import os
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
from typing import List
|
| 4 |
|
|
@@ -7,7 +6,7 @@ from fastapi.responses import HTMLResponse
|
|
| 7 |
|
| 8 |
from models import RequestModel, ResponseModel
|
| 9 |
from similarity import (
|
| 10 |
-
check_similarity,
|
| 11 |
start_memory_stats_reporter, start_memory_trimmer,
|
| 12 |
)
|
| 13 |
|
|
@@ -54,19 +53,19 @@ h1{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-style:italic;
|
|
| 54 |
.req{font-size:.75rem;color:#94a3b8;font-family:'SF Mono','Fira Code',monospace}
|
| 55 |
.track{height:5px;background:#1e2942;border-radius:99px;margin-top:.45rem;overflow:hidden}
|
| 56 |
.fill{height:100%;border-radius:99px;opacity:.7}
|
| 57 |
-
.
|
| 58 |
display:flex;flex-direction:column;flex:1;min-height:0}
|
| 59 |
-
.
|
| 60 |
text-transform:uppercase;letter-spacing:.08em;margin-bottom:.4rem;
|
| 61 |
display:flex;align-items:center;gap:.5rem;flex-shrink:0}
|
| 62 |
-
.
|
| 63 |
-
|
| 64 |
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
| 65 |
-
.
|
| 66 |
-
flex:1;min-height:
|
| 67 |
-
font-family:
|
| 68 |
-
|
| 69 |
-
.
|
| 70 |
</style>
|
| 71 |
</head><body>
|
| 72 |
<header>
|
|
@@ -74,23 +73,41 @@ h1{font-family:'Barlow Condensed',sans-serif;font-weight:800;font-style:italic;
|
|
| 74 |
<span id="meta" class="meta"></span>
|
| 75 |
</header>
|
| 76 |
<div id="grid" class="grid"></div>
|
| 77 |
-
<div class="
|
| 78 |
-
<div class="
|
| 79 |
-
<div id="
|
| 80 |
</div>
|
| 81 |
<script>
|
| 82 |
var MAX=__CACHE_MAX__;
|
| 83 |
function col(pct){return pct<50?'#4ade80':pct<80?'#fbbf24':'#f87171'}
|
| 84 |
function workerCol(mb){return mb<800?'#4ade80':mb<1000?'#fbbf24':'#f87171'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
function render(d){
|
| 86 |
-
var w=d.workers,m=d.machine;
|
| 87 |
var gb=(w.reduce(function(s,x){return s+x.rss_mb},0)/1024).toFixed(1);
|
| 88 |
var rc=col(m.ram_percent),cc=col(m.cpu_percent);
|
|
|
|
| 89 |
document.getElementById('meta').innerHTML=
|
| 90 |
'<b>'+w.length+'</b> workers · '+
|
| 91 |
'<b>'+gb+' GB</b> RSS · '+
|
| 92 |
'RAM <b style="color:'+rc+'">'+m.ram_used_gb+'/'+m.ram_total_gb+' GB</b> · '+
|
| 93 |
'CPU <b style="color:'+cc+'">'+m.cpu_percent+'%</b> · '+
|
|
|
|
|
|
|
|
|
|
| 94 |
new Date().toTimeString().slice(0,8);
|
| 95 |
var maxRss=Math.max.apply(null,w.map(function(x){return x.rss_mb}).concat([1]));
|
| 96 |
document.getElementById('grid').innerHTML=w.map(function(x){
|
|
@@ -102,59 +119,28 @@ function render(d){
|
|
| 102 |
'<div class="track"><div class="fill" style="width:'+p+'%;background:'+c+'"></div></div>'+
|
| 103 |
'</div>';
|
| 104 |
}).join('');
|
|
|
|
| 105 |
}
|
| 106 |
function refresh(){
|
| 107 |
var xhr=new XMLHttpRequest();
|
| 108 |
xhr.open('GET','/stats');
|
| 109 |
xhr.onload=function(){
|
| 110 |
if(xhr.status===200){
|
| 111 |
-
try{var d=JSON.parse(xhr.responseText);if(d&&d.workers&&d.machine)render(d);}catch(e){}
|
| 112 |
}
|
| 113 |
};
|
| 114 |
xhr.send();
|
| 115 |
}
|
| 116 |
refresh();setInterval(refresh,5000);
|
| 117 |
-
|
| 118 |
-
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
| 119 |
-
function lineCol(s){
|
| 120 |
-
if(/error|exception|traceback|critical/i.test(s)) return '#d03b3b';
|
| 121 |
-
if(/warn/i.test(s)) return '#fab219';
|
| 122 |
-
return '#f4f4f2';
|
| 123 |
-
}
|
| 124 |
-
function refreshLogs(){
|
| 125 |
-
var xhr=new XMLHttpRequest();
|
| 126 |
-
xhr.open('GET','/logs?n=400');
|
| 127 |
-
xhr.onload=function(){
|
| 128 |
-
if(xhr.status===200){
|
| 129 |
-
try{
|
| 130 |
-
var d=JSON.parse(xhr.responseText);
|
| 131 |
-
if(d&&d.lines&&d.lines.length){
|
| 132 |
-
var el=document.getElementById('logs');
|
| 133 |
-
var atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<60;
|
| 134 |
-
var lines=d.lines.slice().reverse();
|
| 135 |
-
el.innerHTML=lines.map(function(l){
|
| 136 |
-
return '<div class="log-line" style="color:'+lineCol(l)+'">'+esc(l)+'</div>';
|
| 137 |
-
}).join('');
|
| 138 |
-
if(atBottom) el.scrollTop=el.scrollHeight;
|
| 139 |
-
}
|
| 140 |
-
}catch(e){}
|
| 141 |
-
}
|
| 142 |
-
};
|
| 143 |
-
xhr.send();
|
| 144 |
-
}
|
| 145 |
-
refreshLogs();setInterval(refreshLogs,2000);
|
| 146 |
</script>
|
| 147 |
</body></html>""").replace("__CACHE_MAX__", str(_CACHE_MAX))
|
| 148 |
|
| 149 |
|
| 150 |
@app.get("/stats", include_in_schema=False)
|
| 151 |
async def stats_endpoint():
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
@app.get("/logs", include_in_schema=False)
|
| 156 |
-
async def logs_endpoint(n: int = 100):
|
| 157 |
-
return {"lines": get_log_lines(n=min(n, 1000))}
|
| 158 |
|
| 159 |
|
| 160 |
@app.get("/", include_in_schema=False)
|
|
|
|
|
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
from typing import List
|
| 3 |
|
|
|
|
| 6 |
|
| 7 |
from models import RequestModel, ResponseModel
|
| 8 |
from similarity import (
|
| 9 |
+
check_similarity, get_dashboard_data, get_machine_stats,
|
| 10 |
start_memory_stats_reporter, start_memory_trimmer,
|
| 11 |
)
|
| 12 |
|
|
|
|
| 53 |
.req{font-size:.75rem;color:#94a3b8;font-family:'SF Mono','Fira Code',monospace}
|
| 54 |
.track{height:5px;background:#1e2942;border-radius:99px;margin-top:.45rem;overflow:hidden}
|
| 55 |
.fill{height:100%;border-radius:99px;opacity:.7}
|
| 56 |
+
.alerts-section{margin-top:.875rem;border-top:1px solid #1a1a2e;padding-top:.75rem;
|
| 57 |
display:flex;flex-direction:column;flex:1;min-height:0}
|
| 58 |
+
.alerts-label{font-size:.65rem;color:#475569;font-family:'SF Mono','Fira Code',monospace;
|
| 59 |
text-transform:uppercase;letter-spacing:.08em;margin-bottom:.4rem;
|
| 60 |
display:flex;align-items:center;gap:.5rem;flex-shrink:0}
|
| 61 |
+
.alerts-dot{width:6px;height:6px;border-radius:50%;background:#4ade80;box-shadow:0 0 6px #4ade80}
|
| 62 |
+
.alerts-dot.warn{background:#fab219;box-shadow:0 0 6px #fab219;animation:pulse 2s infinite}
|
| 63 |
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
| 64 |
+
.alerts-box{background:#111827;border:1px solid #1e2942;border-radius:6px;
|
| 65 |
+
flex:1;min-height:180px;overflow-y:auto;padding:.75rem .875rem;
|
| 66 |
+
font-family:'Inter',system-ui,sans-serif;font-size:.82rem;line-height:2}
|
| 67 |
+
.alert-line{display:flex;align-items:baseline;gap:.55rem;color:#f4f4f2}
|
| 68 |
+
.alert-empty{color:#475569;font-family:'SF Mono','Fira Code',monospace;font-size:.75rem}
|
| 69 |
</style>
|
| 70 |
</head><body>
|
| 71 |
<header>
|
|
|
|
| 73 |
<span id="meta" class="meta"></span>
|
| 74 |
</header>
|
| 75 |
<div id="grid" class="grid"></div>
|
| 76 |
+
<div class="alerts-section">
|
| 77 |
+
<div class="alerts-label"><span id="alerts-dot" class="alerts-dot"></span>Alertas</div>
|
| 78 |
+
<div id="alerts" class="alerts-box"></div>
|
| 79 |
</div>
|
| 80 |
<script>
|
| 81 |
var MAX=__CACHE_MAX__;
|
| 82 |
function col(pct){return pct<50?'#4ade80':pct<80?'#fbbf24':'#f87171'}
|
| 83 |
function workerCol(mb){return mb<800?'#4ade80':mb<1000?'#fbbf24':'#f87171'}
|
| 84 |
+
function esc(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
| 85 |
+
function alertColor(s){return /sem reportar|taxa de erro/i.test(s)?'#d03b3b':'#fab219'}
|
| 86 |
+
function renderAlerts(alerts){
|
| 87 |
+
var box=document.getElementById('alerts'),dot=document.getElementById('alerts-dot');
|
| 88 |
+
if(!alerts||!alerts.length){
|
| 89 |
+
box.innerHTML='<div class="alert-empty">sem alertas</div>';
|
| 90 |
+
dot.className='alerts-dot';
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
dot.className='alerts-dot warn';
|
| 94 |
+
box.innerHTML=alerts.map(function(a){
|
| 95 |
+
return '<div class="alert-line"><span style="color:'+alertColor(a)+'">●</span><span>'+esc(a)+'</span></div>';
|
| 96 |
+
}).join('');
|
| 97 |
+
}
|
| 98 |
function render(d){
|
| 99 |
+
var w=d.workers,m=d.machine,h=d.health;
|
| 100 |
var gb=(w.reduce(function(s,x){return s+x.rss_mb},0)/1024).toFixed(1);
|
| 101 |
var rc=col(m.ram_percent),cc=col(m.cpu_percent);
|
| 102 |
+
var ec=h.error_rate_pct>5?'#d03b3b':'#94a3b8';
|
| 103 |
document.getElementById('meta').innerHTML=
|
| 104 |
'<b>'+w.length+'</b> workers · '+
|
| 105 |
'<b>'+gb+' GB</b> RSS · '+
|
| 106 |
'RAM <b style="color:'+rc+'">'+m.ram_used_gb+'/'+m.ram_total_gb+' GB</b> · '+
|
| 107 |
'CPU <b style="color:'+cc+'">'+m.cpu_percent+'%</b> · '+
|
| 108 |
+
'<b>'+h.throughput_per_min+'</b> req/min · '+
|
| 109 |
+
'<b style="color:'+ec+'">'+h.error_rate_pct+'%</b> erros · '+
|
| 110 |
+
'<b>'+h.avg_latency_ms+'</b> ms · '+
|
| 111 |
new Date().toTimeString().slice(0,8);
|
| 112 |
var maxRss=Math.max.apply(null,w.map(function(x){return x.rss_mb}).concat([1]));
|
| 113 |
document.getElementById('grid').innerHTML=w.map(function(x){
|
|
|
|
| 119 |
'<div class="track"><div class="fill" style="width:'+p+'%;background:'+c+'"></div></div>'+
|
| 120 |
'</div>';
|
| 121 |
}).join('');
|
| 122 |
+
renderAlerts(d.alerts);
|
| 123 |
}
|
| 124 |
function refresh(){
|
| 125 |
var xhr=new XMLHttpRequest();
|
| 126 |
xhr.open('GET','/stats');
|
| 127 |
xhr.onload=function(){
|
| 128 |
if(xhr.status===200){
|
| 129 |
+
try{var d=JSON.parse(xhr.responseText);if(d&&d.workers&&d.machine&&d.health)render(d);}catch(e){}
|
| 130 |
}
|
| 131 |
};
|
| 132 |
xhr.send();
|
| 133 |
}
|
| 134 |
refresh();setInterval(refresh,5000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</script>
|
| 136 |
</body></html>""").replace("__CACHE_MAX__", str(_CACHE_MAX))
|
| 137 |
|
| 138 |
|
| 139 |
@app.get("/stats", include_in_schema=False)
|
| 140 |
async def stats_endpoint():
|
| 141 |
+
data = get_dashboard_data()
|
| 142 |
+
data["machine"] = get_machine_stats()
|
| 143 |
+
return data
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
|
| 146 |
@app.get("/", include_in_schema=False)
|
similarity.py
CHANGED
|
@@ -11,12 +11,11 @@ import psutil
|
|
| 11 |
import json
|
| 12 |
import time
|
| 13 |
import ctypes
|
| 14 |
-
from collections import OrderedDict
|
| 15 |
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
from typing import List
|
| 17 |
from tensorflow.keras.applications import MobileNetV2
|
| 18 |
from tensorflow.keras.applications.mobilenet_v2 import preprocess_input
|
| 19 |
-
from tensorflow.keras.models import Model
|
| 20 |
from tensorflow.keras.preprocessing.image import img_to_array
|
| 21 |
from sklearn.metrics.pairwise import cosine_similarity
|
| 22 |
from skimage.metrics import structural_similarity as ssim
|
|
@@ -30,58 +29,6 @@ register_heif_opener()
|
|
| 30 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 31 |
logging.getLogger("urllib3.connection").setLevel(logging.ERROR)
|
| 32 |
|
| 33 |
-
_LOG_DIR = "/tmp/similarity_logs"
|
| 34 |
-
os.makedirs(_LOG_DIR, exist_ok=True)
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
class _FileLogHandler(logging.Handler):
|
| 38 |
-
def __init__(self):
|
| 39 |
-
super().__init__(logging.INFO)
|
| 40 |
-
self.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s', datefmt='%H:%M:%S'))
|
| 41 |
-
self._file = os.path.join(_LOG_DIR, f"{os.getpid()}.log")
|
| 42 |
-
|
| 43 |
-
def emit(self, record):
|
| 44 |
-
try:
|
| 45 |
-
with open(self._file, 'a') as f:
|
| 46 |
-
f.write(self.format(record) + "\n")
|
| 47 |
-
except Exception:
|
| 48 |
-
pass
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
logging.getLogger().addHandler(_FileLogHandler())
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
def _trim_log_files(max_lines=1000):
|
| 55 |
-
try:
|
| 56 |
-
for fname in os.listdir(_LOG_DIR):
|
| 57 |
-
fpath = os.path.join(_LOG_DIR, fname)
|
| 58 |
-
try:
|
| 59 |
-
with open(fpath, 'r') as f:
|
| 60 |
-
lines = f.readlines()
|
| 61 |
-
if len(lines) > max_lines:
|
| 62 |
-
with open(fpath, 'w') as f:
|
| 63 |
-
f.writelines(lines[-max_lines:])
|
| 64 |
-
except (OSError, IOError):
|
| 65 |
-
pass
|
| 66 |
-
except OSError:
|
| 67 |
-
pass
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def get_log_lines(n=100):
|
| 71 |
-
all_lines = []
|
| 72 |
-
try:
|
| 73 |
-
for fname in os.listdir(_LOG_DIR):
|
| 74 |
-
fpath = os.path.join(_LOG_DIR, fname)
|
| 75 |
-
try:
|
| 76 |
-
with open(fpath, 'r') as f:
|
| 77 |
-
all_lines.extend(f.readlines())
|
| 78 |
-
except (OSError, IOError):
|
| 79 |
-
continue
|
| 80 |
-
except OSError:
|
| 81 |
-
return []
|
| 82 |
-
all_lines.sort()
|
| 83 |
-
return [l.rstrip() for l in all_lines[-n:]][::-1]
|
| 84 |
-
|
| 85 |
_mobilenet = None
|
| 86 |
_mobilenet_lock = threading.Lock()
|
| 87 |
|
|
@@ -93,6 +40,69 @@ _request_count = 0
|
|
| 93 |
|
| 94 |
_process = psutil.Process()
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
_cpu_cache = {"pct": 0.0, "usage": None, "ts": None}
|
| 97 |
|
| 98 |
|
|
@@ -200,15 +210,22 @@ def get_machine_stats():
|
|
| 200 |
|
| 201 |
|
| 202 |
def get_memory_stats():
|
| 203 |
-
rss_mb = _process.memory_info().rss / (1024 * 1024)
|
| 204 |
with _image_cache_lock:
|
| 205 |
cache_size = len(_image_cache)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
stats = {
|
| 207 |
"pid": os.getpid(),
|
| 208 |
-
"rss_mb":
|
| 209 |
"cache_size": cache_size,
|
| 210 |
"requests": _request_count,
|
|
|
|
|
|
|
| 211 |
"updated_at": time.time(),
|
|
|
|
| 212 |
}
|
| 213 |
with open(os.path.join(_MEM_STATS_DIR, f"{stats['pid']}.json"), "w") as f:
|
| 214 |
json.dump(stats, f)
|
|
@@ -236,21 +253,74 @@ def start_memory_trimmer(interval_seconds=300):
|
|
| 236 |
while True:
|
| 237 |
time.sleep(interval_seconds)
|
| 238 |
_trim_memory()
|
| 239 |
-
_trim_log_files()
|
| 240 |
threading.Thread(target=_trim_loop, daemon=True).start()
|
| 241 |
|
| 242 |
|
| 243 |
-
def
|
| 244 |
-
|
| 245 |
-
all_stats = []
|
| 246 |
for filename in os.listdir(_MEM_STATS_DIR):
|
|
|
|
| 247 |
try:
|
| 248 |
-
with open(
|
| 249 |
-
|
| 250 |
except (OSError, json.JSONDecodeError):
|
| 251 |
continue
|
| 252 |
-
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
def get_mobilenet():
|
| 256 |
global _mobilenet
|
|
@@ -369,6 +439,7 @@ def _cache_put(asset_code, image):
|
|
| 369 |
with _image_cache_lock:
|
| 370 |
if len(_image_cache) >= _IMAGE_CACHE_MAX:
|
| 371 |
_image_cache.popitem(last=False)
|
|
|
|
| 372 |
_image_cache[asset_code] = image
|
| 373 |
|
| 374 |
|
|
@@ -512,6 +583,8 @@ def change_similarity_from_quadrants(main_image, main_source, source_image , sou
|
|
| 512 |
|
| 513 |
def _process_image(args):
|
| 514 |
i, original_image, original_image_shape, images, quadrant = args
|
|
|
|
|
|
|
| 515 |
try:
|
| 516 |
image = load_image_url(images[i].source, images[i].assetCode, images[i].contentType)
|
| 517 |
image = cv2.resize(image, original_image_shape[::-1])
|
|
@@ -532,6 +605,9 @@ def _process_image(args):
|
|
| 532 |
similarity_score = 0
|
| 533 |
similarity_orb_score = 0
|
| 534 |
similarity_mobilenet_score = 0
|
|
|
|
|
|
|
|
|
|
| 535 |
|
| 536 |
return ResponseModel(originId=images[i].originId, sequence=images[i].sequence,
|
| 537 |
assetCode=images[i].assetCode, similarity=similarity_score, similarityOrb=similarity_orb_score, similarityMobileNet=similarity_mobilenet_score)
|
|
|
|
| 11 |
import json
|
| 12 |
import time
|
| 13 |
import ctypes
|
| 14 |
+
from collections import OrderedDict, deque
|
| 15 |
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
from typing import List
|
| 17 |
from tensorflow.keras.applications import MobileNetV2
|
| 18 |
from tensorflow.keras.applications.mobilenet_v2 import preprocess_input
|
|
|
|
| 19 |
from tensorflow.keras.preprocessing.image import img_to_array
|
| 20 |
from sklearn.metrics.pairwise import cosine_similarity
|
| 21 |
from skimage.metrics import structural_similarity as ssim
|
|
|
|
| 29 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 30 |
logging.getLogger("urllib3.connection").setLevel(logging.ERROR)
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
_mobilenet = None
|
| 33 |
_mobilenet_lock = threading.Lock()
|
| 34 |
|
|
|
|
| 40 |
|
| 41 |
_process = psutil.Process()
|
| 42 |
|
| 43 |
+
# Rolling 5-minute window of per-image-comparison events, cache evictions, and
|
| 44 |
+
# RSS samples, used to compute health stats and anomaly alerts. Bounded by both
|
| 45 |
+
# maxlen (hard cap against a traffic spike) and periodic time-based pruning.
|
| 46 |
+
_events_lock = threading.Lock()
|
| 47 |
+
_request_events = deque(maxlen=5000) # (timestamp, duration_ms, success)
|
| 48 |
+
_eviction_events = deque(maxlen=2000) # timestamp
|
| 49 |
+
_rss_history = deque(maxlen=90) # (timestamp, rss_mb), one per 5s reporter tick
|
| 50 |
+
|
| 51 |
+
_WINDOW_SECONDS = 300
|
| 52 |
+
_RSS_SPIKE_WINDOW_SECONDS = 120
|
| 53 |
+
_RSS_SPIKE_MB = 150
|
| 54 |
+
_RSS_SPIKE_MIN_UPTIME_SECONDS = 600
|
| 55 |
+
_CACHE_THRASH_EVICTIONS_PER_MIN = 10
|
| 56 |
+
_ERROR_RATE_ALERT_PCT = 5
|
| 57 |
+
_STALE_AFTER_SECONDS = 15
|
| 58 |
+
_STALE_FILE_DELETE_AFTER_SECONDS = 300
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _record_eviction():
|
| 62 |
+
with _events_lock:
|
| 63 |
+
_eviction_events.append(time.time())
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _record_request_event(duration_ms, success):
|
| 67 |
+
with _events_lock:
|
| 68 |
+
_request_events.append((time.time(), duration_ms, success))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _compute_window_metrics():
|
| 72 |
+
cutoff = time.time() - _WINDOW_SECONDS
|
| 73 |
+
with _events_lock:
|
| 74 |
+
while _request_events and _request_events[0][0] < cutoff:
|
| 75 |
+
_request_events.popleft()
|
| 76 |
+
while _eviction_events and _eviction_events[0] < cutoff:
|
| 77 |
+
_eviction_events.popleft()
|
| 78 |
+
total = len(_request_events)
|
| 79 |
+
failed = sum(1 for _, _, ok in _request_events if not ok)
|
| 80 |
+
latency_sum = sum(d for _, d, _ in _request_events)
|
| 81 |
+
evictions = len(_eviction_events)
|
| 82 |
+
|
| 83 |
+
minutes = _WINDOW_SECONDS / 60
|
| 84 |
+
return {
|
| 85 |
+
"throughput_per_min": round(total / minutes, 1),
|
| 86 |
+
"error_rate_pct": round((failed / total * 100) if total else 0.0, 1),
|
| 87 |
+
"avg_latency_ms": round((latency_sum / total) if total else 0.0, 1),
|
| 88 |
+
"evictions_per_min": round(evictions / minutes, 1),
|
| 89 |
+
"request_count_window": total,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _record_rss_sample(rss_mb):
|
| 94 |
+
now = time.time()
|
| 95 |
+
target_ts = now - _RSS_SPIKE_WINDOW_SECONDS
|
| 96 |
+
with _events_lock:
|
| 97 |
+
_rss_history.append((now, rss_mb))
|
| 98 |
+
rss_2min_ago = None
|
| 99 |
+
for ts, value in _rss_history:
|
| 100 |
+
if ts <= target_ts:
|
| 101 |
+
rss_2min_ago = value
|
| 102 |
+
else:
|
| 103 |
+
break
|
| 104 |
+
return round(rss_mb - rss_2min_ago, 1) if rss_2min_ago is not None else 0.0
|
| 105 |
+
|
| 106 |
_cpu_cache = {"pct": 0.0, "usage": None, "ts": None}
|
| 107 |
|
| 108 |
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
def get_memory_stats():
|
| 213 |
+
rss_mb = round(_process.memory_info().rss / (1024 * 1024), 1)
|
| 214 |
with _image_cache_lock:
|
| 215 |
cache_size = len(_image_cache)
|
| 216 |
+
|
| 217 |
+
rss_delta_2min_mb = _record_rss_sample(rss_mb)
|
| 218 |
+
window = _compute_window_metrics()
|
| 219 |
+
|
| 220 |
stats = {
|
| 221 |
"pid": os.getpid(),
|
| 222 |
+
"rss_mb": rss_mb,
|
| 223 |
"cache_size": cache_size,
|
| 224 |
"requests": _request_count,
|
| 225 |
+
"uptime_seconds": round(time.time() - _process.create_time(), 1),
|
| 226 |
+
"rss_delta_2min_mb": rss_delta_2min_mb,
|
| 227 |
"updated_at": time.time(),
|
| 228 |
+
**window,
|
| 229 |
}
|
| 230 |
with open(os.path.join(_MEM_STATS_DIR, f"{stats['pid']}.json"), "w") as f:
|
| 231 |
json.dump(stats, f)
|
|
|
|
| 253 |
while True:
|
| 254 |
time.sleep(interval_seconds)
|
| 255 |
_trim_memory()
|
|
|
|
| 256 |
threading.Thread(target=_trim_loop, daemon=True).start()
|
| 257 |
|
| 258 |
|
| 259 |
+
def _read_all_worker_files():
|
| 260 |
+
entries = []
|
|
|
|
| 261 |
for filename in os.listdir(_MEM_STATS_DIR):
|
| 262 |
+
fpath = os.path.join(_MEM_STATS_DIR, filename)
|
| 263 |
try:
|
| 264 |
+
with open(fpath) as f:
|
| 265 |
+
entries.append((fpath, json.load(f)))
|
| 266 |
except (OSError, json.JSONDecodeError):
|
| 267 |
continue
|
| 268 |
+
return entries
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def get_dashboard_data():
|
| 272 |
+
get_memory_stats() # refresh this worker's own file before merging
|
| 273 |
+
|
| 274 |
+
now = time.time()
|
| 275 |
+
alive, alerts = [], []
|
| 276 |
+
|
| 277 |
+
for fpath, stats in _read_all_worker_files():
|
| 278 |
+
age = now - stats.get("updated_at", 0)
|
| 279 |
+
|
| 280 |
+
if age > _STALE_FILE_DELETE_AFTER_SECONDS:
|
| 281 |
+
try:
|
| 282 |
+
os.remove(fpath)
|
| 283 |
+
except OSError:
|
| 284 |
+
pass
|
| 285 |
+
continue
|
| 286 |
+
|
| 287 |
+
if age > _STALE_AFTER_SECONDS:
|
| 288 |
+
alerts.append(f"worker {stats['pid']} sem reportar há {int(age)}s")
|
| 289 |
+
continue
|
| 290 |
+
|
| 291 |
+
alive.append(stats)
|
| 292 |
+
|
| 293 |
+
if (stats.get("uptime_seconds", 0) > _RSS_SPIKE_MIN_UPTIME_SECONDS
|
| 294 |
+
and stats.get("rss_delta_2min_mb", 0) > _RSS_SPIKE_MB):
|
| 295 |
+
alerts.append(f"worker {stats['pid']} RAM subiu {stats['rss_delta_2min_mb']:.0f} MB em 2 min")
|
| 296 |
+
|
| 297 |
+
if stats.get("evictions_per_min", 0) > _CACHE_THRASH_EVICTIONS_PER_MIN:
|
| 298 |
+
alerts.append(f"worker {stats['pid']} cache a saturar (~{stats['evictions_per_min']:.0f} evicções/min)")
|
| 299 |
+
|
| 300 |
+
alive.sort(key=lambda s: s["pid"])
|
| 301 |
+
|
| 302 |
+
total_requests = sum(s.get("request_count_window", 0) for s in alive)
|
| 303 |
+
throughput_per_min = round(sum(s.get("throughput_per_min", 0) for s in alive), 1)
|
| 304 |
+
evictions_per_min = round(sum(s.get("evictions_per_min", 0) for s in alive), 1)
|
| 305 |
+
if total_requests:
|
| 306 |
+
error_rate_pct = round(
|
| 307 |
+
sum(s.get("error_rate_pct", 0) * s.get("request_count_window", 0) for s in alive) / total_requests, 1)
|
| 308 |
+
avg_latency_ms = round(
|
| 309 |
+
sum(s.get("avg_latency_ms", 0) * s.get("request_count_window", 0) for s in alive) / total_requests, 1)
|
| 310 |
+
else:
|
| 311 |
+
error_rate_pct = 0.0
|
| 312 |
+
avg_latency_ms = 0.0
|
| 313 |
+
|
| 314 |
+
if error_rate_pct > _ERROR_RATE_ALERT_PCT:
|
| 315 |
+
alerts.append(f"taxa de erro em {error_rate_pct:.0f}% nos últimos 5 min")
|
| 316 |
+
|
| 317 |
+
health = {
|
| 318 |
+
"throughput_per_min": throughput_per_min,
|
| 319 |
+
"error_rate_pct": error_rate_pct,
|
| 320 |
+
"avg_latency_ms": avg_latency_ms,
|
| 321 |
+
"evictions_per_min": evictions_per_min,
|
| 322 |
+
}
|
| 323 |
+
return {"workers": alive, "health": health, "alerts": alerts}
|
| 324 |
|
| 325 |
def get_mobilenet():
|
| 326 |
global _mobilenet
|
|
|
|
| 439 |
with _image_cache_lock:
|
| 440 |
if len(_image_cache) >= _IMAGE_CACHE_MAX:
|
| 441 |
_image_cache.popitem(last=False)
|
| 442 |
+
_record_eviction()
|
| 443 |
_image_cache[asset_code] = image
|
| 444 |
|
| 445 |
|
|
|
|
| 583 |
|
| 584 |
def _process_image(args):
|
| 585 |
i, original_image, original_image_shape, images, quadrant = args
|
| 586 |
+
started_at = time.time()
|
| 587 |
+
success = True
|
| 588 |
try:
|
| 589 |
image = load_image_url(images[i].source, images[i].assetCode, images[i].contentType)
|
| 590 |
image = cv2.resize(image, original_image_shape[::-1])
|
|
|
|
| 605 |
similarity_score = 0
|
| 606 |
similarity_orb_score = 0
|
| 607 |
similarity_mobilenet_score = 0
|
| 608 |
+
success = False
|
| 609 |
+
|
| 610 |
+
_record_request_event((time.time() - started_at) * 1000, success)
|
| 611 |
|
| 612 |
return ResponseModel(originId=images[i].originId, sequence=images[i].sequence,
|
| 613 |
assetCode=images[i].assetCode, similarity=similarity_score, similarityOrb=similarity_orb_score, similarityMobileNet=similarity_mobilenet_score)
|