File size: 7,439 Bytes
c042366
 
 
 
 
79fbfe4
c042366
 
3d68445
c042366
 
 
 
 
 
f7e869a
1d8b84a
 
79fbfe4
d400c5e
3d68445
c042366
 
 
 
 
 
 
79fbfe4
c042366
79fbfe4
c042366
 
 
79fbfe4
c042366
 
d400c5e
 
 
 
c042366
 
79fbfe4
c042366
 
 
c9d81e9
79fbfe4
 
c042366
 
 
 
 
 
 
 
79fbfe4
c042366
79fbfe4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d8b84a
79fbfe4
 
1d8b84a
 
79fbfe4
 
 
 
 
 
 
 
 
 
 
c042366
d400c5e
 
 
79fbfe4
c042366
 
 
 
 
 
 
79fbfe4
 
 
c042366
 
79fbfe4
d400c5e
 
 
 
 
 
 
 
 
 
79fbfe4
d400c5e
 
 
 
c042366
 
d400c5e
c042366
79fbfe4
 
 
c39e61e
 
79fbfe4
c39e61e
e5c381e
d92092f
e5c381e
62105cf
84cdc86
 
 
d400c5e
 
c042366
 
79fbfe4
d400c5e
 
 
 
c042366
 
79fbfe4
c042366
 
d400c5e
 
 
 
 
 
 
 
 
 
 
 
c042366
 
79fbfe4
c042366
 
 
79fbfe4
c042366
 
 
 
 
 
 
d400c5e
 
 
 
 
c042366
 
d400c5e
 
 
 
 
 
 
 
 
 
3d68445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c042366
 
3d68445
1d8b84a
c042366
 
 
 
3d68445
 
 
 
c042366
 
 
 
 
 
 
3d68445
c042366
 
3d68445
84cdc86
 
c042366
 
 
79fbfe4
c042366
 
3d68445
 
 
 
 
 
c042366
 
 
 
 
79fbfe4
c042366
 
 
 
3d68445
 
 
 
c39e61e
79fbfe4
 
c042366
79fbfe4
c042366
79fbfe4
c042366
79fbfe4
3d68445
c042366
79fbfe4
c042366
79fbfe4
 
 
 
c042366
 
79fbfe4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
import asyncio
import json
import os
import time
from datetime import datetime, timezone, timedelta
from typing import Any, Dict

import psycopg2
from psycopg2.extras import execute_values
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

# =========================
# CONFIG
# =========================
FLUSH_INTERVAL_SEC = 20.0
MAX_BATCH_SIZE = 200
MAX_ROWS = 100000
RESET_N = 10
MAX_QUEUE_SIZE = 50000
CLEANUP_INTERVAL_SEC = 6 * 3600  # every 6 hours instead of every 30 min

IST = timezone(timedelta(hours=5, minutes=30))

# =========================
# DATA MODEL
# =========================
class IngestMsg(BaseModel):
    ts: str
    plant_id: str = Field(..., min_length=1, max_length=50)
    payload: Dict[str, Any]


# =========================
# DB CONNECTION
# =========================
def make_conn():
    return psycopg2.connect(
        os.environ["DATABASE_URL"],
        connect_timeout=5,
    )

# =========================
# TIMESTAMP PARSE
# =========================
def parse_ts_ist(ts_str: str) -> datetime:
    try:
        dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
    except Exception:
        raise ValueError(f"Invalid ts format: {ts_str}")

    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=IST)

    return dt


# =========================
# RESET LOGIC
# =========================
def reset_last_n_old_records(cursor, conn, n=10):
    cursor.execute("""
        SELECT id FROM dbo.raw_plant_data
        ORDER BY ts DESC
        LIMIT %s
    """, (n,))
    
    rows = cursor.fetchall()
    if not rows:
        return

    now = datetime.utcnow()

    for i, (row_id,) in enumerate(rows, start=1):
        new_ts = now - timedelta(seconds=i)

        cursor.execute("""
            UPDATE dbo.raw_plant_data
            SET ts = %s
            WHERE id = %s
        """, (new_ts, row_id))

    conn.commit()
    print(f"Reset {len(rows)} old records timestamps")


# =========================
# CLEANUP LOGIC
# =========================
def cleanup_old_rows(cursor, conn):
    cursor.execute("""
        DELETE FROM dbo.raw_plant_data
        WHERE ts < NOW() - INTERVAL '3 days'
    """)
    
    deleted = cursor.rowcount
    conn.commit()

    if deleted > 0:
        print(f"Deleted {deleted} old rows")

# =========================
# APP
# =========================
app = FastAPI(title="SWaT Ingest API")

queue: asyncio.Queue[IngestMsg] = asyncio.Queue(
    maxsize=MAX_QUEUE_SIZE
)

stats = {
    "received": 0,
    "inserted": 0,
    "bad_ts": 0,
}


# =========================
# STARTUP
# =========================
@app.on_event("startup")
async def startup():
    try:
        conn = make_conn()
        cursor = conn.cursor()

        try:
            cleanup_old_rows(cursor, conn)
            print("Startup DB cleanup completed")

        finally:
            cursor.close()
            conn.close()

    except Exception as e:
        print(f"Startup DB unavailable: {e}")

    # ALWAYS start flush loop
    asyncio.create_task(flush_loop())

    print("Application startup complete")

# =========================
# ROUTES
# =========================
@app.get("/")
async def root():
    return {"status": "running"}

@app.api_route("/health", methods=["GET", "POST", "HEAD"])
async def health():
    return {"status": "ok"}
    
@app.get("/ingest")
async def ingest_health():
    return {"ok": True, "queue_size": queue.qsize()}


@app.post("/ingest")
async def ingest(msg: IngestMsg):
    if not msg.payload:
        raise HTTPException(
            status_code=400,
            detail="payload required"
        )

    try:
        parse_ts_ist(msg.ts)
    except ValueError as e:
        stats["bad_ts"] += 1
        raise HTTPException(
            status_code=400,
            detail=str(e)
        )

    try:
        queue.put_nowait(msg)
    except asyncio.QueueFull:
        raise HTTPException(
            status_code=503,
            detail="queue full"
        )

    stats["received"] += 1

    return {"ok": True}

# =========================
# FLUSH LOOP
# =========================
async def flush_loop():
    conn = None
    cursor = None

    def ensure_sql():
        nonlocal conn, cursor
    
        if conn is not None:
            return
    
        try:
            conn = make_conn()
            cursor = conn.cursor()
    
            print("Database connection established")
    
        except Exception as e:
            print(f"Database unavailable: {e}")
    
            conn = None
            cursor = None
    
            raise

    def close_sql():
        # Close the DB connection between flushes so it isn't held open
        # idly. On scale-to-zero Neon plans, an idle-but-open connection
        # can prevent the compute from suspending and racking up cost.
        nonlocal conn, cursor

        try:
            if cursor:
                cursor.close()
        except:
            pass

        try:
            if conn:
                conn.close()
        except:
            pass

        conn = None
        cursor = None

    batch = []
    last_flush = time.time()
    # Cleanup every CLEANUP_INTERVAL_SEC seconds
    last_cleanup = time.time()

    while True:
        try:
            try:
                # Wait longer for a message when idle instead of polling
                # every 0.1s — this cuts wake-ups ~50x while keeping
                # responsiveness for the 20s flush interval.
                msg = await asyncio.wait_for(queue.get(), timeout=5.0)
                batch.append(msg)
            except asyncio.TimeoutError:
                pass

            now = time.time()
            time_due = (now - last_flush) >= FLUSH_INTERVAL_SEC
            size_due = len(batch) >= MAX_BATCH_SIZE

            if batch and (time_due or size_due):
                ensure_sql()
                if time.time() - last_cleanup > CLEANUP_INTERVAL_SEC:
                    cleanup_old_rows(cursor, conn)
                    last_cleanup = time.time()
                rows = []
                for m in batch:
                    dt = parse_ts_ist(m.ts)
                    payload_json = json.dumps(m.payload)
                    rows.append((dt, m.plant_id, payload_json))

                # execute_values sends one multi-row INSERT statement
                # instead of N separate INSERTs (executemany does NOT
                # batch under the hood for psycopg2) — much less DB CPU.
                execute_values(
                    cursor,
                    "INSERT INTO dbo.raw_plant_data (ts, plant_id, payload_json) VALUES %s",
                    rows,
                )
                conn.commit()

                stats["inserted"] += len(rows)
                print(f"Inserted {len(rows)} rows")

                batch.clear()
                last_flush = now

                # Close the connection now that the write is done, rather
                # than keeping it open until the next flush in ~20s.
                close_sql()

        except Exception as e:
            print(f"Flush error: {e}")

            try:
                if conn:
                    conn.rollback()
            except:
                pass

            close_sql()

            await asyncio.sleep(1)


# =========================
# MAIN
# =========================
if __name__ == "__main__":
    import uvicorn
    uvicorn.run("ml_api:app", host="0.0.0.0", port=7860)