Implement a Partitioned Connector
Goal
Implement a Python connector for {{source_name}} that conforms to both the LakeflowConnect interface and the SupportsPartitionedStream mixin (or SupportsPartition for batch-only partitioning). The implementation should be based on the source API documentation in src/databricks/labs/community_connector/sources/{source_name}/{source_name}_api_doc.md.
CRITICAL REQUIREMENT: Read and follow the patterns in the existing implement-connector SKILL at .claude/skills/implement-connector/SKILL.md — most of its rules (schema, metadata, incremental offsets, API best practices, etc.) apply here as well. This SKILL only covers the additional requirements for partitioned connectors.
Reference Files
- SupportsPartition / SupportsPartitionedStream interface:
src/databricks/labs/community_connector/interface/supports_partition.py - LakeflowConnect interface:
src/databricks/labs/community_connector/interface/lakeflow_connect.py
Choosing the Right Mixin
SupportsPartitionedStream— Use when the connector needs partitioned streaming reads. This also automatically enables partitioned batch reads (inherits fromSupportsPartition). This is the most common choice.SupportsPartition— Use when the connector only needs partitioned batch reads and streaming should remain on the single-driverSimpleDataSourceStreamReaderpath.
Class Declaration
The connector class must extend both LakeflowConnect and the chosen mixin:
from databricks.labs.community_connector.interface import (
LakeflowConnect,
SupportsPartitionedStream,
)
class MyLakeflowConnect(LakeflowConnect, SupportsPartitionedStream):
...
Methods to Implement
From LakeflowConnect (always required)
list_tables()get_table_schema(table_name, table_options)read_table_metadata(table_name, table_options)read_table(table_name, start_offset, table_options)— still required; used bysimpleStreamReaderfor tables whereis_partitioned()returnsFalse
From SupportsPartition (batch partitioning)
get_partitions(table_name, table_options)— Return a list of partition descriptor dicts. Each dict is JSON-serialised and passed toread_partitionon a Spark executor. Keep descriptors small (e.g.,{"page": 3}or{"start": 100, "end": 200}).read_partition(table_name, partition, table_options)— Read records for one partition. This runs on executors and must be self-contained (re-create API clients, do not rely on driver-side state).
Additional from SupportsPartitionedStream (streaming partitioning)
latest_offset(table_name, table_options, start_offset=None)— Return the most recent offset available. Called by Spark every micro-batch.start_offsetis the current committed offset ({}on the first call). Return a dict with primitive values (str, int, bool). Micro-batch sizing (rows per batch, time window, etc.) is the connector's responsibility — use table_options (e.g.window_days,max_records_per_batch) to control it. The engine always requests "all available" and does not pass an admission-control hint.get_partitions(table_name, table_options, start_offset=None, end_offset=None)— Overrides the batch version with optional offset params. Whenstart_offsetandend_offsetare bothNone, behave as batch (partition the entire table). When offsets are provided, partition only the given range. Return an empty list whenstart_offset == end_offset.is_partitioned(table_name)(optional override) — ReturnFalsefor tables that should fall back tosimpleStreamReader. Default isTrue.
How Partitioned Streaming Works
Spark drives each micro-batch in three steps:
latest_offset— Called periodically on the driver to discover new data. Must be a lightweight metadata-only call (e.g., query the source for a max timestamp or latest event ID). Do NOT read actual records here.get_partitions(start, end)— Called with two adjacent offsets from successivelatest_offsetcalls. The first micro-batch hasstart={}(empty dict frominitialOffset), meaning "beginning of time". Split the range(start, end]into partition descriptors.read_partition— Called on executors for each partition. Fetch and return the actual records.
Offset discovery and data reading are separate. Unlike SimpleDataSourceStreamReader where read() discovers and returns data together, here latest_offset discovers what exists (cheap) and read_partition reads it (expensive, parallelised). Reading records inside latest_offset would be catastrophic — it runs on the driver every interval and the records are discarded.
Typical pattern: Source APIs with time-range filters (since/until). latest_offset returns the current high-water mark timestamp. get_partitions splits the time range into windows. read_partition queries each window.
Guaranteeing Termination (Trigger AvailableNow)
The connector runs under Trigger.AvailableNow, which issues microbatches until the source reports "no more data available" — signalled by latest_offset returning the same value as the previous call. There is no explicit stop signal. If latest_offset keeps returning ever-advancing values (e.g. by reflecting continuously-arriving new data), the trigger never terminates and the pipeline hangs.
This is the partitioned-stream equivalent of the end_offset == start_offset rule for the SimpleDataSourceStreamReader path, and it is just as critical.
How to guarantee termination: Cap the offset at init time. Record datetime.now(UTC) in __init__ (self._init_time) and have latest_offset return min(source_high_water_mark, self._init_time). Once the stream catches up to _init_time — either because there was finite data, or because subsequent microbatches have drained everything up to that cap — latest_offset stabilises and the trigger terminates. Data arriving after _init_time is picked up by the next trigger, which creates a fresh connector instance with a newer _init_time.
def __init__(self, options):
super().__init__(options)
self._init_time = datetime.now(timezone.utc).isoformat()
def latest_offset(self, table_name, table_options, start_offset=None):
source_max = self._query_source_high_water_mark(table_name) # e.g. max updated_at
capped = min(source_max, self._init_time) if source_max else self._init_time
return {"cursor": capped}
Do not cap the data returned by read_partition at _init_time; only cap the offset. Records whose cursor is greater than _init_time may still be read — they will simply be re-read by the next trigger with a larger _init_time window. (For CDC tables with primary keys this is safe via upsert; for append-only tables, design partitions so the cap boundary does not produce duplicates.)
Partitioning Design Guidelines
Partition Granularity
- Each partition should represent a bounded unit of work (e.g., one API page, one time range, one shard).
- Aim for partitions that each take roughly the same time to process.
- Too few partitions underutilise parallelism; too many create scheduling overhead.
Partition Descriptors Must Be Self-Contained
read_partitionruns on executors without access to driver state.- The partition dict must contain everything needed to fetch the data (e.g., page number, offset range, API URL parameters).
- Re-create API clients inside
read_partitionusingself.optionsfor credentials.
Batch vs Stream Partitioning
- Batch (
get_partitions(table_name, table_options)with no offsets): Partition the entire table. Example: if the API has 10 pages, return 10 partition dicts. - Stream (
get_partitions(table_name, table_options, start, end)): Partition only the offset range. Example: ifstart={"page": 3}andend={"page": 7}, return partitions for pages 4-7.
Error Handling
- For batch reads, if
get_partitionsthrows an exception, the framework automatically falls back to single-partitionread_table. So it is safe to raise if partitioning is not feasible for a particular call. - For streaming reads, there is no such fallback within a micro-batch — exceptions propagate. Use
is_partitioned()to opt out specific tables instead.