feat: add moss-connector-s3 — index an S3 bucket, re-index on change - #402
Conversation
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…n change)
Adds an Amazon S3 source connector following the _template pattern:
- S3Connector lists a bucket via list_objects_v2 (auto pagination),
supports prefix (server-side) and suffix (client-side) filtering,
decodes object bodies, and hands the mapper a row dict with key,
text, etag, last_modified, size, content_type, and user metadata
- watch() ingests once, then polls {key: etag} snapshots and rebuilds
the index when objects are added, removed, or modified (the
watch-a-bucket story from usemoss#381); polls only list keys, bodies are
fetched only when a re-index runs
- ingest() copied verbatim from the template, kept in sync with the
other connector packages
- 12 moto-mocked unit tests (no AWS needed), live integration tests
for MinIO/LocalStack or real AWS, demo.py walkthrough, README,
.env.example
- parent README layout + connector table updated
Closes usemoss#381
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new Python connector package under packages/moss-data-connector/ that can ingest documents from an Amazon S3 bucket into a Moss index, plus a polling-based watch() helper that re-indexes when bucket contents change (based on {key: etag} snapshots). This fits the existing moss-data-connector ecosystem by following the _template ingest pattern and matching the boto3 style used by the DynamoDB connector.
Changes:
- Added
moss-connector-s3package:S3Connector(list/get + filtering + snapshot),ingest(), andwatch()re-index loop. - Added unit tests (moto-backed) and live integration tests (skip-gated) for S3→Moss ingest + watch behavior.
- Added docs/demo assets and registered the connector in the parent
moss-data-connectorREADME.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/moss-data-connector/README.md | Registers the new S3 connector in the connector list/table. |
| packages/moss-data-connector/moss-connector-s3/src/init.py | Re-exports S3Connector, ingest, and watch as the package API. |
| packages/moss-data-connector/moss-connector-s3/src/connector.py | Implements S3 listing, filtering, object download/decoding, and snapshotting. |
| packages/moss-data-connector/moss-connector-s3/src/ingest.py | Template-style ingest helper that (re)creates a Moss index from a source iterable. |
| packages/moss-data-connector/moss-connector-s3/src/watch.py | Implements polling loop to detect bucket changes via snapshots and re-ingest. |
| packages/moss-data-connector/moss-connector-s3/tests/test_s3.py | Moto-based unit tests for filtering, pagination, snapshot diffing, and watch behavior. |
| packages/moss-data-connector/moss-connector-s3/tests/test_integration_s3_moss.py | Skip-gated live integration tests for end-to-end ingest/query and watch re-indexing. |
| packages/moss-data-connector/moss-connector-s3/README.md | End-user docs: usage, mapper schema, filtering, pagination, and MinIO/LocalStack notes. |
| packages/moss-data-connector/moss-connector-s3/pyproject.toml | Defines package metadata, deps (boto3/moss), and dev/test tooling config. |
| packages/moss-data-connector/moss-connector-s3/demo.py | Runnable demo script showing bucket setup → ingest → query → watch → cleanup. |
| packages/moss-data-connector/moss-connector-s3/.gitignore | Package-local ignores for venv/build/test caches and .env. |
| packages/moss-data-connector/moss-connector-s3/.env.example | Example env vars for S3 endpoint + Moss credentials. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
@cubic-dev-ai review this pull request |
@HarshaNalluru I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 12 files
Tip: instead of fixing issues one by one fix them all with cubic
Re-trigger cubic
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
- watch(): delete the Moss index when every matching object is removed from the bucket — ingest() no-ops on an empty source, which previously left stale documents searchable forever (P0) - watch(): run bucket snapshots and object downloads in a worker thread via asyncio.to_thread so boto3's synchronous I/O no longer blocks the event loop; ingest.py stays verbatim per the template rule - watch(): await async on_change callbacks via inspect.isawaitable (covers Futures and custom awaitables, not just coroutines) - connector: take etag/last_modified/size from the get_object response instead of the stale list entry, so each row describes one consistent object version under concurrent overwrites - connector: skip objects deleted between listing and fetching (NoSuchKey/404) instead of aborting the whole iteration - demo: guard .env discovery against shallow checkouts and fix the mislabeled parents[] depths - README: handle ingest() returning None in the example; clarify auto_id belongs to ingest(), document empty-bucket index deletion - pyproject: add the Python 3.14 classifier Adds unit tests for the empty-bucket deletion and async on_change paths (14 total, all passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
High: watch() re-index calls create_index() on an index that already exists
watch() -> _sync(empty=False) -> ingest() -> MossClient.create_index(index_name, docs). The initial ingest creates the index, but every subsequent change re-enters the same path and calls create_index() again on a name that already exists (src/watch.py:846 initial, src/watch.py:856 on each change).
Evidence in this repo that create_index is create-only, not idempotent:
- packages/agno-moss/src/agno_moss/runtime.py:141-147 is the one first-party package that updates an existing index, and it explicitly branches: create_index only when not exists(), else add_docs(..., upsert=True). If create_index overwrote, that branch would not be needed.
- The SDK docstring reads "Create a new index" (sdks/python/sdk/src/moss/client/moss_client.py:85).
- No test or example anywhere in the repo calls create_index twice on the same name. Every SDK test uses generate_unique_index_name(...) and deletes on teardown.
Why the current tests do not catch it: FakeMossClient.create_index (tests/test_s3.py) just appends to a list and never errors on a duplicate name, so test_watch_reindexes_on_change asserting len(calls) == 2 passes regardless. The integration test would exercise the real backend but is skip-gated, and the reported test results only cover test_s3.py. The live watch path and demo.py step 5 appear not to have been run end to end. The core create_index lives in the compiled inferedge-moss-core crate, so this cannot be proven statically, but the convention evidence is strong enough that it should be verified before merge.
Same bug, second trigger: if watch() is restarted while the index already exists from a prior run, the initial _sync(empty=False) hits it immediately.
Suggested fix: make the rebuild path delete-then-create, mirroring the empty branch that already deletes and matching the documented "re-creates the index on every bucket change" intent:
async def _sync(..., *, empty: bool) -> None:
client = MossClient(project_id, project_key)
try:
await client.delete_index(index_name)
except Exception:
pass # not-found on first run / after an empty transition is fine
if empty:
return
docs = await asyncio.to_thread(list, source)
await ingest(docs, project_id, project_key, index_name, model_id=model_id, auto_id=auto_id)
Then add a test where the fake raises on duplicate-name create_index, and run the integration test once against MinIO plus a real Moss project to confirm.
Medium: full re-embed of the whole bucket on every change
Even after the fix above, rebuilding re-embeds every object whenever any single one changes. A one-file edit in a 10k-object bucket re-embeds all 10k, which is costly in time and embedding spend. The snapshot diff already knows exactly which keys were added, removed, or modified, so incremental add_docs plus delete_docs would be much cheaper. Full rebuild is a reasonable v1 for correctness since it is the only way to reflect deletes without diffing, but the tradeoff is worth calling out in the README watch section, with incremental sync as a follow-up. Not a blocker.
Medium: delete_index in the empty branch is not guarded
_sync's empty branch calls delete_index(index_name) unguarded (src/watch.py:886). agno-moss wraps every delete_index in try/except because it can raise when the index is absent. In watch() the empty branch only fires on a non-empty to empty transition, so the index should exist, but a concurrent external delete would then crash the watcher. Wrap it defensively. The suggested fix above centralizes this anyway.
Low / nits
- ingest.py is byte-identical to _template/src/ingest.py, consistent with the keep-in-sync rule. Good.
- The parent README already omits moss-connector-huggingface. Since this PR edits that exact table, adding the missing huggingface row would be a cheap cleanup. Optional.
- _client() builds a fresh boto3.client("s3") on every snapshot() and every iter (src/connector.py:669). For a 60s-interval watcher this is negligible and matches the DynamoDB connector's per-iter resource creation, so it is convention-consistent. Caching on the instance would trim minor overhead.
- page_size default 1000 vs DynamoDB's 100 is correct, since 1000 is the S3 list_objects_v2 maximum, and it is documented.
…reate-only Every re-index (and the initial one, in case a prior watch() run left the index behind) now deletes the existing index before calling create_index, mirroring the empty-bucket branch. The delete is best-effort so a missing index on first run — or a concurrent external delete — can't crash the watcher, which also resolves the unguarded delete_index in the empty branch. FakeMossClient now mimics the real client's strictness (create_index raises on a duplicate name, delete_index raises when absent), so the watch tests would catch this regression; adds a restart-with-existing- index test for the second trigger. README: document the full-rebuild cost per change (delete + re-download + re-embed the whole bucket) with incremental add_docs/delete_docs sync as a planned follow-up; add the missing huggingface row to the parent connector table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@HarshaNalluru done with the changes |
|
@Sravan1011 please resolve the conversations. |
I have resolved all, can you specify which one ? I have also made change for the latest comment also |
|
@Sravan1011 go to each of the comments to check which ones are unaddressed. |
…orker thread The package now exports ingest() from a new aio.py wrapper: same signature and contract as the shared template ingest(), but the source is materialized via asyncio.to_thread before delegating, so boto3's synchronous bucket downloads no longer stall every other coroutine. ingest.py itself stays byte-identical to _template/src/ingest.py per the keep-in-sync rule — the async path lives in the connector's own code, the same placement the template prescribes for retry logic. watch()'s _sync now reuses the wrapper instead of hand-rolling the same to_thread materialization. Adds a test that the exported ingest() accepts pre-materialized iterables (16 tests total, all passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codex reviewNo issues found. |
…marker snapshots Addresses two Codex review findings: - BLOCKING: watch()'s rebuild deleted the live index before the S3 download/decode/mapper path had succeeded, so a transient S3 error or one bad object body could leave the index deleted with no replacement. _sync now downloads and validates the full replacement list first; only with the materialized docs in hand does it delete the old index and create the new one. A failure during materialization propagates while the live index is still intact. If the materialized list is empty (bucket emptied, or all objects vanished between snapshot and download), only the delete runs. - snapshot() markers now combine ETag, LastModified, and Size instead of ETag alone — metadata-only rewrites keep the content hash but bump LastModified, and the mapper exposes metadata/content_type/ last_modified, so those updates must trigger a re-index too. Adds tests for both: a poison-mapper rebuild that fails mid-download must leave the old index untouched, and the snapshot marker must embed LastModified/Size (18 tests, all passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… failures Addresses the review finding that a rebuild removed the current index before Moss had accepted the replacement: a transient Moss error, invalid metadata, quota/model failure, or network interruption during create_index left users without search until a later successful rebuild. watch() no longer deletes-and-recreates. It now syncs by diff, per the reviewer's suggested alternative, using the SDK surface agno-moss already relies on (add_docs with MutationOptions(upsert=True), delete_docs, get_docs, list_indexes): - startup: create the index if absent; otherwise reconcile a survivor in place — upsert current docs, purge stale ones — never delete/recreate - on change: download only the objects whose version markers changed and push the diff (upsert additions/modifications, delete_docs removals); a one-file edit no longer re-embeds the whole bucket, which also resolves the earlier full-re-embed cost finding - failure safety: any S3 or Moss failure mid-sync propagates with the existing index intact; the index is deleted only when the bucket empties (guarded against concurrent external deletes) - S3Connector grows fetch(keys) yielding (key, DocumentInfo) for the changed keys only; __iter__ shares the same per-object fetch path - watch() drops auto_id: diff updates are applied by document id, so mappers must produce stable ids (documented in README + docstrings) Tests rewritten around a stateful fake that stores docs per index and raises on duplicate create / missing index: incremental add, modify, remove, restart reconciliation, failed-sync safety, emptied bucket, async on_change (20 tests, all passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The empty-bucket branches previously swallowed every delete_index failure, so an auth, network, or server error was treated as success: watch() marked the index gone while stale documents stayed searchable, and unchanged later polls would never retry. The SDK exposes no typed not-found error, so absence is now confirmed positively instead of inferred from a failure: _delete_index_if_exists checks list_indexes() first, skips only when the index is confirmed missing (e.g. deleted externally), and lets every actual delete_index failure propagate. index_exists flips to False only after a confirmed delete or confirmed absence, and a raised error leaves unadvanced so the empty transition is still pending on restart. Tests: a simulated moss-unreachable delete now propagates with the index still live, and an externally deleted index is a clean no-op (22 tests, all passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
index_exists was only computed at startup, so if the Moss index was deleted externally mid-run while the bucket stayed non-empty, the next add/modify path called add_docs on a missing index and the watcher exited instead of recovering. Existence is now re-confirmed via list_indexes() before each mutation. When the index is gone, it is recreated from the full current bucket snapshot (source.fetch over every current key) with key_ids rebuilt -- not from only the changed keys, which would have produced an index missing every unchanged document. This also unifies the previously separate create-on-first-objects path. Test: dropping the index externally plus adding an object yields create + create-from-full-bucket, with all five docs present (23 tests, all passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…semoss#402) ## Summary Adds an Amazon S3 source connector, built from the `packages/moss-data-connector/_template` pattern (with the DynamoDB connector as the boto3 style reference). It indexes documents from an S3 bucket and — per the watch-a-bucket story — re-indexes automatically when the bucket contents change. Closes usemoss#381 ## What's included **`S3Connector`** (`src/connector.py`) - Lists the bucket with `list_objects_v2`, following continuation tokens automatically - `prefix` filtering server-side, `suffix` filtering client-side (`".md"` or a tuple like `(".md", ".txt")`) — non-matching objects are skipped without being downloaded - Zero-byte "folder" placeholder keys (ending in `/`) always skipped - Hands the mapper a row dict: `key`, `text` (decoded body, configurable encoding), `etag`, `last_modified`, `size`, `content_type`, and S3 user metadata - `**boto3_kwargs` passthrough (`region_name`, `endpoint_url`, credentials) so MinIO/LocalStack work out of the box - `snapshot()` returns `{key: etag}` from a list-only pass — no bodies downloaded **`watch()`** (`src/watch.py`) — the re-index-on-change piece - Ingests once, then polls `snapshot()` on `poll_interval` and rebuilds the index whenever an object is added, removed, or modified - Snapshot is taken *before* each ingest, so changes landing mid-ingest are caught on the next poll rather than lost - `max_polls` for one-shot sync jobs and tests; `on_change` callback with the new snapshot - Per the template rules, sync/loop logic lives in the connector's own code — `ingest.py` is copied verbatim from the template **Tests** - `tests/test_s3.py` — 12 unit tests, moto-mocked, no AWS needed: ingest end-to-end, prefix/suffix filters, pagination (`page_size=1`), empty bucket, `auto_id`, user-metadata passthrough, snapshot add/modify/delete tracking, watch re-index on change, and watch no-op when unchanged - `tests/test_integration_s3_moss.py` — live round trip (S3 → ingest → Moss query → cleanup) plus a live watch test; skip-gated the same way as the DynamoDB connector (`S3_ENDPOINT_URL` for MinIO/LocalStack, or `MOSS_CONNECTOR_S3_ALLOW_AWS=1` to opt in to real AWS) **Docs & extras** - Package README with one-shot ingest and watch-a-bucket usage, mapper row reference, MinIO/LocalStack notes - `demo.py` end-to-end walkthrough (create bucket → upload → ingest → query → add object → watch re-indexes → cleanup) - `.env.example`, and a new row in the parent `moss-data-connector` README layout + table ## Test results ``` tests/test_s3.py ............ 12 passed ruff check . All checks passed! ``` (Unit tests run fully offline via moto. Integration tests verified to collect and skip cleanly without credentials.) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

Summary
Adds an Amazon S3 source connector, built from the
packages/moss-data-connector/_templatepattern (with the DynamoDB connector as the boto3 style reference). It indexes documents from an S3 bucket and — per the watch-a-bucket story — re-indexes automatically when the bucket contents change.Closes #381
What's included
S3Connector(src/connector.py)list_objects_v2, following continuation tokens automaticallyprefixfiltering server-side,suffixfiltering client-side (".md"or a tuple like(".md", ".txt")) — non-matching objects are skipped without being downloaded/) always skippedkey,text(decoded body, configurable encoding),etag,last_modified,size,content_type, and S3 user metadata**boto3_kwargspassthrough (region_name,endpoint_url, credentials) so MinIO/LocalStack work out of the boxsnapshot()returns{key: etag}from a list-only pass — no bodies downloadedwatch()(src/watch.py) — the re-index-on-change piecesnapshot()onpoll_intervaland rebuilds the index whenever an object is added, removed, or modifiedmax_pollsfor one-shot sync jobs and tests;on_changecallback with the new snapshotingest.pyis copied verbatim from the templateTests
tests/test_s3.py— 12 unit tests, moto-mocked, no AWS needed: ingest end-to-end, prefix/suffix filters, pagination (page_size=1), empty bucket,auto_id, user-metadata passthrough, snapshot add/modify/delete tracking, watch re-index on change, and watch no-op when unchangedtests/test_integration_s3_moss.py— live round trip (S3 → ingest → Moss query → cleanup) plus a live watch test; skip-gated the same way as the DynamoDB connector (S3_ENDPOINT_URLfor MinIO/LocalStack, orMOSS_CONNECTOR_S3_ALLOW_AWS=1to opt in to real AWS)Docs & extras
demo.pyend-to-end walkthrough (create bucket → upload → ingest → query → add object → watch re-indexes → cleanup).env.example, and a new row in the parentmoss-data-connectorREADME layout + tableTest results
(Unit tests run fully offline via moto. Integration tests verified to collect and skip cleanly without credentials.)