Skip to content

feature(connectors): add GBIF occurrences connector - #56

Merged
kellykohlleffel merged 4 commits into
fivetran:mainfrom
kellykohlleffel:feature/connectors/gbif
Aug 17, 2026
Merged

feature(connectors): add GBIF occurrences connector#56
kellykohlleffel merged 4 commits into
fivetran:mainfrom
kellykohlleffel:feature/connectors/gbif

Conversation

@kellykohlleffel

Copy link
Copy Markdown
Contributor

What this connector does

Syncs species occurrence records from the public GBIF occurrence search API into a single occurrence table keyed on gbif_id. GBIF (Global Biodiversity Information Facility) aggregates biodiversity data from thousands of institutions; each record is one observation or specimen of a species with its taxonomy, coordinates, and event date. The API is public and requires no credentials.

Hazards found while profiling the live API (2026-07-29)

Profiling was done against the live endpoint before any code was written. What it found:

  • Hazard docs: improve README files for batch 03 (quest_db → zigpoll) #8 -- reserved SQL keywords (the designated hazard for this connector, exercised). The occurrence record carries class (e.g. "Magnoliopsida") and order (e.g. "Rosales") as top-level taxonomic fields, and a numeric key that duplicates gbifID. All three are reserved SQL keywords that fail an unquoted CREATE TABLE on most warehouses. Handled by renaming class and order to taxon_class and taxon_order at the source, and dropping key in favour of the string gbif_id primary key. Verified by the shared contract's static reserved-word check and a live DuckDB CREATE TABLE of the delivered schema.
  • Hazard feature(connector_sdk): Rename template_connector #2 -- deep-paging offset cap (also exercised). GBIF caps pagination at offset + limit <= 100000; live, offset=100001 returns HTTP 400, and the unfiltered corpus reports 3,909,145,444 matches. A connector that paged blindly would either 400 mid-sync or silently deliver a truncated table. Handled by reading the total count on the first page, warning loudly when it exceeds the cap, stopping at the cap, and shrinking the final page's limit so no request is ever issued past offset + limit = 100000. The remedy for a larger set is to narrow the query with taxon_key or country. Both debug runs below show the cap warning firing live.
  • Cap + offset off-by-N (the NVD class). Because the cursor is the offset and the connector has a per-sync record cap, a naive implementation that advanced the offset by a full page size after a mid-page stop would skip the page remainder forever. Handled by advancing the offset per record, so max_records_per_sync is a true ceiling. Covered by a drain regression test asserting repeated bounded syncs cover every record exactly once.
  • Cursor design. Occurrence search exposes no stable modified-time ordering (lastInterpreted is rewritten on reprocessing; a Jan-2024 window returned count 0), so a time cursor would skip or duplicate. The offset is the resume cursor for a bounded query. Hazard feature(connector_sdk): Initial community connectors commit #1 (inclusive-range-to-compound-cursor) does not apply: there is no inclusive time-range filter driving the cursor.
  • Page size. The API caps limit at 300 regardless of what is requested; page_size is validated to 1-300.
  • Config completeness. Every configuration key (page_size, max_records_per_sync, taxon_key, country) is validated before any request; numeric checks reject zero where a positive integer is required; the country and taxon_key filters are URL-encoded via urllib.parse. There is no hostname config and no boolean flag, so those hazard classes do not apply.

Debug evidence

Two live fivetran debug runs. The second resumes from the first's checkpoint (offset 150 -> offset 300) rather than restarting:

================================================================================
DEBUG RUN 1 of 2 — fresh sync (no prior state)
command: fivetran debug --configuration configuration_local.json
config:  page_size=100, max_records_per_sync=150, taxon_key=(any), country=(any)
================================================================================
29-Jul 17:16:56.801 INFO  › debugging connector at: .../contributions/gbif
29-Jul 17:16:58.483 INFO  previous state:
{}
29-Jul 17:16:59.774 INFO  calling schema()
29-Jul 17:16:59.785 INFO  schema change detected: tester.occurrence
29-Jul 17:16:59.791 INFO  table created: tester.occurrence
29-Jul 17:16:59.798 INFO  calling update()
29-Jul 17:16:59.798 WARNING Example: Source Examples - GBIF Occurrences
29-Jul 17:16:59.798 INFO  Syncing GBIF occurrences from offset 0 (page size 100, record limit 150, taxon_key any, country any)
29-Jul 17:17:02.466 INFO  GBIF reports 3909145444 occurrences match this query
29-Jul 17:17:02.467 WARNING The query matches 3909145444 occurrences but GBIF only allows paging through the first 100000. Only those are reachable here; narrow the query with taxon_key or country to sync the rest.
29-Jul 17:17:06.005 WARNING Reached the configured max_records_per_sync limit of 150. Synced 150 records through offset 150. The next sync resumes immediately after it.
29-Jul 17:17:06.010 INFO  Sync complete. Upserted 150 occurrences up to offset 150
29-Jul 17:17:06.442 INFO  checkpoint recorded: {"offset": 150}
29-Jul 17:17:06.442 INFO  Final checkpoint: committing any remaining operations
29-Jul 17:17:06.443 INFO  SYNC SUCCEEDED — total elapsed 00:00:07
Operation       | Counts
----------------+------------
Upserts         | 150
Updates         | 0
Deletes         | 0
Truncates       | 0
Schema changes  | 1
Checkpoints     | 1
29-Jul 17:17:09.012 INFO  peak memory used by the debug process: 0.06 GB


================================================================================
DEBUG RUN 2 of 2 — RESUME (reads run 1 checkpoint from files/state.json)
command: fivetran debug --configuration configuration_local.json
================================================================================
29-Jul 17:17:17.187 INFO  › debugging connector at: .../contributions/gbif
29-Jul 17:17:18.535 INFO  previous state:
{"offset": 150}
29-Jul 17:17:19.802 INFO  calling schema()
29-Jul 17:17:19.813 INFO  schema change detected: tester.occurrence
29-Jul 17:17:19.824 INFO  calling update()
29-Jul 17:17:19.824 WARNING Example: Source Examples - GBIF Occurrences
29-Jul 17:17:19.824 INFO  Syncing GBIF occurrences from offset 150 (page size 100, record limit 150, taxon_key any, country any)
29-Jul 17:17:22.618 INFO  GBIF reports 3909145444 occurrences match this query
29-Jul 17:17:22.618 WARNING The query matches 3909145444 occurrences but GBIF only allows paging through the first 100000. Only those are reachable here; narrow the query with taxon_key or country to sync the rest.
29-Jul 17:17:25.649 WARNING Reached the configured max_records_per_sync limit of 150. Synced 150 records through offset 300. The next sync resumes immediately after it.
29-Jul 17:17:25.664 INFO  Sync complete. Upserted 150 occurrences up to offset 300
29-Jul 17:17:26.121 INFO  checkpoint recorded: {"offset": 300}
29-Jul 17:17:26.122 INFO  Final checkpoint: committing any remaining operations
29-Jul 17:17:26.122 INFO  SYNC SUCCEEDED — total elapsed 00:00:07
Operation       | Counts
----------------+------------
Upserts         | 150
Updates         | 0
Deletes         | 0
Truncates       | 0
Schema changes  | 1
Checkpoints     | 1
29-Jul 17:17:28.508 INFO  peak memory used by the debug process: 0.06 GB

--------------------------------------------------------------------------------
RESUME PROOF: run 1 checkpointed at offset 150; run 2 loaded that as its previous
state ({"offset": 150}), synced FROM offset 150 (not from 0), and advanced the
cursor to offset 300. The second run resumed; it did not restart.

HAZARD #2 (deep-paging offset cap) exercised LIVE in both runs: GBIF reports
3,909,145,444 matches for the unfiltered query, and the connector warned that only
the first 100,000 are reachable by offset paging rather than silently delivering a
truncated table. Verified separately during profiling: offset=100001 returns HTTP
400.
--------------------------------------------------------------------------------

Checklist

  • SDK v2+ (op.upsert / op.checkpoint called directly, no yield op.)
  • validate_configuration() called first in update()
  • configuration.json holds placeholder values only; no credentials in the diff
  • Public API, no authentication
  • Passes the shared connector contract (universal rules incl. reserved-word static + live DuckDB CREATE TABLE) plus source-specific tests for the offset cursor, per-record offset advance, deep-paging cap, and the drain test
  • 8-stage pre-submission gate passed (flake8, black --line-length=99, credential scrub)
  • Ships three files: connector.py, configuration.json, README.md

Sync species occurrence records from the public GBIF occurrence search
API. Offset cursor over a bounded query with the deep-paging cap handled
explicitly; the reserved taxonomic fields class and order are renamed to
taxon_class and taxon_order. No credentials required.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The gbif README is not fully compliant with the repo’s README requirements, and connector.py needs small consistency fixes for checkpoint/state handling and standard end-of-file comments.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds a new gbif Connector SDK example that syncs GBIF occurrence records into a single occurrence table using offset-based pagination with a deep-paging cap safeguard and resumable state.

Changes:

  • Added gbif/connector.py implementing schema, configuration validation, retry/backoff, offset cursoring, and checkpointing.
  • Added gbif/configuration.json and gbif/README.md documenting setup, configuration, pagination, and delivered schema.
  • Listed the new gbif connector in the repository root README.md.
File summaries
File Description
README.md Adds the gbif connector to the top-level connector index.
gbif/README.md Documents the connector’s purpose, configuration, pagination approach, and table schema.
gbif/connector.py Implements the GBIF occurrence sync logic, schema, and operational behaviors (retry, pagination cap, state).
gbif/configuration.json Provides placeholder configuration keys for paging and optional query filters.
Review details

Comments suppressed due to low confidence (1)

gbif/connector.py:418

  • Same as above: update the existing state dict before checkpointing to avoid discarding any other state keys (repo examples generally use state[...] = ... then op.checkpoint(state)).
    op.checkpoint(state={"offset": offset})
  • Files reviewed: 4/4 changed files
  • Comments generated: 4
  • Review effort level: Low

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread gbif/README.md
Comment thread gbif/README.md Outdated
Comment thread gbif/connector.py
Comment thread gbif/connector.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (3)

gbif/connector.py:312

  • Same issue as in validate_configuration(): taxon_key = configuration.get(...).strip() and country = ...strip() will raise AttributeError if a user supplies a numeric JSON value or null. Coerce to string (or empty string) before stripping.
    page_size = int(configuration.get("page_size", __DEFAULT_PAGE_SIZE))
    max_records = int(configuration.get("max_records_per_sync", "0"))
    taxon_key = configuration.get("taxon_key", "").strip()
    country = configuration.get("country", "").strip()

gbif/connector.py:100

  • configuration.get("taxon_key", "").strip() and the similar country line assume these config values are always strings. If a user provides taxon_key as a JSON number (e.g. 212) or null, this will raise AttributeError and fail the sync. Coerce to string (or empty string) before calling .strip() so both string and numeric JSON values are accepted.

This issue also appears on line 308 of the same file.

    taxon_key = configuration.get("taxon_key", "").strip()
    if taxon_key and (not taxon_key.isdigit() or int(taxon_key) <= 0):
        raise ValueError(
            f"Invalid configuration value for taxon_key: {taxon_key}. "
            "Must be a positive integer GBIF taxon key, for example 212 for birds."
        )

    country = configuration.get("country", "").strip()
    if country and not re_two_letter(country):

gbif/README.md:95

  • In the Tables created section, the table name is shown as OCCURRENCE, but the connector schema defines the table name as occurrence. Using the exact schema name here avoids confusion for users searching for the table in their destination.
`OCCURRENCE`

@fivetran-JenasVimal fivetran-JenasVimal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@fivetran-JenasVimal

Copy link
Copy Markdown
Contributor

@kellykohlleffel please resolve all you comments and request for a re-review as it is not allowing us to merge the PR

@kellykohlleffel
kellykohlleffel merged commit a0ec691 into fivetran:main Aug 17, 2026
3 checks passed
@kellykohlleffel
kellykohlleffel deleted the feature/connectors/gbif branch August 17, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants