meltano-state-backend-snowflake 0.1.0__py3-none-any.whl
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.
- meltano_state_backend_snowflake/__init__.py +1 -0
- meltano_state_backend_snowflake/backend.py +410 -0
- meltano_state_backend_snowflake/py.typed +0 -0
- meltano_state_backend_snowflake-0.1.0.dist-info/METADATA +129 -0
- meltano_state_backend_snowflake-0.1.0.dist-info/RECORD +8 -0
- meltano_state_backend_snowflake-0.1.0.dist-info/WHEEL +4 -0
- meltano_state_backend_snowflake-0.1.0.dist-info/entry_points.txt +11 -0
- meltano_state_backend_snowflake-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Meltano State Backend for Snowflake."""
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""StateStoreManager for Snowflake state backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import typing as t
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from functools import cached_property
|
|
9
|
+
from time import sleep
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import snowflake.connector
|
|
13
|
+
from meltano.core.error import MeltanoError
|
|
14
|
+
from meltano.core.setting_definition import SettingDefinition, SettingKind
|
|
15
|
+
from meltano.core.state_store.base import (
|
|
16
|
+
MeltanoState,
|
|
17
|
+
MissingStateBackendSettingsError,
|
|
18
|
+
StateIDLockedError,
|
|
19
|
+
StateStoreManager,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if t.TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Generator, Iterable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SnowflakeStateBackendError(MeltanoError):
|
|
27
|
+
"""Base error for Snowflake state backend."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SNOWFLAKE_ACCOUNT = SettingDefinition(
|
|
31
|
+
name="state_backend.snowflake.account",
|
|
32
|
+
label="Snowflake Account",
|
|
33
|
+
description="Snowflake account identifier",
|
|
34
|
+
kind=SettingKind.STRING,
|
|
35
|
+
env_specific=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SNOWFLAKE_USER = SettingDefinition(
|
|
39
|
+
name="state_backend.snowflake.user",
|
|
40
|
+
label="Snowflake User",
|
|
41
|
+
description="Snowflake username",
|
|
42
|
+
kind=SettingKind.STRING,
|
|
43
|
+
env_specific=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
SNOWFLAKE_PASSWORD = SettingDefinition(
|
|
47
|
+
name="state_backend.snowflake.password",
|
|
48
|
+
label="Snowflake Password",
|
|
49
|
+
description="Snowflake password",
|
|
50
|
+
kind=SettingKind.STRING,
|
|
51
|
+
sensitive=True,
|
|
52
|
+
env_specific=True,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
SNOWFLAKE_WAREHOUSE = SettingDefinition(
|
|
56
|
+
name="state_backend.snowflake.warehouse",
|
|
57
|
+
label="Snowflake Warehouse",
|
|
58
|
+
description="Snowflake compute warehouse",
|
|
59
|
+
kind=SettingKind.STRING,
|
|
60
|
+
env_specific=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
SNOWFLAKE_DATABASE = SettingDefinition(
|
|
64
|
+
name="state_backend.snowflake.database",
|
|
65
|
+
label="Snowflake Database",
|
|
66
|
+
description="Snowflake database name",
|
|
67
|
+
kind=SettingKind.STRING,
|
|
68
|
+
env_specific=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
SNOWFLAKE_SCHEMA = SettingDefinition(
|
|
72
|
+
name="state_backend.snowflake.schema",
|
|
73
|
+
label="Snowflake Schema",
|
|
74
|
+
description="Snowflake schema name",
|
|
75
|
+
kind=SettingKind.STRING,
|
|
76
|
+
default="PUBLIC",
|
|
77
|
+
env_specific=True,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
SNOWFLAKE_ROLE = SettingDefinition(
|
|
81
|
+
name="state_backend.snowflake.role",
|
|
82
|
+
label="Snowflake Role",
|
|
83
|
+
description="Snowflake role to use",
|
|
84
|
+
kind=SettingKind.STRING,
|
|
85
|
+
env_specific=True,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SnowflakeStateStoreManager(StateStoreManager):
|
|
90
|
+
"""State backend for Snowflake."""
|
|
91
|
+
|
|
92
|
+
label: str = "Snowflake"
|
|
93
|
+
table_name: str = "meltano_state"
|
|
94
|
+
lock_table_name: str = "meltano_state_locks"
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
uri: str,
|
|
99
|
+
*,
|
|
100
|
+
account: str | None = None,
|
|
101
|
+
user: str | None = None,
|
|
102
|
+
password: str | None = None,
|
|
103
|
+
warehouse: str | None = None,
|
|
104
|
+
database: str | None = None,
|
|
105
|
+
schema: str | None = None,
|
|
106
|
+
role: str | None = None,
|
|
107
|
+
**kwargs: t.Any,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Initialize the SnowflakeStateStoreManager.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
uri: The state backend URI
|
|
113
|
+
account: Snowflake account identifier
|
|
114
|
+
user: Snowflake username
|
|
115
|
+
password: Snowflake password
|
|
116
|
+
warehouse: Snowflake compute warehouse
|
|
117
|
+
database: Snowflake database name
|
|
118
|
+
schema: Snowflake schema name (default: PUBLIC)
|
|
119
|
+
role: Optional Snowflake role to use
|
|
120
|
+
kwargs: Additional keyword args to pass to parent
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
super().__init__(**kwargs)
|
|
124
|
+
self.uri = uri
|
|
125
|
+
parsed = urlparse(uri)
|
|
126
|
+
|
|
127
|
+
# Extract connection details from URI and parameters
|
|
128
|
+
self.account = account or parsed.hostname
|
|
129
|
+
if not self.account:
|
|
130
|
+
msg = "Snowflake account is required"
|
|
131
|
+
raise MissingStateBackendSettingsError(msg)
|
|
132
|
+
|
|
133
|
+
self.user = user or parsed.username
|
|
134
|
+
if not self.user:
|
|
135
|
+
msg = "Snowflake user is required"
|
|
136
|
+
raise MissingStateBackendSettingsError(msg)
|
|
137
|
+
|
|
138
|
+
self.password = password or parsed.password
|
|
139
|
+
if not self.password:
|
|
140
|
+
msg = "Snowflake password is required"
|
|
141
|
+
raise MissingStateBackendSettingsError(msg)
|
|
142
|
+
|
|
143
|
+
self.warehouse = warehouse
|
|
144
|
+
if not self.warehouse:
|
|
145
|
+
msg = "Snowflake warehouse is required"
|
|
146
|
+
raise MissingStateBackendSettingsError(msg)
|
|
147
|
+
|
|
148
|
+
# Extract database from path
|
|
149
|
+
path_parts = parsed.path.strip("/").split("/") if parsed.path else []
|
|
150
|
+
self.database = database or (path_parts[0] if path_parts else None)
|
|
151
|
+
if not self.database:
|
|
152
|
+
msg = "Snowflake database is required"
|
|
153
|
+
raise MissingStateBackendSettingsError(msg)
|
|
154
|
+
|
|
155
|
+
self.schema = schema or (path_parts[1] if len(path_parts) > 1 else "PUBLIC")
|
|
156
|
+
self.role = role
|
|
157
|
+
|
|
158
|
+
self._ensure_tables()
|
|
159
|
+
|
|
160
|
+
@cached_property
|
|
161
|
+
def connection(self) -> snowflake.connector.SnowflakeConnection:
|
|
162
|
+
"""Get a Snowflake connection.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
A Snowflake connection object.
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
conn_params = {
|
|
169
|
+
"account": self.account,
|
|
170
|
+
"user": self.user,
|
|
171
|
+
"password": self.password,
|
|
172
|
+
"warehouse": self.warehouse,
|
|
173
|
+
"database": self.database,
|
|
174
|
+
"schema": self.schema,
|
|
175
|
+
}
|
|
176
|
+
if self.role:
|
|
177
|
+
conn_params["role"] = self.role
|
|
178
|
+
|
|
179
|
+
return snowflake.connector.connect(**conn_params)
|
|
180
|
+
|
|
181
|
+
def _ensure_tables(self) -> None:
|
|
182
|
+
"""Ensure the state and lock tables exist."""
|
|
183
|
+
with self.connection.cursor() as cursor:
|
|
184
|
+
# Create state table
|
|
185
|
+
cursor.execute(
|
|
186
|
+
f"""
|
|
187
|
+
CREATE TABLE IF NOT EXISTS {self.database}.{self.schema}.{self.table_name} (
|
|
188
|
+
state_id VARCHAR PRIMARY KEY,
|
|
189
|
+
partial_state VARIANT,
|
|
190
|
+
completed_state VARIANT,
|
|
191
|
+
updated_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP()
|
|
192
|
+
)
|
|
193
|
+
""",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Create lock table
|
|
197
|
+
cursor.execute(
|
|
198
|
+
f"""
|
|
199
|
+
CREATE TABLE IF NOT EXISTS {self.database}.{self.schema}.{self.lock_table_name} (
|
|
200
|
+
state_id VARCHAR PRIMARY KEY,
|
|
201
|
+
locked_at TIMESTAMP_NTZ DEFAULT CURRENT_TIMESTAMP(),
|
|
202
|
+
lock_id VARCHAR
|
|
203
|
+
)
|
|
204
|
+
""",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def set(self, state: MeltanoState) -> None:
|
|
208
|
+
"""Set the job state for the given state_id.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
state: the state to set.
|
|
212
|
+
|
|
213
|
+
"""
|
|
214
|
+
partial_json = json.dumps(state.partial_state) if state.partial_state else None
|
|
215
|
+
completed_json = json.dumps(state.completed_state) if state.completed_state else None
|
|
216
|
+
|
|
217
|
+
with self.connection.cursor() as cursor:
|
|
218
|
+
cursor.execute(
|
|
219
|
+
f"""
|
|
220
|
+
MERGE INTO {self.database}.{self.schema}.{self.table_name} AS target
|
|
221
|
+
USING (SELECT %s AS state_id, PARSE_JSON(%s) AS partial_state,
|
|
222
|
+
PARSE_JSON(%s) AS completed_state) AS source
|
|
223
|
+
ON target.state_id = source.state_id
|
|
224
|
+
WHEN MATCHED THEN
|
|
225
|
+
UPDATE SET
|
|
226
|
+
partial_state = source.partial_state,
|
|
227
|
+
completed_state = source.completed_state,
|
|
228
|
+
updated_at = CURRENT_TIMESTAMP()
|
|
229
|
+
WHEN NOT MATCHED THEN
|
|
230
|
+
INSERT (state_id, partial_state, completed_state)
|
|
231
|
+
VALUES (source.state_id, source.partial_state, source.completed_state)
|
|
232
|
+
""", # noqa: S608
|
|
233
|
+
(state.state_id, partial_json, completed_json),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def get(self, state_id: str) -> MeltanoState | None:
|
|
237
|
+
"""Get the job state for the given state_id.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
state_id: the name of the job to get state for.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The current state for the given job
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
with self.connection.cursor() as cursor:
|
|
247
|
+
cursor.execute(
|
|
248
|
+
f"""
|
|
249
|
+
SELECT partial_state, completed_state
|
|
250
|
+
FROM {self.database}.{self.schema}.{self.table_name}
|
|
251
|
+
WHERE state_id = %s
|
|
252
|
+
""", # noqa: S608
|
|
253
|
+
(state_id,),
|
|
254
|
+
)
|
|
255
|
+
row = cursor.fetchone()
|
|
256
|
+
|
|
257
|
+
if not row:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
# Snowflake returns None for NULL VARIANT columns
|
|
261
|
+
# but MeltanoState expects empty dicts
|
|
262
|
+
# Additionally, VARIANT columns might return JSON strings that need parsing
|
|
263
|
+
partial_state = row[0]
|
|
264
|
+
completed_state = row[1]
|
|
265
|
+
|
|
266
|
+
# Handle None values
|
|
267
|
+
if partial_state is None:
|
|
268
|
+
partial_state = {}
|
|
269
|
+
# Parse JSON string if Snowflake returns string instead of dict
|
|
270
|
+
elif isinstance(partial_state, str):
|
|
271
|
+
partial_state = json.loads(partial_state)
|
|
272
|
+
|
|
273
|
+
if completed_state is None:
|
|
274
|
+
completed_state = {}
|
|
275
|
+
# Parse JSON string if Snowflake returns string instead of dict
|
|
276
|
+
elif isinstance(completed_state, str):
|
|
277
|
+
completed_state = json.loads(completed_state)
|
|
278
|
+
|
|
279
|
+
return MeltanoState(
|
|
280
|
+
state_id=state_id,
|
|
281
|
+
partial_state=partial_state,
|
|
282
|
+
completed_state=completed_state,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
def delete(self, state_id: str) -> None:
|
|
286
|
+
"""Delete state for the given state_id.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
state_id: the state_id to clear state for
|
|
290
|
+
|
|
291
|
+
"""
|
|
292
|
+
with self.connection.cursor() as cursor:
|
|
293
|
+
cursor.execute(
|
|
294
|
+
f"DELETE FROM {self.database}.{self.schema}.{self.table_name} WHERE state_id = %s", # noqa: S608
|
|
295
|
+
(state_id,),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def clear_all(self) -> int:
|
|
299
|
+
"""Clear all states.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
The number of states cleared from the store.
|
|
303
|
+
|
|
304
|
+
"""
|
|
305
|
+
with self.connection.cursor() as cursor:
|
|
306
|
+
cursor.execute(
|
|
307
|
+
f"SELECT COUNT(*) FROM {self.database}.{self.schema}.{self.table_name}", # noqa: S608
|
|
308
|
+
)
|
|
309
|
+
count = cursor.fetchone()[0] # type: ignore[index]
|
|
310
|
+
cursor.execute(
|
|
311
|
+
f"TRUNCATE TABLE {self.database}.{self.schema}.{self.table_name}",
|
|
312
|
+
)
|
|
313
|
+
return count # type: ignore[no-any-return]
|
|
314
|
+
|
|
315
|
+
def get_state_ids(self, pattern: str | None = None) -> Iterable[str]:
|
|
316
|
+
"""Get all state_ids available in this state store manager.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
pattern: glob-style pattern to filter by
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
An iterable of state_ids
|
|
323
|
+
|
|
324
|
+
"""
|
|
325
|
+
with self.connection.cursor() as cursor:
|
|
326
|
+
if pattern and pattern != "*":
|
|
327
|
+
# Convert glob pattern to SQL LIKE pattern
|
|
328
|
+
sql_pattern = pattern.replace("*", "%").replace("?", "_")
|
|
329
|
+
cursor.execute(
|
|
330
|
+
f"SELECT state_id FROM {self.database}.{self.schema}.{self.table_name} WHERE state_id LIKE %s", # noqa: E501, S608
|
|
331
|
+
(sql_pattern,),
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
cursor.execute(
|
|
335
|
+
f"SELECT state_id FROM {self.database}.{self.schema}.{self.table_name}", # noqa: S608
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
for row in cursor:
|
|
339
|
+
yield row[0]
|
|
340
|
+
|
|
341
|
+
@contextmanager
|
|
342
|
+
def acquire_lock(
|
|
343
|
+
self,
|
|
344
|
+
state_id: str,
|
|
345
|
+
*,
|
|
346
|
+
retry_seconds: int = 1,
|
|
347
|
+
) -> Generator[None, None, None]:
|
|
348
|
+
"""Acquire a lock for the given job's state.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
state_id: the state_id to lock
|
|
352
|
+
retry_seconds: the number of seconds to wait before retrying
|
|
353
|
+
|
|
354
|
+
Yields:
|
|
355
|
+
None
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
StateIDLockedError: if the lock cannot be acquired
|
|
359
|
+
|
|
360
|
+
"""
|
|
361
|
+
import uuid
|
|
362
|
+
|
|
363
|
+
lock_id = str(uuid.uuid4())
|
|
364
|
+
max_seconds = 30
|
|
365
|
+
seconds_waited = 0
|
|
366
|
+
|
|
367
|
+
while seconds_waited < max_seconds: # pragma: no branch
|
|
368
|
+
try:
|
|
369
|
+
with self.connection.cursor() as cursor:
|
|
370
|
+
# Try to acquire lock
|
|
371
|
+
cursor.execute(
|
|
372
|
+
f"""
|
|
373
|
+
INSERT INTO {self.database}.{self.schema}.{self.lock_table_name} (state_id, lock_id)
|
|
374
|
+
VALUES (%s, %s)
|
|
375
|
+
""", # noqa: E501, S608
|
|
376
|
+
(state_id, lock_id),
|
|
377
|
+
)
|
|
378
|
+
break
|
|
379
|
+
except snowflake.connector.errors.ProgrammingError as e:
|
|
380
|
+
# Check if it's a unique constraint violation
|
|
381
|
+
if "Duplicate key" in str(e):
|
|
382
|
+
seconds_waited += retry_seconds
|
|
383
|
+
if seconds_waited >= max_seconds: # Last attempt
|
|
384
|
+
msg = f"Could not acquire lock for state_id: {state_id}"
|
|
385
|
+
raise StateIDLockedError(msg) from e
|
|
386
|
+
sleep(retry_seconds)
|
|
387
|
+
else:
|
|
388
|
+
raise
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
yield
|
|
392
|
+
finally:
|
|
393
|
+
# Release the lock
|
|
394
|
+
with self.connection.cursor() as cursor:
|
|
395
|
+
cursor.execute(
|
|
396
|
+
f"""
|
|
397
|
+
DELETE FROM {self.database}.{self.schema}.{self.lock_table_name}
|
|
398
|
+
WHERE state_id = %s AND lock_id = %s
|
|
399
|
+
""", # noqa: S608
|
|
400
|
+
(state_id, lock_id),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Clean up old locks (older than 5 minutes)
|
|
404
|
+
with self.connection.cursor() as cursor:
|
|
405
|
+
cursor.execute(
|
|
406
|
+
f"""
|
|
407
|
+
DELETE FROM {self.database}.{self.schema}.{self.lock_table_name}
|
|
408
|
+
WHERE locked_at < DATEADD(minute, -5, CURRENT_TIMESTAMP())
|
|
409
|
+
""", # noqa: S608
|
|
410
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: meltano-state-backend-snowflake
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Meltano State Backend for Snowflake
|
|
5
|
+
Author-email: Taylor Murphy <taylor@arch.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: meltano>=3.7
|
|
16
|
+
Requires-Dist: snowflake-connector-python<4,>=3
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# `meltano-state-backend-snowflake`
|
|
20
|
+
|
|
21
|
+
[](https://pypi.org/project/meltano-state-backend-snowflake)
|
|
22
|
+
[](https://pypi.org/project/meltano-state-backend-snowflake)
|
|
23
|
+
|
|
24
|
+
This is a [Meltano][meltano] extension that provides a [Snowflake][snowflake] [state backend][state-backend].
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
This package needs to be installed in the same Python environment as Meltano.
|
|
29
|
+
|
|
30
|
+
### From GitHub
|
|
31
|
+
|
|
32
|
+
#### With [uv]
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv tool install --with meltano-state-backend-snowflake meltano
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
#### With [pipx]
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pipx install meltano
|
|
42
|
+
pipx inject meltano 'meltano-state-backend-snowflake
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
To store state in Snowflake, set the `state_backend.uri` setting to `snowflake://<user>:<password>@<account>/<database>/<schema>`.
|
|
48
|
+
|
|
49
|
+
State will be stored in two tables that Meltano will create automatically:
|
|
50
|
+
- `meltano_state` - Stores the actual state data
|
|
51
|
+
- `meltano_state_locks` - Manages concurrency locks
|
|
52
|
+
|
|
53
|
+
To authenticate to Snowflake, you'll need to provide:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
state_backend:
|
|
57
|
+
uri: snowflake://my_user:my_password@my_account/my_database/my_schema
|
|
58
|
+
snowflake:
|
|
59
|
+
warehouse: my_warehouse # Required: The compute warehouse to use
|
|
60
|
+
role: my_role # Optional: The role to use for the connection
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Alternatively, you can provide credentials via individual settings:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
state_backend:
|
|
67
|
+
uri: snowflake://my_account/my_database/my_schema
|
|
68
|
+
snowflake:
|
|
69
|
+
account: my_account
|
|
70
|
+
user: my_user
|
|
71
|
+
password: my_password
|
|
72
|
+
warehouse: my_warehouse
|
|
73
|
+
database: my_database
|
|
74
|
+
schema: my_schema # Defaults to PUBLIC if not specified
|
|
75
|
+
role: my_role # Optional
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
#### Connection Parameters
|
|
79
|
+
|
|
80
|
+
- **account**: Your Snowflake account identifier (e.g., `myorg-account123`)
|
|
81
|
+
- **user**: The username for authentication
|
|
82
|
+
- **password**: The password for authentication
|
|
83
|
+
- **warehouse**: The compute warehouse to use (required)
|
|
84
|
+
- **database**: The database where state will be stored
|
|
85
|
+
- **schema**: The schema where state tables will be created (defaults to PUBLIC)
|
|
86
|
+
- **role**: Optional role to use for the connection
|
|
87
|
+
|
|
88
|
+
#### Security Considerations
|
|
89
|
+
|
|
90
|
+
When storing credentials:
|
|
91
|
+
- Use environment variables for sensitive values in production
|
|
92
|
+
- Consider using Snowflake key-pair authentication (future enhancement)
|
|
93
|
+
- Ensure the user has CREATE TABLE, INSERT, UPDATE, DELETE, and SELECT privileges
|
|
94
|
+
|
|
95
|
+
Example using environment variables:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
export MELTANO_STATE_BACKEND_SNOWFLAKE_PASSWORD='my_secure_password'
|
|
99
|
+
meltano config meltano set state_backend.uri 'snowflake://my_user@my_account/my_database'
|
|
100
|
+
meltano config meltano set state_backend.snowflake.warehouse 'my_warehouse'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
### Setup
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv sync
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Run tests
|
|
112
|
+
|
|
113
|
+
Run all tests, type checks, linting, and coverage:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uvx -with tox-uv tox run-parallel
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Bump the version
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv version --bump <type>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
[meltano]: https://meltano.com
|
|
126
|
+
[snowflake]: https://www.snowflake.com/
|
|
127
|
+
[state-backend]: https://docs.meltano.com/concepts/state_backends
|
|
128
|
+
[pipx]: https://github.com/pypa/pipx
|
|
129
|
+
[uv]: https://docs.astral.sh/uv
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
meltano_state_backend_snowflake/__init__.py,sha256=VKrpkX_1iA77T_Akw03QYSwhEouiB6WCwk8ni2SAexM,43
|
|
2
|
+
meltano_state_backend_snowflake/backend.py,sha256=f_dVCUG0YYy9z1Rl_oXEgDfBOgTfekTsRZsMmXddk9s,13639
|
|
3
|
+
meltano_state_backend_snowflake/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
meltano_state_backend_snowflake-0.1.0.dist-info/METADATA,sha256=U5abP9PQjKPmB4b0Vo0wgJ6YLit_mE-u-jtDC2JOueY,3944
|
|
5
|
+
meltano_state_backend_snowflake-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
meltano_state_backend_snowflake-0.1.0.dist-info/entry_points.txt,sha256=jZRNuruL8DV7LQshmT9rLCjFWFU1RFDiTKMjSdb1Gak,664
|
|
7
|
+
meltano_state_backend_snowflake-0.1.0.dist-info/licenses/LICENSE,sha256=-5_wfLmGpH1fzTKoBkjWeEnK7cV60Cs0c6Ap3bE9n1U,1064
|
|
8
|
+
meltano_state_backend_snowflake-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[meltano.settings]
|
|
2
|
+
snowflake_account = meltano_state_backend_snowflake.backend:SNOWFLAKE_ACCOUNT
|
|
3
|
+
snowflake_database = meltano_state_backend_snowflake.backend:SNOWFLAKE_DATABASE
|
|
4
|
+
snowflake_password = meltano_state_backend_snowflake.backend:SNOWFLAKE_PASSWORD
|
|
5
|
+
snowflake_role = meltano_state_backend_snowflake.backend:SNOWFLAKE_ROLE
|
|
6
|
+
snowflake_schema = meltano_state_backend_snowflake.backend:SNOWFLAKE_SCHEMA
|
|
7
|
+
snowflake_user = meltano_state_backend_snowflake.backend:SNOWFLAKE_USER
|
|
8
|
+
snowflake_warehouse = meltano_state_backend_snowflake.backend:SNOWFLAKE_WAREHOUSE
|
|
9
|
+
|
|
10
|
+
[meltano.state_backends]
|
|
11
|
+
snowflake = meltano_state_backend_snowflake.backend:SnowflakeStateStoreManager
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Meltano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|