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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/meltano-state-backend-snowflake.svg?logo=pypi&logoColor=FFE873&color=blue)](https://pypi.org/project/meltano-state-backend-snowflake)
22
+ [![Python versions](https://img.shields.io/pypi/pyversions/meltano-state-backend-snowflake.svg?logo=python&logoColor=FFE873)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.