sofhiaazzhr Claude Opus 4.8 commited on
Commit
3f48226
·
1 Parent(s): b8b76c4

[NOTICKET] feat(storage): read tabular Parquet from Supabase S3 via provider toggle

Browse files

TabularExecutor was hard-wired to Azure Blob (az_blob:// location_ref +
AzureBlobStorage). After the Go data plane switched storage provider to
Supabase S3, location_ref became object_storage://... and tabular queries
failed with a ValueError before download.

- settings: add STORAGE_PROVIDER toggle + SUPABASE_S3_* config (mirrors Go)
- storage/object_storage: new isolated boto3 Supabase S3 read client
- tabular executor: accept object_storage:// prefix; pick backend by toggle

Bridge (Option C) until Go exposes a source-data endpoint; see
docs/proposal_go_source_data_endpoint.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

src/config/settings.py CHANGED
@@ -63,6 +63,21 @@ class Settings(BaseSettings):
63
  azureai_container_name: str = Field(alias="azureai__container__name", default="")
64
  azureai_container_account_name: str = Field(alias="azureai__container__account__name", default="")
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  # Langfuse
67
  LANGFUSE_PUBLIC_KEY: str
68
  LANGFUSE_SECRET_KEY: str
 
63
  azureai_container_name: str = Field(alias="azureai__container__name", default="")
64
  azureai_container_account_name: str = Field(alias="azureai__container__account__name", default="")
65
 
66
+ # Object storage provider toggle. Mirrors the Go data plane's `storage.provider`
67
+ # (see Orchestrator config: azure_blob | supabase_s3). Blank falls back to azure_blob.
68
+ # Tabular query execution reads the processed Parquet from whichever backend is active.
69
+ storage_provider: str = Field(alias="STORAGE_PROVIDER", default="azure_blob")
70
+
71
+ # Supabase S3-compatible object storage (active when storage_provider == "supabase_s3").
72
+ # BRIDGE (Option C): lets Python read the same bucket Go writes to, until Go exposes a
73
+ # source-data endpoint (see docs/proposal_go_source_data_endpoint.md) — then this client
74
+ # is removed and Python stops touching storage directly.
75
+ supabase_s3_bucket: str = Field(alias="SUPABASE_S3_BUCKET", default="")
76
+ supabase_s3_endpoint: str = Field(alias="SUPABASE_S3_ENDPOINT", default="")
77
+ supabase_s3_region: str = Field(alias="SUPABASE_S3_REGION", default="")
78
+ supabase_s3_access_key_id: str = Field(alias="SUPABASE_S3_ACCESS_KEY_ID", default="")
79
+ supabase_s3_secret_access_key: str = Field(alias="SUPABASE_S3_SECRET_ACCESS_KEY", default="")
80
+
81
  # Langfuse
82
  LANGFUSE_PUBLIC_KEY: str
83
  LANGFUSE_SECRET_KEY: str
src/query/executor/tabular.py CHANGED
@@ -29,13 +29,18 @@ from .base import BaseExecutor, QueryResult
29
  logger = get_logger("tabular_executor")
30
 
31
  _AZ_BLOB_PREFIX = "az_blob://"
 
 
 
 
32
  _ROW_HARD_CAP = 10_000
33
 
34
 
35
  class TabularExecutor(BaseExecutor):
36
  """Executes compiled pandas chain on a Parquet blob.
37
 
38
- `fetch_blob` is injectable for tests — defaults to AzureBlobStorage.
 
39
  """
40
 
41
  def __init__(
@@ -49,6 +54,16 @@ class TabularExecutor(BaseExecutor):
49
 
50
  @staticmethod
51
  async def _default_fetch_blob(blob_name: str) -> bytes:
 
 
 
 
 
 
 
 
 
 
52
  from ...storage.az_blob.az_blob import blob_storage
53
 
54
  return await blob_storage.download_file(blob_name)
@@ -157,15 +172,19 @@ def _resolve_blob_name(source: Source, table: Table) -> str:
157
  on the upload pipeline preserving the file extension, which it does today
158
  because `Document.filename` is set once at upload and never renamed.
159
  """
160
- if not source.location_ref.startswith(_AZ_BLOB_PREFIX):
 
 
 
 
161
  raise ValueError(
162
- f"TabularExecutor expects 'az_blob://...' location_ref, "
163
- f"got {source.location_ref!r}"
164
  )
165
- path = source.location_ref[len(_AZ_BLOB_PREFIX):]
166
  parts = path.split("/", 1)
167
  if len(parts) != 2 or not parts[0] or not parts[1]:
168
- raise ValueError(f"Malformed az_blob location_ref: {source.location_ref!r}")
169
  user_id, document_id = parts
170
  is_xlsx = source.name.lower().endswith(".xlsx")
171
  sheet_name = table.name if is_xlsx else None
 
29
  logger = get_logger("tabular_executor")
30
 
31
  _AZ_BLOB_PREFIX = "az_blob://"
32
+ # Go's Supabase S3 data plane writes location_ref with this prefix instead of az_blob://.
33
+ # Both encode the same path structure after the prefix: {user_id}/{document_id}.
34
+ _OBJECT_STORAGE_PREFIX = "object_storage://"
35
+ _LOCATION_REF_PREFIXES = (_AZ_BLOB_PREFIX, _OBJECT_STORAGE_PREFIX)
36
  _ROW_HARD_CAP = 10_000
37
 
38
 
39
  class TabularExecutor(BaseExecutor):
40
  """Executes compiled pandas chain on a Parquet blob.
41
 
42
+ `fetch_blob` is injectable for tests — defaults to the storage backend
43
+ selected by `settings.storage_provider` (azure_blob | supabase_s3).
44
  """
45
 
46
  def __init__(
 
54
 
55
  @staticmethod
56
  async def _default_fetch_blob(blob_name: str) -> bytes:
57
+ # Pick the storage backend by the same toggle the Go data plane uses.
58
+ # Blank/unknown falls back to Azure Blob (the pre-migration default).
59
+ from ...config.settings import settings
60
+
61
+ provider = (settings.storage_provider or "").strip().lower()
62
+ if provider == "supabase_s3":
63
+ from ...storage.object_storage import object_storage
64
+
65
+ return await object_storage.download_file(blob_name)
66
+
67
  from ...storage.az_blob.az_blob import blob_storage
68
 
69
  return await blob_storage.download_file(blob_name)
 
172
  on the upload pipeline preserving the file extension, which it does today
173
  because `Document.filename` is set once at upload and never renamed.
174
  """
175
+ matched_prefix = next(
176
+ (p for p in _LOCATION_REF_PREFIXES if source.location_ref.startswith(p)),
177
+ None,
178
+ )
179
+ if matched_prefix is None:
180
  raise ValueError(
181
+ f"TabularExecutor expects 'az_blob://...' or 'object_storage://...' "
182
+ f"location_ref, got {source.location_ref!r}"
183
  )
184
+ path = source.location_ref[len(matched_prefix):]
185
  parts = path.split("/", 1)
186
  if len(parts) != 2 or not parts[0] or not parts[1]:
187
+ raise ValueError(f"Malformed location_ref: {source.location_ref!r}")
188
  user_id, document_id = parts
189
  is_xlsx = source.name.lower().endswith(".xlsx")
190
  sheet_name = table.name if is_xlsx else None
src/storage/object_storage/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase S3-compatible object storage (read side).
2
+
3
+ BRIDGE MODULE (Option C) — self-contained on purpose. It exists only so the
4
+ tabular query path can read the processed Parquet from the same bucket the Go
5
+ data plane writes to, after Go switched storage provider Azure Blob -> Supabase S3.
6
+
7
+ Once Go exposes a source-data endpoint (docs/proposal_go_source_data_endpoint.md),
8
+ delete this whole package and point TabularExecutor at that endpoint instead.
9
+ """
10
+
11
+ from .supabase_s3 import object_storage
12
+
13
+ __all__ = ["object_storage"]
src/storage/object_storage/supabase_s3.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Supabase S3-compatible object storage client (read side).
2
+
3
+ Mirrors the Go data plane's Supabase S3 client (Orchestrator
4
+ `internal/documents/supabase_s3.go`): path-style addressing against a custom
5
+ endpoint, static credentials, single bucket.
6
+
7
+ boto3 is synchronous; download runs in a worker thread so the async call sites
8
+ (TabularExecutor._fetch_blob) stay non-blocking. Only the read path is
9
+ implemented — Python is read-only here; Go owns all writes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+
16
+ from src.config.settings import settings
17
+ from src.middlewares.logging import get_logger
18
+
19
+ logger = get_logger("supabase_s3")
20
+
21
+
22
+ class SupabaseS3Storage:
23
+ """Read-only client for the Supabase S3 bucket Go writes to."""
24
+
25
+ def __init__(self) -> None:
26
+ self._bucket = settings.supabase_s3_bucket
27
+ self._endpoint = settings.supabase_s3_endpoint.rstrip("/")
28
+ self._region = settings.supabase_s3_region
29
+ self._access_key_id = settings.supabase_s3_access_key_id
30
+ self._secret_access_key = settings.supabase_s3_secret_access_key
31
+ self._client = None # lazy — avoid import-time failure on Azure deployments
32
+
33
+ def _is_configured(self) -> bool:
34
+ return all(
35
+ (
36
+ self._bucket,
37
+ self._endpoint,
38
+ self._region,
39
+ self._access_key_id,
40
+ self._secret_access_key,
41
+ )
42
+ )
43
+
44
+ def _get_client(self):
45
+ if self._client is None:
46
+ if not self._is_configured():
47
+ raise RuntimeError(
48
+ "Supabase S3 storage is not fully configured "
49
+ "(set STORAGE_PROVIDER=supabase_s3 and SUPABASE_S3_* env vars)"
50
+ )
51
+ import boto3
52
+ from botocore.config import Config
53
+
54
+ self._client = boto3.client(
55
+ "s3",
56
+ endpoint_url=self._endpoint,
57
+ region_name=self._region,
58
+ aws_access_key_id=self._access_key_id,
59
+ aws_secret_access_key=self._secret_access_key,
60
+ config=Config(
61
+ signature_version="s3v4",
62
+ s3={"addressing_style": "path"}, # path-style, like Go's UsePathStyle
63
+ ),
64
+ )
65
+ return self._client
66
+
67
+ def _download_sync(self, object_name: str) -> bytes:
68
+ client = self._get_client()
69
+ resp = client.get_object(Bucket=self._bucket, Key=object_name)
70
+ return resp["Body"].read()
71
+
72
+ async def download_file(self, object_name: str) -> bytes:
73
+ """Download an object's bytes. Interface-compatible with AzureBlobStorage.download_file."""
74
+ try:
75
+ logger.info(f"Downloading object {object_name}")
76
+ content = await asyncio.to_thread(self._download_sync, object_name)
77
+ logger.info(f"Successfully downloaded {object_name} ({len(content)} bytes)")
78
+ return content
79
+ except Exception as e:
80
+ logger.error(f"Failed to download object {object_name}", error=str(e))
81
+ raise
82
+
83
+
84
+ # Singleton (lazy client construction — safe to import even when not configured)
85
+ object_storage = SupabaseS3Storage()