sql-dialect-master 1.1.7__tar.gz → 1.1.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/PKG-INFO +31 -3
  2. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/README.md +29 -2
  3. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/main.py +126 -5
  4. sql_dialect_master-1.1.9/backend/api/metrics.py +204 -0
  5. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/middleware.py +8 -1
  6. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/workers.py +34 -2
  7. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/config.py +12 -0
  8. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/metadata.py +2 -2
  9. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/pyproject.toml +18 -1
  10. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/sql_dialect_master.egg-info/PKG-INFO +31 -3
  11. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/sql_dialect_master.egg-info/SOURCES.txt +1 -0
  12. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/sql_dialect_master.egg-info/requires.txt +1 -0
  13. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/LICENSE +0 -0
  14. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/__init__.py +0 -0
  15. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/__init__.py +0 -0
  16. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/auth.py +0 -0
  17. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/client_identity.py +0 -0
  18. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/rate_limit_store.py +0 -0
  19. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/api/readiness.py +0 -0
  20. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/__init__.py +0 -0
  21. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/cache.py +0 -0
  22. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/column_hint_validation.py +0 -0
  23. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/constants.py +0 -0
  24. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/exceptions.py +0 -0
  25. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/function_call_scanner.py +0 -0
  26. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/function_semantics.py +0 -0
  27. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/functions_db.json +0 -0
  28. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/functions_lookup.py +0 -0
  29. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql.py +0 -0
  30. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/__init__.py +0 -0
  31. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/aggregations.py +0 -0
  32. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/boolean_conditions.py +0 -0
  33. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/core_extract.py +0 -0
  34. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/evidence.py +0 -0
  35. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/mappings.py +0 -0
  36. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/relations.py +0 -0
  37. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/templates.py +0 -0
  38. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_components/tokenizer.py +0 -0
  39. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_legacy.py +0 -0
  40. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/__init__.py +0 -0
  41. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/aggregations.py +0 -0
  42. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/clause_extract.py +0 -0
  43. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/dialect.py +0 -0
  44. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/intent.py +0 -0
  45. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/ir.py +0 -0
  46. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/models.py +0 -0
  47. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/orchestrator.py +0 -0
  48. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/predicates.py +0 -0
  49. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/renderer.py +0 -0
  50. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/resolution.py +0 -0
  51. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/suggestions.py +0 -0
  52. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/templates.py +0 -0
  53. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/nl2sql_pipeline/validation.py +0 -0
  54. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/p1_sql_scanner.py +0 -0
  55. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/p2_data_validation.py +0 -0
  56. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/parser.py +0 -0
  57. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/post_processor.py +0 -0
  58. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/rules.py +0 -0
  59. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/semantic_diff.py +0 -0
  60. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/sql_statements.py +0 -0
  61. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/transpiler.py +0 -0
  62. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/type_mapping.json +0 -0
  63. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/core/type_mapping.py +0 -0
  64. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/utils/__init__.py +0 -0
  65. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/backend/utils/validators.py +0 -0
  66. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/setup.cfg +0 -0
  67. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/sql_dialect_master.egg-info/dependency_links.txt +0 -0
  68. {sql_dialect_master-1.1.7 → sql_dialect_master-1.1.9}/sql_dialect_master.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dialect-master
3
- Version: 1.1.7
3
+ Version: 1.1.9
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.115.0
10
10
  Requires-Dist: uvicorn>=0.30.0
11
11
  Requires-Dist: pydantic>=2.9.0
12
12
  Requires-Dist: pydantic-settings>=2.0.0
13
+ Requires-Dist: prometheus-client<1.0.0,>=0.20.0
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: hypothesis>=6.112.0; extra == "dev"
15
16
  Requires-Dist: pytest>=8.3.0; extra == "dev"
@@ -35,7 +36,7 @@ Dynamic: license-file
35
36
 
36
37
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
37
38
 
38
- Current release: 1.1.7
39
+ Current release: 1.1.9
39
40
 
40
41
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -293,6 +294,7 @@ sql-dialect-master/
293
294
  | Endpoint | Method | Description |
294
295
  |----------|--------|-------------|
295
296
  | `/api/convert` | POST | Convert SQL between dialects; response includes `error_code` on failure |
297
+ | `/api/convert/batch` | POST | Convert an ordered batch with one dialect pair and per-item results |
296
298
  | `/api/parse` | POST | Parse SQL and extract structural elements |
297
299
  | `/api/nl2sql` | POST | Generate SQL from natural language |
298
300
  | `/api/functions` | GET | Search SQL functions |
@@ -304,6 +306,24 @@ sql-dialect-master/
304
306
  | `/health/deep` | GET | Deep health check |
305
307
  | `/api/stats` | GET | Service and dataset statistics |
306
308
 
309
+ For example, send this JSON body to `POST /api/convert/batch`:
310
+
311
+ ```json
312
+ {
313
+ "statements": ["SELECT id FROM users", "SELECT user_id FROM orders"],
314
+ "source_dialect": "postgres",
315
+ "target_dialect": "mysql",
316
+ "pretty": true
317
+ }
318
+ ```
319
+
320
+ Results retain input order and include a zero-based `index` alongside the
321
+ single-conversion response fields. The summary includes `count`, `succeeded`,
322
+ and `failed`; top-level `success` is true only when every item succeeds.
323
+ Ordinary item failures, including a batch where every item fails, return HTTP
324
+ 200 with per-item `error` and `error_code`. Request-level errors use the
325
+ existing error envelope; 503/504 responses do not include partial results.
326
+
307
327
  ## 🧪 Quality, Validation & Performance
308
328
 
309
329
  The project maintains a 12 × 12 source-to-target dialect regression matrix, semantic regression tests, post-processing safety regressions, security boundary checks, deterministic rule-engine tests, cache-versioning tests, async concurrency tests, and stable error-code tests.
@@ -316,7 +336,14 @@ The semantic CI gate executes representative conversions against PostgreSQL and
316
336
 
317
337
  Security validation structurally detects stacked SQL statements using the parser when available, with configurable policy controlling whether dangerous input is blocked or only warned about.
318
338
 
319
- The async batch API bounds concurrent work and preserves input order. A reproducible transpiler benchmark is available under `backend/benchmarks/` and is also executed in CI for performance observability. The benchmark intentionally reports measurements without failing the build on a fixed latency threshold because hosted CI timing is variable.
339
+ The SDK's `batch_transpile_async` bounds concurrent work and preserves input
340
+ order. REST batch conversion runs sequentially in one API worker slot, and the
341
+ entire batch shares the default 30-second queue-plus-execution budget
342
+ (`SDM_REQUEST_TIMEOUT`). HTTP 504 does not cancel the background batch. See
343
+ the [deployment limits](docs/deployment.md#1-deployment-model) for capacity
344
+ and timeout behavior.
345
+
346
+ A reproducible transpiler benchmark is available under `backend/benchmarks/` and is also executed in CI for performance observability. The benchmark intentionally reports measurements without failing the build on a fixed latency threshold because hosted CI timing is variable.
320
347
 
321
348
  README source-of-truth checks run in CI and verify the release version, Python requirement, supported dialect list, and benchmark references against repository sources.
322
349
 
@@ -372,3 +399,4 @@ public catalog responses use short `private` caching. Full deployment details
372
399
  will be documented in `docs/deployment.md`.
373
400
 
374
401
  See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
402
+ Metrics configuration and scraping guidance are documented in the same guide.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
4
4
 
5
- Current release: 1.1.7
5
+ Current release: 1.1.9
6
6
 
7
7
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -260,6 +260,7 @@ sql-dialect-master/
260
260
  | Endpoint | Method | Description |
261
261
  |----------|--------|-------------|
262
262
  | `/api/convert` | POST | Convert SQL between dialects; response includes `error_code` on failure |
263
+ | `/api/convert/batch` | POST | Convert an ordered batch with one dialect pair and per-item results |
263
264
  | `/api/parse` | POST | Parse SQL and extract structural elements |
264
265
  | `/api/nl2sql` | POST | Generate SQL from natural language |
265
266
  | `/api/functions` | GET | Search SQL functions |
@@ -271,6 +272,24 @@ sql-dialect-master/
271
272
  | `/health/deep` | GET | Deep health check |
272
273
  | `/api/stats` | GET | Service and dataset statistics |
273
274
 
275
+ For example, send this JSON body to `POST /api/convert/batch`:
276
+
277
+ ```json
278
+ {
279
+ "statements": ["SELECT id FROM users", "SELECT user_id FROM orders"],
280
+ "source_dialect": "postgres",
281
+ "target_dialect": "mysql",
282
+ "pretty": true
283
+ }
284
+ ```
285
+
286
+ Results retain input order and include a zero-based `index` alongside the
287
+ single-conversion response fields. The summary includes `count`, `succeeded`,
288
+ and `failed`; top-level `success` is true only when every item succeeds.
289
+ Ordinary item failures, including a batch where every item fails, return HTTP
290
+ 200 with per-item `error` and `error_code`. Request-level errors use the
291
+ existing error envelope; 503/504 responses do not include partial results.
292
+
274
293
  ## 🧪 Quality, Validation & Performance
275
294
 
276
295
  The project maintains a 12 × 12 source-to-target dialect regression matrix, semantic regression tests, post-processing safety regressions, security boundary checks, deterministic rule-engine tests, cache-versioning tests, async concurrency tests, and stable error-code tests.
@@ -283,7 +302,14 @@ The semantic CI gate executes representative conversions against PostgreSQL and
283
302
 
284
303
  Security validation structurally detects stacked SQL statements using the parser when available, with configurable policy controlling whether dangerous input is blocked or only warned about.
285
304
 
286
- The async batch API bounds concurrent work and preserves input order. A reproducible transpiler benchmark is available under `backend/benchmarks/` and is also executed in CI for performance observability. The benchmark intentionally reports measurements without failing the build on a fixed latency threshold because hosted CI timing is variable.
305
+ The SDK's `batch_transpile_async` bounds concurrent work and preserves input
306
+ order. REST batch conversion runs sequentially in one API worker slot, and the
307
+ entire batch shares the default 30-second queue-plus-execution budget
308
+ (`SDM_REQUEST_TIMEOUT`). HTTP 504 does not cancel the background batch. See
309
+ the [deployment limits](docs/deployment.md#1-deployment-model) for capacity
310
+ and timeout behavior.
311
+
312
+ A reproducible transpiler benchmark is available under `backend/benchmarks/` and is also executed in CI for performance observability. The benchmark intentionally reports measurements without failing the build on a fixed latency threshold because hosted CI timing is variable.
287
313
 
288
314
  README source-of-truth checks run in CI and verify the release version, Python requirement, supported dialect list, and benchmark references against repository sources.
289
315
 
@@ -339,3 +365,4 @@ public catalog responses use short `private` caching. Full deployment details
339
365
  will be documented in `docs/deployment.md`.
340
366
 
341
367
  See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
368
+ Metrics configuration and scraping guidance are documented in the same guide.
@@ -27,9 +27,18 @@ from fastapi import Depends, FastAPI, HTTPException, Query, Request
27
27
  from fastapi.middleware.cors import CORSMiddleware
28
28
  from fastapi.middleware.gzip import GZipMiddleware
29
29
  from fastapi.responses import JSONResponse
30
- from pydantic import BaseModel, ConfigDict, Field
30
+ from pydantic import BaseModel, ConfigDict, Field, StrictStr
31
31
 
32
32
  from backend.api.auth import require_api_access
33
+ from backend.api.metrics import (
34
+ MetricsMiddleware,
35
+ configure_runtime_metrics,
36
+ metrics_response,
37
+ observe_transpile_batch_size,
38
+ record_transpile_batch_items,
39
+ record_transpile_batch_request,
40
+ record_transpile_request,
41
+ )
33
42
  from backend.api.rate_limit_store import InMemoryRateLimitStore
34
43
  from backend.api.readiness import health_probe_response
35
44
  from backend.api.workers import BoundedWorkerPool, WorkerCapacityError
@@ -303,6 +312,12 @@ func_encyclopedia = FunctionEncyclopedia()
303
312
  type_mapper = TypeMapper()
304
313
  nl2sql_generator = NL2SQLGenerator()
305
314
 
315
+ configure_runtime_metrics(
316
+ transpiler_provider=lambda: transpiler,
317
+ worker_provider=_get_worker_pool,
318
+ limiter_provider=lambda: rate_limiter,
319
+ )
320
+
306
321
  # Request timing middleware with logging
307
322
  @app.middleware("http")
308
323
  async def add_process_time_header(request: Request, call_next):
@@ -320,6 +335,11 @@ async def add_process_time_header(request: Request, call_next):
320
335
  response.status_code, process_time)
321
336
  return response
322
337
 
338
+
339
+ # This is the outermost application middleware, so it also observes short
340
+ # circuit responses returned by rate limiting and worker-capacity guards.
341
+ app.add_middleware(MetricsMiddleware)
342
+
323
343
  # Dialect info for enhanced responses (from centralized config)
324
344
  DIALECT_INFO = get_dialect_api_info()
325
345
 
@@ -380,6 +400,38 @@ class ConvertResponse(BaseModel):
380
400
  )
381
401
  timestamp: str = Field(default_factory=lambda: datetime.now().isoformat(), description="Response timestamp")
382
402
 
403
+
404
+ class BatchConvertRequest(BaseModel):
405
+ """Convert an ordered, bounded list using one dialect pair."""
406
+
407
+ statements: List[StrictStr] = Field(
408
+ ...,
409
+ min_length=1,
410
+ max_length=settings.max_batch_size,
411
+ description="SQL statements in input order; each item is converted independently",
412
+ )
413
+ source_dialect: str
414
+ target_dialect: str
415
+ pretty: bool = settings.transpiler_pretty_default
416
+
417
+
418
+ class BatchConvertItemResponse(ConvertResponse):
419
+ """A conversion result associated with its original input position."""
420
+
421
+ index: int = Field(..., ge=0, description="Zero-based index in the input statements")
422
+
423
+
424
+ class BatchConvertResponse(BaseModel):
425
+ """Ordered conversion results, including ordinary per-item failures."""
426
+
427
+ success: bool
428
+ results: List[BatchConvertItemResponse]
429
+ count: int
430
+ succeeded: int
431
+ failed: int
432
+ timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
433
+
434
+
383
435
  class NL2SQLRequest(BaseModel):
384
436
  """Natural language to SQL request model."""
385
437
  text: str = Field(..., description="Natural language query (Chinese or English)")
@@ -460,6 +512,10 @@ class APIResponse(BaseModel):
460
512
 
461
513
  # === API Endpoints ===
462
514
 
515
+ @app.get("/metrics", include_in_schema=False)
516
+ async def metrics_endpoint(request: Request):
517
+ return await metrics_response(request)
518
+
463
519
  @app.get("/", tags=["system"])
464
520
  async def root():
465
521
  """
@@ -560,11 +616,13 @@ async def convert_sql(request: ConvertRequest):
560
616
  source_dialect = request.source_dialect.strip().lower()
561
617
  target_dialect = request.target_dialect.strip().lower()
562
618
  if source_dialect not in SUPPORTED_DIALECTS:
619
+ record_transpile_request("unknown", "unknown", "validation_error")
563
620
  raise HTTPException(
564
621
  status_code=400,
565
622
  detail=f"Unsupported source dialect: {request.source_dialect}. Supported: {SUPPORTED_DIALECTS}"
566
623
  )
567
624
  if target_dialect not in SUPPORTED_DIALECTS:
625
+ record_transpile_request(source_dialect, "unknown", "validation_error")
568
626
  raise HTTPException(
569
627
  status_code=400,
570
628
  detail=f"Unsupported target dialect: {request.target_dialect}. Supported: {SUPPORTED_DIALECTS}"
@@ -572,12 +630,21 @@ async def convert_sql(request: ConvertRequest):
572
630
 
573
631
  # sqlglot parsing/transpilation is CPU-bound. Keep it off the event loop
574
632
  # so one large request cannot stall unrelated async requests.
575
- result = await _run_cpu_bound(
576
- transpiler.transpile,
577
- request.sql,
633
+ try:
634
+ result = await _run_cpu_bound(
635
+ transpiler.transpile,
636
+ request.sql,
637
+ source_dialect,
638
+ target_dialect,
639
+ request.pretty,
640
+ )
641
+ except HTTPException:
642
+ record_transpile_request(source_dialect, target_dialect, "error")
643
+ raise
644
+ record_transpile_request(
578
645
  source_dialect,
579
646
  target_dialect,
580
- request.pretty,
647
+ "success" if result.success else "error",
581
648
  )
582
649
  return ConvertResponse(
583
650
  success=result.success,
@@ -593,6 +660,60 @@ async def convert_sql(request: ConvertRequest):
593
660
  target_validation_state=result.target_validation_state,
594
661
  )
595
662
 
663
+ @app.post("/api/convert/batch", response_model=BatchConvertResponse, tags=["conversion"])
664
+ async def convert_sql_batch(request: BatchConvertRequest):
665
+ """Convert statements sequentially in one worker slot, preserving input order.
666
+
667
+ Ordinary item failures, including a batch where every item fails, return
668
+ HTTP 200 with per-item errors. Request-level failures use the existing
669
+ error envelope. A 504 ends the wait but does not cancel the worker batch.
670
+ """
671
+ source_dialect = target_dialect = "unknown"
672
+ try:
673
+ source_dialect = normalize_dialect(request.source_dialect, "source dialect")
674
+ target_dialect = normalize_dialect(request.target_dialect, "target dialect")
675
+ observe_transpile_batch_size(len(request.statements))
676
+ batch_results = await _run_cpu_bound(
677
+ transpiler.batch_transpile,
678
+ list(request.statements),
679
+ source_dialect,
680
+ target_dialect,
681
+ request.pretty,
682
+ )
683
+ results = [
684
+ BatchConvertItemResponse(index=index, **result.to_dict())
685
+ for index, result in enumerate(batch_results)
686
+ ]
687
+ count = len(results)
688
+ succeeded = sum(item.success for item in results)
689
+ failed = count - succeeded
690
+ response = BatchConvertResponse(
691
+ success=failed == 0,
692
+ results=results,
693
+ count=count,
694
+ succeeded=succeeded,
695
+ failed=failed,
696
+ )
697
+ except HTTPException as exc:
698
+ record_transpile_batch_request(
699
+ source_dialect,
700
+ target_dialect,
701
+ "validation_error" if exc.status_code == 400 else "error",
702
+ )
703
+ raise
704
+ except Exception:
705
+ record_transpile_batch_request(source_dialect, target_dialect, "error")
706
+ raise
707
+
708
+ record_transpile_batch_items(source_dialect, target_dialect, succeeded, failed)
709
+ record_transpile_batch_request(
710
+ source_dialect,
711
+ target_dialect,
712
+ "success" if failed == 0 else "partial_failure" if succeeded else "error",
713
+ )
714
+ return response
715
+
716
+
596
717
  @app.post("/api/parse", tags=["conversion"])
597
718
  async def parse_sql(request: ParseRequest):
598
719
  """
@@ -0,0 +1,204 @@
1
+ """Protected Prometheus metrics for the API process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+ from collections.abc import Callable
7
+
8
+ from fastapi import Request
9
+ from fastapi.responses import PlainTextResponse, Response
10
+ from prometheus_client import (
11
+ CONTENT_TYPE_LATEST,
12
+ CollectorRegistry,
13
+ Counter,
14
+ Gauge,
15
+ Histogram,
16
+ generate_latest,
17
+ )
18
+ from prometheus_client.core import CounterMetricFamily, GaugeMetricFamily
19
+
20
+ from backend.core.config import settings
21
+ from backend.core.metadata import PROJECT_VERSION
22
+
23
+ REGISTRY = CollectorRegistry(auto_describe=True)
24
+
25
+ BUILD_INFO = Gauge("sdm_build_info", "SQL Dialect Master build information", ["version"], registry=REGISTRY)
26
+ BUILD_INFO.labels(version=PROJECT_VERSION).set(1)
27
+
28
+ HTTP_REQUESTS = Counter(
29
+ "sdm_http_requests_total",
30
+ "HTTP requests handled by the API",
31
+ ["method", "route", "status_class"],
32
+ registry=REGISTRY,
33
+ )
34
+ HTTP_DURATION = Histogram(
35
+ "sdm_http_request_duration_seconds",
36
+ "HTTP request duration in seconds",
37
+ ["method", "route", "status_class"],
38
+ registry=REGISTRY,
39
+ )
40
+ RATE_LIMIT_REJECTIONS = Counter(
41
+ "sdm_rate_limit_rejections_total",
42
+ "Rate-limited requests",
43
+ registry=REGISTRY,
44
+ )
45
+ TRANSPILE_REQUESTS = Counter(
46
+ "sdm_transpile_requests_total",
47
+ "Conversion requests by dialect pair and result",
48
+ ["source", "target", "result"],
49
+ registry=REGISTRY,
50
+ )
51
+ TRANSPILE_BATCH_REQUESTS = Counter(
52
+ "sdm_transpile_batch_requests_total",
53
+ "Batch conversion handler outcomes by dialect pair and result",
54
+ ["source", "target", "result"],
55
+ registry=REGISTRY,
56
+ )
57
+ TRANSPILE_BATCH_ITEMS = Counter(
58
+ "sdm_transpile_batch_items_total",
59
+ "Items in complete batch results received by the API handler",
60
+ ["source", "target", "result"],
61
+ registry=REGISTRY,
62
+ )
63
+ TRANSPILE_BATCH_SIZE = Histogram(
64
+ "sdm_transpile_batch_size",
65
+ "Statement count of validated batches before worker admission",
66
+ buckets=(1, 2, 5, 10, 20, 50, 100),
67
+ registry=REGISTRY,
68
+ )
69
+
70
+ _transpiler_provider: Callable[[], object] | None = None
71
+ _worker_provider: Callable[[], object] | None = None
72
+ _limiter_provider: Callable[[], object] | None = None
73
+
74
+
75
+ def configure_runtime_metrics(*, transpiler_provider=None, worker_provider=None, limiter_provider=None) -> None:
76
+ """Register process-local runtime providers used when /metrics is scraped."""
77
+ global _transpiler_provider, _worker_provider, _limiter_provider
78
+ _transpiler_provider = transpiler_provider
79
+ _worker_provider = worker_provider
80
+ _limiter_provider = limiter_provider
81
+
82
+
83
+ class RuntimeCollector:
84
+ """Read low-frequency runtime snapshots without copying them per request."""
85
+
86
+ def collect(self):
87
+ if _transpiler_provider is not None:
88
+ try:
89
+ stats = _transpiler_provider().get_stats()
90
+ cache = stats.get("cache", {})
91
+ if cache.get("enabled", True) is not False:
92
+ yield CounterMetricFamily("sdm_transpile_cache_hits_total", "Transpiler cache hits", value=cache.get("hits", 0))
93
+ yield CounterMetricFamily("sdm_transpile_cache_misses_total", "Transpiler cache misses", value=cache.get("misses", 0))
94
+ yield CounterMetricFamily("sdm_transpile_cache_evictions_total", "Transpiler cache evictions", value=cache.get("evictions", 0))
95
+ yield GaugeMetricFamily("sdm_transpile_cache_size", "Current transpiler cache size", value=cache.get("size", 0))
96
+ except Exception:
97
+ pass
98
+
99
+ if _worker_provider is not None:
100
+ try:
101
+ stats = _worker_provider().stats()
102
+ yield GaugeMetricFamily("sdm_api_worker_capacity", "API worker pool capacity", value=stats["capacity"])
103
+ yield GaugeMetricFamily("sdm_api_worker_in_use", "API worker slots in use", value=stats["in_use"])
104
+ yield CounterMetricFamily("sdm_api_worker_rejections_total", "API worker capacity rejections", value=stats["rejections"])
105
+ yield CounterMetricFamily("sdm_api_request_timeouts_total", "API request timeouts", value=stats["timeouts"])
106
+ except Exception:
107
+ pass
108
+
109
+ if _limiter_provider is not None:
110
+ try:
111
+ stats = _limiter_provider().get_stats()
112
+ if stats.get("backend") == "memory":
113
+ yield GaugeMetricFamily("sdm_rate_limit_active_clients", "Active in-memory rate-limit clients", value=stats.get("active_clients", 0))
114
+ except Exception:
115
+ pass
116
+
117
+
118
+ REGISTRY.register(RuntimeCollector())
119
+
120
+
121
+ def normalize_method(method: str) -> str:
122
+ return method if method in {"GET", "POST", "HEAD", "OPTIONS"} else "other"
123
+
124
+
125
+ def route_template(request: Request) -> str:
126
+ route = request.scope.get("route")
127
+ template = getattr(route, "path_format", None) or getattr(route, "path", None)
128
+ return template if template else "other"
129
+
130
+
131
+ def status_class(status_code: int) -> str:
132
+ return f"{status_code // 100}xx" if 100 <= status_code < 600 else "other"
133
+
134
+
135
+ def record_http_request(request: Request, status_code: int, duration: float) -> None:
136
+ if request.url.path == "/metrics":
137
+ return
138
+ labels = (normalize_method(request.method), route_template(request), status_class(status_code))
139
+ HTTP_REQUESTS.labels(*labels).inc()
140
+ HTTP_DURATION.labels(*labels).observe(duration)
141
+
142
+
143
+ def record_rate_limit_rejection() -> None:
144
+ RATE_LIMIT_REJECTIONS.inc()
145
+
146
+
147
+ def record_transpile_request(source: str, target: str, result: str) -> None:
148
+ TRANSPILE_REQUESTS.labels(source=source, target=target, result=result).inc()
149
+
150
+
151
+ def record_transpile_batch_request(source: str, target: str, result: str) -> None:
152
+ TRANSPILE_BATCH_REQUESTS.labels(source=source, target=target, result=result).inc()
153
+
154
+
155
+ def record_transpile_batch_items(source: str, target: str, succeeded: int, failed: int) -> None:
156
+ if succeeded:
157
+ TRANSPILE_BATCH_ITEMS.labels(source=source, target=target, result="success").inc(succeeded)
158
+ if failed:
159
+ TRANSPILE_BATCH_ITEMS.labels(source=source, target=target, result="error").inc(failed)
160
+
161
+
162
+ def observe_transpile_batch_size(size: int) -> None:
163
+ TRANSPILE_BATCH_SIZE.observe(size)
164
+
165
+
166
+ def _authorized(request: Request) -> bool:
167
+ configured = settings.metrics_token
168
+ expected = configured.get_secret_value().strip() if configured else ""
169
+ supplied = request.headers.get("X-Metrics-Token", "")
170
+ return bool(expected and supplied and secrets.compare_digest(supplied, expected))
171
+
172
+
173
+ async def metrics_response(request: Request) -> Response:
174
+ if not settings.metrics_enabled:
175
+ return Response(status_code=404)
176
+ if not _authorized(request):
177
+ return Response(status_code=401, content="Metrics token required")
178
+ return PlainTextResponse(generate_latest(REGISTRY), media_type=CONTENT_TYPE_LATEST)
179
+
180
+
181
+ class MetricsMiddleware:
182
+ """ASGI middleware that records final HTTP status and elapsed time."""
183
+
184
+ def __init__(self, app):
185
+ self.app = app
186
+
187
+ async def __call__(self, scope, receive, send):
188
+ if scope.get("type") != "http":
189
+ await self.app(scope, receive, send)
190
+ return
191
+ import time
192
+
193
+ started = time.perf_counter()
194
+ status_code = 500
195
+
196
+ async def send_wrapper(message):
197
+ nonlocal status_code
198
+ if message.get("type") == "http.response.start":
199
+ status_code = message.get("status", 500)
200
+ await send(message)
201
+
202
+ await self.app(scope, receive, send_wrapper)
203
+ request = Request(scope)
204
+ record_http_request(request, status_code, time.perf_counter() - started)
@@ -20,6 +20,7 @@ from starlette.concurrency import run_in_threadpool
20
20
  from backend.core.exceptions import ErrorCode
21
21
 
22
22
  from .client_identity import resolve_client_ip
23
+ from .metrics import record_rate_limit_rejection
23
24
  from .rate_limit_store import InMemoryRateLimitStore, RateLimitStore, create_rate_limit_store
24
25
  from .readiness import health_probe_response
25
26
 
@@ -108,7 +109,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
108
109
 
109
110
  async def dispatch(self, request: Request, call_next) -> Response:
110
111
  is_health_probe = (
111
- request.url.path in {"/health", "/", "/ready", "/health/deep"}
112
+ request.url.path in {"/health", "/", "/ready", "/health/deep", "/metrics"}
112
113
  and request.method in {"GET", "HEAD"}
113
114
  )
114
115
  # Browser CORS preflight requests carry no business payload and are
@@ -133,6 +134,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
133
134
  is_allowed, remaining, reset_time = await run_in_threadpool(self.limiter.is_allowed, client_id)
134
135
  rate_limit_checked = True
135
136
  if not is_allowed:
137
+ record_rate_limit_rejection()
136
138
  logger.warning("Rate limit exceeded for client: %s", _sanitize_log_value(client_id))
137
139
  return JSONResponse(
138
140
  status_code=429,
@@ -164,6 +166,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
164
166
  if is_health_probe:
165
167
  if request.url.path in {"/ready", "/health/deep"}:
166
168
  return await health_probe_response(request)
169
+ if request.url.path == "/metrics":
170
+ from .metrics import metrics_response
171
+ return await metrics_response(request)
167
172
  return await call_next(request)
168
173
  response = await call_next(request)
169
174
  if rate_limit_checked:
@@ -386,6 +391,8 @@ class CacheControlMiddleware(BaseHTTPMiddleware):
386
391
  if request.method == "OPTIONS":
387
392
  return None
388
393
  path = request.url.path
394
+ if path == "/metrics":
395
+ return "no-store"
389
396
  if path in {"/docs", "/redoc", "/openapi.json"}:
390
397
  return None
391
398
  if request.method == "POST" and path.startswith("/api/"):
@@ -24,22 +24,54 @@ class BoundedWorkerPool:
24
24
  self._slots = BoundedSemaphore(workers + queue_size)
25
25
  self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="sdm-api")
26
26
  self._shutdown_lock = Lock()
27
+ self._stats_lock = Lock()
28
+ self._capacity = workers + queue_size
29
+ self._in_use = 0
30
+ self._rejections = 0
31
+ self._timeouts = 0
27
32
  self._shutdown = False
28
33
 
29
34
  async def run(self, function, *args, timeout: float):
30
35
  if not self._slots.acquire(blocking=False):
36
+ with self._stats_lock:
37
+ self._rejections += 1
31
38
  raise WorkerCapacityError("Request processing capacity is exhausted")
39
+ with self._stats_lock:
40
+ self._in_use += 1
32
41
  try:
33
42
  future = self._executor.submit(function, *args)
34
43
  except BaseException:
35
44
  self._slots.release()
45
+ with self._stats_lock:
46
+ self._in_use -= 1
36
47
  raise
37
- future.add_done_callback(lambda _future: self._slots.release())
48
+
49
+ def release_slot(_future):
50
+ self._slots.release()
51
+ with self._stats_lock:
52
+ self._in_use -= 1
53
+
54
+ future.add_done_callback(release_slot)
38
55
  wrapped = asyncio.wrap_future(future)
39
56
  # A timed-out caller no longer awaits this result. Retrieve exceptions
40
57
  # to avoid an unhandled-future warning when background work later fails.
41
58
  wrapped.add_done_callback(lambda completed: None if completed.cancelled() else completed.exception())
42
- return await asyncio.wait_for(asyncio.shield(wrapped), timeout=timeout)
59
+ try:
60
+ return await asyncio.wait_for(asyncio.shield(wrapped), timeout=timeout)
61
+ except asyncio.TimeoutError:
62
+ with self._stats_lock:
63
+ self._timeouts += 1
64
+ raise
65
+
66
+ def stats(self) -> dict[str, int]:
67
+ """Return a thread-safe snapshot of pool capacity counters."""
68
+ with self._stats_lock:
69
+ return {
70
+ "capacity": self._capacity,
71
+ "in_use": self._in_use,
72
+ "rejections": self._rejections,
73
+ "timeouts": self._timeouts,
74
+ }
43
75
 
44
76
  def shutdown(self) -> None:
45
77
  """Cancel queued work; already-running calls retain their slots."""
@@ -276,6 +276,10 @@ class AppSettings(BaseSettings):
276
276
  # Readiness probes. SecretStr keeps the value out of settings reprs and
277
277
  # structured logs while still allowing deployment through SDM_ env vars.
278
278
  health_probe_token: Optional[SecretStr] = None
279
+
280
+ # Prometheus metrics are opt-in and use a dedicated secret.
281
+ metrics_enabled: bool = False
282
+ metrics_token: Optional[SecretStr] = None
279
283
 
280
284
  # Security settings
281
285
  security_check_enabled: bool = True
@@ -292,6 +296,14 @@ class AppSettings(BaseSettings):
292
296
  self.parser_max_sql_length = self.transpiler_max_sql_length
293
297
  return self
294
298
 
299
+ @model_validator(mode="after")
300
+ def _validate_metrics_token(self) -> "AppSettings":
301
+ """Require a non-blank dedicated token when metrics are enabled."""
302
+ token = self.metrics_token.get_secret_value().strip() if self.metrics_token else ""
303
+ if self.metrics_enabled and not token:
304
+ raise ValueError("metrics_token is required when metrics_enabled is true")
305
+ return self
306
+
295
307
  @field_validator("log_level", mode="before")
296
308
  @classmethod
297
309
  def _normalize_log_level(cls, value: str) -> str:
@@ -17,8 +17,8 @@ Design rules:
17
17
  from __future__ import annotations
18
18
 
19
19
  # ── Release version facts (sole source of truth) ────────────────────────
20
- PROJECT_VERSION = "1.1.7"
21
- API_VERSION = "1.1.7"
20
+ PROJECT_VERSION = "1.1.9"
21
+ API_VERSION = "1.1.9"
22
22
  # Frontend v2 architecture — independent of the product release version.
23
23
  FRONTEND_ARCHITECTURE_VERSION = 2
24
24
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-dialect-master"
3
- version = "1.1.7"
3
+ version = "1.1.9"
4
4
  description = "SQL Dialect Master - Multi-database SQL conversion engine"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "uvicorn>=0.30.0",
11
11
  "pydantic>=2.9.0",
12
12
  "pydantic-settings>=2.0.0",
13
+ "prometheus-client>=0.20.0,<1.0.0",
13
14
  ]
14
15
 
15
16
  [project.optional-dependencies]
@@ -51,3 +52,19 @@ exclude = ["backend.tests*", "backend.benchmarks*"]
51
52
  testpaths = ["backend/tests"]
52
53
  python_files = ["test_*.py"]
53
54
  addopts = "-v --tb=short"
55
+
56
+ [tool.coverage.run]
57
+ source = ["backend"]
58
+ omit = [
59
+ "backend/tests/*",
60
+ "backend/benchmarks/*",
61
+ ]
62
+
63
+ [tool.coverage.report]
64
+ show_missing = true
65
+ skip_covered = false
66
+ fail_under = 93
67
+ exclude_lines = [
68
+ "pragma: no cover",
69
+ "if __name__ == .__main__.:",
70
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-dialect-master
3
- Version: 1.1.7
3
+ Version: 1.1.9
4
4
  Summary: SQL Dialect Master - Multi-database SQL conversion engine
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -10,6 +10,7 @@ Requires-Dist: fastapi>=0.115.0
10
10
  Requires-Dist: uvicorn>=0.30.0
11
11
  Requires-Dist: pydantic>=2.9.0
12
12
  Requires-Dist: pydantic-settings>=2.0.0
13
+ Requires-Dist: prometheus-client<1.0.0,>=0.20.0
13
14
  Provides-Extra: dev
14
15
  Requires-Dist: hypothesis>=6.112.0; extra == "dev"
15
16
  Requires-Dist: pytest>=8.3.0; extra == "dev"
@@ -35,7 +36,7 @@ Dynamic: license-file
35
36
 
36
37
  Enterprise-grade multi-database SQL conversion engine supporting 12 database dialects.
37
38
 
38
- Current release: 1.1.7
39
+ Current release: 1.1.9
39
40
 
40
41
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
41
42
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -293,6 +294,7 @@ sql-dialect-master/
293
294
  | Endpoint | Method | Description |
294
295
  |----------|--------|-------------|
295
296
  | `/api/convert` | POST | Convert SQL between dialects; response includes `error_code` on failure |
297
+ | `/api/convert/batch` | POST | Convert an ordered batch with one dialect pair and per-item results |
296
298
  | `/api/parse` | POST | Parse SQL and extract structural elements |
297
299
  | `/api/nl2sql` | POST | Generate SQL from natural language |
298
300
  | `/api/functions` | GET | Search SQL functions |
@@ -304,6 +306,24 @@ sql-dialect-master/
304
306
  | `/health/deep` | GET | Deep health check |
305
307
  | `/api/stats` | GET | Service and dataset statistics |
306
308
 
309
+ For example, send this JSON body to `POST /api/convert/batch`:
310
+
311
+ ```json
312
+ {
313
+ "statements": ["SELECT id FROM users", "SELECT user_id FROM orders"],
314
+ "source_dialect": "postgres",
315
+ "target_dialect": "mysql",
316
+ "pretty": true
317
+ }
318
+ ```
319
+
320
+ Results retain input order and include a zero-based `index` alongside the
321
+ single-conversion response fields. The summary includes `count`, `succeeded`,
322
+ and `failed`; top-level `success` is true only when every item succeeds.
323
+ Ordinary item failures, including a batch where every item fails, return HTTP
324
+ 200 with per-item `error` and `error_code`. Request-level errors use the
325
+ existing error envelope; 503/504 responses do not include partial results.
326
+
307
327
  ## 🧪 Quality, Validation & Performance
308
328
 
309
329
  The project maintains a 12 × 12 source-to-target dialect regression matrix, semantic regression tests, post-processing safety regressions, security boundary checks, deterministic rule-engine tests, cache-versioning tests, async concurrency tests, and stable error-code tests.
@@ -316,7 +336,14 @@ The semantic CI gate executes representative conversions against PostgreSQL and
316
336
 
317
337
  Security validation structurally detects stacked SQL statements using the parser when available, with configurable policy controlling whether dangerous input is blocked or only warned about.
318
338
 
319
- The async batch API bounds concurrent work and preserves input order. A reproducible transpiler benchmark is available under `backend/benchmarks/` and is also executed in CI for performance observability. The benchmark intentionally reports measurements without failing the build on a fixed latency threshold because hosted CI timing is variable.
339
+ The SDK's `batch_transpile_async` bounds concurrent work and preserves input
340
+ order. REST batch conversion runs sequentially in one API worker slot, and the
341
+ entire batch shares the default 30-second queue-plus-execution budget
342
+ (`SDM_REQUEST_TIMEOUT`). HTTP 504 does not cancel the background batch. See
343
+ the [deployment limits](docs/deployment.md#1-deployment-model) for capacity
344
+ and timeout behavior.
345
+
346
+ A reproducible transpiler benchmark is available under `backend/benchmarks/` and is also executed in CI for performance observability. The benchmark intentionally reports measurements without failing the build on a fixed latency threshold because hosted CI timing is variable.
320
347
 
321
348
  README source-of-truth checks run in CI and verify the release version, Python requirement, supported dialect list, and benchmark references against repository sources.
322
349
 
@@ -372,3 +399,4 @@ public catalog responses use short `private` caching. Full deployment details
372
399
  will be documented in `docs/deployment.md`.
373
400
 
374
401
  See [`docs/deployment.md`](docs/deployment.md) for production deployment guidance.
402
+ Metrics configuration and scraping guidance are documented in the same guide.
@@ -6,6 +6,7 @@ backend/api/__init__.py
6
6
  backend/api/auth.py
7
7
  backend/api/client_identity.py
8
8
  backend/api/main.py
9
+ backend/api/metrics.py
9
10
  backend/api/middleware.py
10
11
  backend/api/rate_limit_store.py
11
12
  backend/api/readiness.py
@@ -3,6 +3,7 @@ fastapi>=0.115.0
3
3
  uvicorn>=0.30.0
4
4
  pydantic>=2.9.0
5
5
  pydantic-settings>=2.0.0
6
+ prometheus-client<1.0.0,>=0.20.0
6
7
 
7
8
  [dev]
8
9
  hypothesis>=6.112.0