"""Retry Snowflake reads when a Chartmetric share table is briefly missing.
Chartmetric's shared tables (``CHARTMETRIC.RAW_DATA.*``) temporarily disappear
and reappear (typically ~5 minutes later). A read during that window fails with
a ``42S02`` SQL compilation error (``errno`` 2003). Because it is a compilation
error, nothing is executed, so the statement is safe to retry.
This module retries only that specific transient case, with bounded backoff,
and lets every other error propagate immediately.
"""
import functools
import logging
import time
from snowflake.connector.errors import ProgrammingError
logger = logging.getLogger(__name__)
# Chartmetric share is a fixed database name, referenced literally in the flow
# SQL (e.g. `chartmetric.raw_data.itunes`), so it is environment-independent.
CHARTMETRIC_SHARE_PREFIX = 'CHARTMETRIC.RAW_DATA.'
# Bounded retry: delays 5, 10, 20, 40, 80, 120, 120 -> ~6.5 min total, which is
# comfortably past the observed ~5 min reappearance window.
MAX_ATTEMPTS = 8
INITIAL_DELAY = 5
MAX_DELAY = 120
def is_transient_share_miss(exc):
"""Return True only for a missing Chartmetric share table.
Args:
exc (Exception): The exception raised by a Snowflake call.
Returns:
bool: True if `exc` is a `CHARTMETRIC.RAW_DATA.
does not exist`
compilation error (errno 2003), False otherwise.
"""
return (
isinstance(exc, ProgrammingError)
and exc.errno == 2003
and CHARTMETRIC_SHARE_PREFIX in (exc.msg or '').upper()
)
def _reconnect(executor):
"""Reopen the Snowflake connection if it was closed by a failed statement.
The base executor's `get_cursor()` closes the whole connection on any
error, so a retry would otherwise hit a dead connection ('Connection is
closed'). This restores a live connection before the next attempt.
Args:
executor (SnowflakeSQLExecutor): The executor instance to reconnect.
"""
conn = getattr(executor, 'snowflake_conn', None)
if conn is None or conn.is_closed():
executor.snowflake_conn = executor.get_connection()
def retry_on_missing_share_table(method):
"""Retry a Snowflake executor method while a share table is absent.
Retries with exponential backoff when the wrapped call fails because a
`CHARTMETRIC.RAW_DATA.*` table is transiently missing, reopening the
connection between attempts. Any other error (including a genuinely
missing table in our own schema) propagates on the first occurrence.
Args:
method (callable): The bound executor method to wrap. Its first
positional argument is the executor instance (`self`).
Returns:
callable: The wrapped method.
"""
@functools.wraps(method)
def wrapper(self, *args, **kwargs):
delay = INITIAL_DELAY
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
return method(self, *args, **kwargs)
except ProgrammingError as exc:
if not is_transient_share_miss(exc):
raise
if attempt == MAX_ATTEMPTS:
logger.error(
f'Chartmetric share table still unavailable after '
f'{MAX_ATTEMPTS} attempts; giving up ({exc.msg}).')
raise
logger.warning(
f'Chartmetric share table unavailable ({exc.msg}). '
f'Retry {attempt}/{MAX_ATTEMPTS} in {delay}s.')
time.sleep(delay)
delay = min(delay * 2, MAX_DELAY)
_reconnect(self)
return wrapper
class ChartmetricShareRetryMixin:
"""Add Chartmetric share-table retry to a SnowflakeSQLExecutor subclass.
Mix in *before* SnowflakeSQLExecutor so that `super()` resolves to the base
implementation of each read method.
"""
@retry_on_missing_share_table
def execute(self, *args, **kwargs):
"""Execute an SQL statement, retrying transient share-table misses."""
return super().execute(*args, **kwargs)
@retry_on_missing_share_table
def fetchone(self, *args, **kwargs):
"""Fetch one row, retrying transient share-table misses."""
return super().fetchone(*args, **kwargs)
@retry_on_missing_share_table
def fetchall(self, *args, **kwargs):
"""Fetch all rows, retrying transient share-table misses."""
return super().fetchall(*args, **kwargs)