Skip to content

feat: add moss-connector-s3 — index an S3 bucket, re-index on change - #402

Merged
HarshaNalluru merged 19 commits into
usemoss:mainfrom
Sravan1011:feat/moss-connector-s3
Jul 25, 2026
Merged

feat: add moss-connector-s3 — index an S3 bucket, re-index on change#402
HarshaNalluru merged 19 commits into
usemoss:mainfrom
Sravan1011:feat/moss-connector-s3

Conversation

@Sravan1011

@Sravan1011 Sravan1011 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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 #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.)

Sravan1011 and others added 10 commits June 26, 2026 08:20
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>

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

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-s3 package: S3Connector (list/get + filtering + snapshot), ingest(), and watch() 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-connector README.

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.

Comment thread packages/moss-data-connector/moss-connector-s3/src/watch.py Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@HarshaNalluru

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this pull request

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review this pull request

@HarshaNalluru I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 12 files

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread packages/moss-data-connector/moss-connector-s3/src/watch.py Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/src/ingest.py
Comment thread packages/moss-data-connector/moss-connector-s3/demo.py Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/README.md Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/README.md Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/src/connector.py Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/src/connector.py Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/pyproject.toml
Comment thread packages/moss-data-connector/moss-connector-s3/src/watch.py Outdated
Comment thread packages/moss-data-connector/moss-connector-s3/src/watch.py Outdated
Sravan1011 and others added 2 commits July 18, 2026 09:23
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>

@HarshaNalluru HarshaNalluru 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.

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>
@Sravan1011

Copy link
Copy Markdown
Contributor Author

@HarshaNalluru done with the changes

HarshaNalluru
HarshaNalluru previously approved these changes Jul 23, 2026
@HarshaNalluru

Copy link
Copy Markdown
Contributor

@Sravan1011 please resolve the conversations.

@Sravan1011

Sravan1011 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@Sravan1011 please resolve the conversations.

I have resolved all, can you specify which one ? I have also made change for the latest comment also

@HarshaNalluru

Copy link
Copy Markdown
Contributor

@Sravan1011 go to each of the comments to check which ones are unaddressed.
image

…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>
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codex review

No issues found.

Sravan1011 and others added 4 commits July 24, 2026 23:34
…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>
@HarshaNalluru
HarshaNalluru merged commit a2101b5 into usemoss:main Jul 25, 2026
16 checks passed
Hendrixx-RE pushed a commit to Hendrixx-RE/moss that referenced this pull request Jul 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Connector: S3 / object storage

3 participants