File size: 16,803 Bytes
40eb9bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
#!/usr/bin/env python3
"""
pg_plan_cache Agent β€” Hugging Face Spaces (Gradio UI)

Provides a web interface for managing and exploring the pg_plan_cache
PostgreSQL extension. Works in two modes:

  Demo mode  β€” query normalization, hashing, architecture docs (no backend needed)
  Live mode  β€” full cache stats, Redis inspection, diagnostics (needs PG + Redis)
"""

import os
import gradio as gr
from normalizer import normalize_query, compute_query_hash

# ---------------------------------------------------------------------------
# Optional live DB connection (only when credentials are provided)
# ---------------------------------------------------------------------------

db = None


def _connect_db():
    """Try to connect using env vars / HF Secrets. Returns DatabaseManager or None."""
    global db
    if db is not None:
        return db
    pg_host = os.getenv("PG_HOST")
    redis_host = os.getenv("REDIS_HOST")
    if not pg_host and not redis_host:
        return None
    try:
        from config import load_config
        from db import DatabaseManager
        config = load_config()
        db = DatabaseManager(config)
        db.pg_query("SELECT 1")
        return db
    except Exception:
        return None


def _is_live():
    return _connect_db() is not None


# ---------------------------------------------------------------------------
# Tab 1: Query Normalizer & Hasher (always works β€” no backend needed)
# ---------------------------------------------------------------------------

def normalize_tab(query: str):
    """Normalize a SQL query and compute its SHA-256 cache key."""
    if not query.strip():
        return "", "", ""
    normalized = normalize_query(query)
    qhash = compute_query_hash(normalized)

    # Check cache status if live
    status = "N/A (demo mode β€” no Redis connected)"
    conn = _connect_db()
    if conn:
        try:
            cached = conn.redis_get(f"plan:{qhash}")
            status = "CACHED" if cached else "NOT CACHED"
        except Exception as e:
            status = f"Error: {e}"

    return normalized, qhash, status


# ---------------------------------------------------------------------------
# Tab 2: Cache Statistics (needs live PG)
# ---------------------------------------------------------------------------

def stats_tab():
    conn = _connect_db()
    if not conn:
        return "Not connected to PostgreSQL.\nSet PG_HOST, PG_USER, etc. in Space Secrets to enable."
    try:
        import tools
        return tools.get_cache_stats(conn)
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 3: Cache Efficiency Analysis (needs live PG + Redis)
# ---------------------------------------------------------------------------

def analyze_tab():
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet PG and Redis credentials in Space Secrets to enable."
    try:
        import tools
        return tools.analyze_cache_efficiency(conn)
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 4: Full Diagnostics (needs live PG + Redis)
# ---------------------------------------------------------------------------

def diagnose_tab():
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet PG and Redis credentials in Space Secrets to enable."
    try:
        import tools
        return tools.diagnose(conn)
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 5: Redis Health (needs Redis)
# ---------------------------------------------------------------------------

def redis_health_tab():
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet REDIS_HOST, REDIS_PORT, etc. in Space Secrets to enable."
    try:
        import tools
        return tools.check_redis_health(conn)
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 6: List Cached Plans (needs Redis)
# ---------------------------------------------------------------------------

def list_plans_tab(limit: int):
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet PG and Redis credentials in Space Secrets to enable."
    try:
        import tools
        return tools.list_cached_plans(conn, int(limit))
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 7: Inspect Plan (needs Redis)
# ---------------------------------------------------------------------------

def inspect_plan_tab(query_hash: str):
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet Redis credentials in Space Secrets to enable."
    if not query_hash.strip():
        return "Enter a query hash."
    try:
        import tools
        return tools.get_cached_plan_detail(conn, query_hash.strip())
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 8: Table Dependencies (needs Redis)
# ---------------------------------------------------------------------------

def deps_tab(table_name: str, query_hash: str):
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet Redis credentials in Space Secrets to enable."
    try:
        import tools
        return tools.get_table_dependencies(
            conn,
            table_name.strip() or None,
            query_hash.strip() or None,
        )
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 9: Extension Config (needs PG)
# ---------------------------------------------------------------------------

def config_tab():
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet PG credentials in Space Secrets to enable."
    try:
        import tools
        return tools.get_extension_config(conn)
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 10: Invalidate (needs PG)
# ---------------------------------------------------------------------------

def invalidate_tab(table_name: str):
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet PG credentials in Space Secrets to enable."
    try:
        import tools
        if table_name.strip():
            return tools.invalidate_table_plans(conn, table_name.strip())
        else:
            return tools.invalidate_all_plans(conn)
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Tab 11: Run SQL (needs PG)
# ---------------------------------------------------------------------------

def sql_tab(sql: str):
    conn = _connect_db()
    if not conn:
        return "Not connected.\nSet PG credentials in Space Secrets to enable."
    if not sql.strip():
        return "Enter a SELECT query."
    try:
        import tools
        return tools.run_sql_query(conn, sql.strip())
    except Exception as e:
        return f"Error: {e}"


# ---------------------------------------------------------------------------
# Architecture reference (always works)
# ---------------------------------------------------------------------------

ARCHITECTURE_MD = """
## pg_plan_cache β€” Architecture

### How It Works

```
Query arrives at PostgreSQL
       β”‚
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚    planner_hook          β”‚  ← pgpc_planner_hook()
β”‚    intercepts planning   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  normalize_query()       β”‚  Replace literals with $N
β”‚  compute_query_hash()    β”‚  SHA-256 of normalized query
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Redis GET plan:<hash>   │────▢│  Cache HIT?   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚                             β”‚
    Cache MISS                    Return cached
         β”‚                       PlannedStmt
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  standard_planner()      β”‚  Run PostgreSQL planner
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚
         β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  serialize + Redis SET   β”‚  Store plan with TTL
β”‚  extract_table_names()   β”‚  Track dependencies
β”‚  redis_set_dependencies()β”‚  Bidirectional mapping
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```

### Key Components

| Component | File | Purpose |
|-----------|------|---------|
| Planner Hook | `pg_plan_cache.c` | Intercepts queries, checks cache, stores plans |
| Query Normalizer | `query_normalize.c` | Literals β†’ `$N`, collapse whitespace, lowercase |
| Redis Client | `redis_client.c` | Connection pool, GET/SET/DEL, dependency tracking |
| Schema Invalidation | `pg_plan_cache.c` | Relcache callback bumps version on DDL |

### Redis Key Patterns

| Pattern | Purpose |
|---------|---------|
| `plan:<sha256>` | Serialized PlannedStmt + metadata |
| `deps:table:<schema.table>` | SET of query hashes depending on this table |
| `qdeps:<sha256>` | SET of table names this query depends on |

### GUC Parameters

| Parameter | Default | Restart? |
|-----------|---------|----------|
| `pg_plan_cache.redis_host` | 127.0.0.1 | No (SIGHUP) |
| `pg_plan_cache.redis_port` | 6379 | No (SIGHUP) |
| `pg_plan_cache.ttl` | 3600 | No (SIGHUP) |
| `pg_plan_cache.enabled` | true | No (SUSET) |
| `pg_plan_cache.redis_timeout_ms` | 500 | No (SIGHUP) |
| `pg_plan_cache.redis_pool_size` | 4 | Yes (POSTMASTER) |

### Serialization Format

```
schema_version|total_cost|unix_timestamp|nodeToString(PlannedStmt)
```

Plans with a stale `schema_version` are discarded on read.

### Normalization Examples

| Input | Normalized |
|-------|-----------|
| `SELECT * FROM users WHERE id = 42` | `select * from users where id = $1` |
| `SELECT name FROM t WHERE x = 'hello'` | `select name from t where x = $1` |
| `SELECT /* comment */ a FROM b` | `select a from b` |
"""


# ---------------------------------------------------------------------------
# Build Gradio app
# ---------------------------------------------------------------------------

def build_app():
    with gr.Blocks(
        title="pg_plan_cache Agent",
        theme=gr.themes.Soft(),
    ) as app:

        gr.Markdown("# pg_plan_cache Agent")
        gr.Markdown(
            "Manage, monitor, and diagnose the **pg_plan_cache** PostgreSQL extension. "
            "The **Normalizer** and **Architecture** tabs work without any backend. "
            "All other tabs require PostgreSQL and Redis credentials set as environment variables."
        )

        live_status = "Connected" if _is_live() else "Demo mode (no backend)"
        gr.Markdown(f"**Status:** {live_status}")

        with gr.Tab("Normalizer"):
            gr.Markdown("Normalize a SQL query and compute its SHA-256 cache key.")
            query_input = gr.Textbox(
                label="SQL Query",
                placeholder="SELECT * FROM users WHERE id = 42 AND name = 'Alice'",
                lines=3,
            )
            norm_btn = gr.Button("Normalize & Hash", variant="primary")
            norm_output = gr.Textbox(label="Normalized Query", lines=2)
            hash_output = gr.Textbox(label="Cache Key (SHA-256)")
            status_output = gr.Textbox(label="Cache Status")
            norm_btn.click(
                fn=normalize_tab,
                inputs=[query_input],
                outputs=[norm_output, hash_output, status_output],
            )

        with gr.Tab("Cache Stats"):
            gr.Markdown("Live cache hit/miss/error statistics from `pg_plan_cache_stats()`.")
            stats_btn = gr.Button("Refresh Stats", variant="primary")
            stats_output = gr.Textbox(label="Statistics", lines=12)
            stats_btn.click(fn=stats_tab, outputs=[stats_output])

        with gr.Tab("Analyze"):
            gr.Markdown("Cache efficiency analysis with tuning recommendations.")
            analyze_btn = gr.Button("Run Analysis", variant="primary")
            analyze_output = gr.Textbox(label="Analysis", lines=20)
            analyze_btn.click(fn=analyze_tab, outputs=[analyze_output])

        with gr.Tab("Diagnose"):
            gr.Markdown("Full diagnostic: PostgreSQL, Redis, stats, and configuration.")
            diag_btn = gr.Button("Run Diagnostics", variant="primary")
            diag_output = gr.Textbox(label="Diagnostic Report", lines=25)
            diag_btn.click(fn=diagnose_tab, outputs=[diag_output])

        with gr.Tab("Redis Health"):
            gr.Markdown("Redis connectivity, memory, and keyspace info.")
            redis_btn = gr.Button("Check Redis", variant="primary")
            redis_output = gr.Textbox(label="Redis Health", lines=12)
            redis_btn.click(fn=redis_health_tab, outputs=[redis_output])

        with gr.Tab("Cached Plans"):
            gr.Markdown("List cached plan keys stored in Redis.")
            plan_limit = gr.Slider(minimum=5, maximum=200, value=50, step=5, label="Limit")
            plans_btn = gr.Button("List Plans", variant="primary")
            plans_output = gr.Textbox(label="Plans", lines=15)
            plans_btn.click(fn=list_plans_tab, inputs=[plan_limit], outputs=[plans_output])

        with gr.Tab("Inspect Plan"):
            gr.Markdown("View details of a specific cached plan by hash.")
            hash_input = gr.Textbox(label="Query Hash (64-char hex)", placeholder="abc123...")
            inspect_btn = gr.Button("Inspect", variant="primary")
            inspect_output = gr.Textbox(label="Plan Details", lines=12)
            inspect_btn.click(fn=inspect_plan_tab, inputs=[hash_input], outputs=[inspect_output])

        with gr.Tab("Dependencies"):
            gr.Markdown("Explore bidirectional dependency mappings between queries and tables.")
            dep_table = gr.Textbox(label="Table Name", placeholder="public.users")
            dep_hash = gr.Textbox(label="Query Hash (optional)", placeholder="abc123...")
            deps_btn = gr.Button("Look Up", variant="primary")
            deps_output = gr.Textbox(label="Dependencies", lines=12)
            deps_btn.click(fn=deps_tab, inputs=[dep_table, dep_hash], outputs=[deps_output])

        with gr.Tab("Configuration"):
            gr.Markdown("View current GUC parameter values for pg_plan_cache.")
            cfg_btn = gr.Button("Show Config", variant="primary")
            cfg_output = gr.Textbox(label="Configuration", lines=8)
            cfg_btn.click(fn=config_tab, outputs=[cfg_output])

        with gr.Tab("Invalidate"):
            gr.Markdown("Invalidate cached plans. Leave table name empty to invalidate ALL plans.")
            inv_table = gr.Textbox(label="Table Name (optional)", placeholder="public.users")
            inv_btn = gr.Button("Invalidate", variant="stop")
            inv_output = gr.Textbox(label="Result")
            inv_btn.click(fn=invalidate_tab, inputs=[inv_table], outputs=[inv_output])

        with gr.Tab("SQL Query"):
            gr.Markdown("Execute a read-only SELECT query against PostgreSQL.")
            sql_input = gr.Textbox(
                label="SQL (SELECT only)",
                placeholder="SELECT * FROM pg_stat_activity LIMIT 5",
                lines=3,
            )
            sql_btn = gr.Button("Execute", variant="primary")
            sql_output = gr.Textbox(label="Results", lines=15)
            sql_btn.click(fn=sql_tab, inputs=[sql_input], outputs=[sql_output])

        with gr.Tab("Architecture"):
            gr.Markdown(ARCHITECTURE_MD)

    return app


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    app = build_app()
    app.launch()