Skip to content

Hackathon [Feature]: Add incremental sync to the Notion connector #4820

Description

@NMZivkovic

Problem Statement

Unlike the other connector tickets in this batch, the Notion connector already exists — packages/connector/notion/ in cognee-community has a working DLT source (~290 lines), tests, an example, and a README. #4541 even points contributors at it as the reference implementation. Read it before you start; this is not a greenfield ticket.

Two things are missing.

1. Every sync re-reads the entire workspace. The source is a full snapshot (write_disposition="replace"), and _page_to_row calls _render_blocks for every page on every run. Unchanged pages are not re-cognified — the content-hash data_id already prevents that, so the LLM cost is fine — but the Notion API read cost is paid in full each time. On a large workspace that means thousands of block-children calls per sync, against Notion's rate limit, to rediscover text that did not change.

2. There is no OAuth flow. Auth is a single internal integration token (NOTION_API_KEY), which works for one workspace you control. A hosted "connect your Notion" experience needs Notion's public OAuth.

Proposed Solution

Incremental fetch, without breaking deletion. This is the interesting constraint and the reason the ticket is not trivial: forget-on-delete depends on the snapshot being complete. If you skip pages, orphan_cleanup reads their absence as deletion and forgets live pages from the graph. Any design must keep listing every visible page while skipping only the expensive part.

The shape that satisfies both:

  • Keep enumerating every page each run (cheap — search/database.query are paginated list calls), so the snapshot stays authoritative.
  • Compare each page's last_edited_time against the previous run's watermark.
  • Only call _render_blocks for pages that changed; reuse the stored content for the rest.
  • Preserve the existing failure semantics: a render error must still abort the run rather than yield a partial snapshot. The current code documents exactly why — a partial snapshot silently forgets memory.

Note the existing subtlety in _page_to_row: last_edited_time bumps on metadata-only edits that do not change text. Using it as a fetch filter is fine (worst case you re-render a page unnecessarily); using it as the data_id would cause churn, which is why the content hash exists. Do not replace the content hash with it.

OAuth can be a second PR, or a second contributor: add the public OAuth flow alongside the token path, keeping the token path as the default for local use.

Watch out for

  • Do not switch away from the full snapshot. It looks wasteful and is load-bearing. The module docstring explains why merge + hard_delete cannot work here — Notion omits trashed pages rather than flagging them.
  • Pages can be unshared rather than deleted; _is_gone already treats that as "forget", which is correct. Keep that behaviour.
  • Blocks nest, and _render_blocks recurses with a depth guard. Any caching must key on page id and handle child-block edits that do not bump the parent's last_edited_time.

Acceptance Criteria

  • A second sync with no upstream changes performs no blocks.children calls
  • Editing one page causes exactly that page to be re-rendered
  • Deleting, trashing, or unsharing a page still removes it from the graph on the next sync
  • A render failure mid-run still aborts without leaving a partial snapshot
  • Existing tests continue to pass, plus new tests for the watermark and the deletion path
  • README documents the watermark and where it is stored
  • (Optional, or a follow-up PR) Notion public OAuth alongside the existing token auth

Context

Part of the cognee hackathon connector push. Difficulty: Intermediate (M) — the code is small, but the snapshot/deletion interaction is the part to get right. Best suited to someone who wants a correctness puzzle rather than a fresh API integration.

Activity

  1. Nandha140904 commented on Aug 28, 2026

    @Nandha140904

    Submitted PR to \cognee-community\ implementing the watermark-based incremental sync while preserving the full snapshot for deletion detection: topoteretes/cognee-community#157

  2. chenhz01 commented on Sep 30, 2026

    @chenhz01

    这个增量约束抓得对——不能简单跳过页面,否则 orphan_cleanup 会把活页面当删除忘掉。正确形状是每轮仍然全量枚举(廉价 list),只对 last_edited_time 变更的页调 _render_blocks,其余复用已存内容。
    下面这段在保持快照权威的同时做水位增量:

    # Incremental Notion sync that keeps the snapshot authoritative (orphan-safe).
    # Plug in: replace the full re-render loop in the DLT source's _page_to_row.
    
    def incremental_pages(pages, prev_watermarks, render, stored_content):
        """Enumerate EVERY page (keeps snapshot complete -> safe orphan_cleanup),
        but only call _render_blocks for pages whose last_edited_time moved.
        Returns (rows_to_emit, page_ids_seen)."""
        rows, seen = [], set()
        for p in pages:                      # full enumeration, cheap list call
            pid = p["id"]
            seen.add(pid)
            edited = p["last_edited_time"]
            if prev_watermarks.get(pid) == edited:
                rows.append(stored_content[pid])   # unchanged -> reuse, skip render
            else:
                rows.append(render(p))             # changed -> re-render
        return rows, seen
    
    def orphan_deletes(current_ids, graph_ids):
        """Forget only pages absent from the authoritative listing."""
        return [g for g in graph_ids if g not in current_ids]
    
    # rows, seen = incremental_pages(pages, watermarks, _render_blocks, cache)
    # forget(orphan_deletes(seen, graph_page_ids))

    如果你们想把 watermark 持久化到 DLT 的 state,我可以补一段 state 读写样例。

  3. ram-from-tvl commented on Oct 4, 2026

    @ram-from-tvl

    Hi @NMZivkovic
    I would like to work on this issue.
    Could you please assign this to me.
    Thanks

  4. Anurag13075 commented on Oct 5, 2026

    @Anurag13075

    Hi @NMZivkovic, I'd like to take this one for Mergetober.

    I'll start by reading packages/connector/notion/ in cognee-community, in particular the full-snapshot setup (write_disposition="replace"), _page_to_row, _render_blocks, and the orphan_cleanup/_is_gone deletion path, so I understand why the snapshot is load-bearing before I change anything.

    My plan for the incremental fetch:

    • Keep enumerating every visible page on each run, so the snapshot stays authoritative and forget-on-delete keeps working (trashed and unshared pages still get removed).
    • Store a per-page last_edited_time watermark from the previous run and compare against it.
    • Only call _render_blocks for pages whose last_edited_time changed, and reuse the stored rendered content for the rest.
    • Keep the content-hash data_id as is, and use last_edited_time only as a fetch filter, never as the id.
    • Keep the existing failure semantics: a render error aborts the run instead of yielding a partial snapshot.
    • I'll test the nested-block case explicitly, to check whether edits to child blocks bump the parent page's last_edited_time, and handle it if they don't.

    Tests: a second sync with no changes makes no blocks.children calls; editing one page re-renders only that page; deleting, trashing and unsharing still forget; a mid-run render failure still aborts cleanly. I'll also document the watermark and where it's stored in the README.

    I'll test against a real Notion workspace, and I'll treat the OAuth part as a possible follow-up PR once the incremental sync is merged-ready.

    Could you assign this to me? Thanks!

  5. p920750 commented on Oct 5, 2026

    @p920750

    Hi @NMZivkovic, I'd like to work on this issue for Mergetober.

    Here is my technical plan for the incremental sync:

    1. Enumerate pages using page search/database.query list calls to keep the full snapshot complete (ensuring orphan_cleanup & deletion mechanics remain intact).
    2. Store a run watermark (last_edited_time) in the local DLT state / connector storage.
    3. Compare each page's last_edited_time against the previous watermark, skipping _render_blocks when unchanged, while retaining content-hash data_id mapping.
    4. Maintain error-handling semantics so mid-run render failures abort without emitting partial snapshots.
    5. Add unit and integration tests covering the watermark logic and page deletion/unsharing scenarios.

    Could you please assign this to me?

  6. shantanu-1607 commented on Oct 6, 2026

    @shantanu-1607

    Hi @NMZivkovic, I’d like to work on this for Mergetober if it’s still available.

    I went through the issue and also noticed the earlier watermark PR (#157) was closed without being merged. My approach would be to keep the existing full-snapshot behavior intact so deletion/unsharing continues to work, while avoiding unnecessary block fetches for pages that haven’t changed.

    I’d use last_edited_time only to decide whether a page needs _render_blocks again, reuse the previously rendered content for unchanged pages, and keep the existing content-hash based data_id. I’d also make sure a failed render never leaves a partial snapshot or advances the saved state.

    For testing, I’d cover the no-change second sync, a single edited page, deletion/unsharing, and failure during rendering.

    If this is still open for a contributor, could you assign it to me? Thanks!

  7. ravithemore commented on Oct 6, 2026

    @ravithemore

    Hi @NMZivkovic! I'd love to implement incremental sync for the Notion connector.

    Implementation Plan:

    1. State Tracking: Track and persist the last_synced_at timestamp / Notion cursor per database/page.
    2. Filtered Query: Use Notion's filter: { timestamp: "last_edited_time", last_edited_time: { after: <timestamp> } } to fetch only updated/new blocks.
    3. Sync Logic: Update only modified records in Cognee's graph/vector memory without re-indexing unchanged entries.
    4. Tests: Add unit tests verifying incremental vs full sync behavior.

    Could you please assign this to me?

  8. RK-625 commented on Oct 7, 2026

    @RK-625

    Hi @NMZivkovic, I'd like to work on this. I read through notion.py and agree with the approach in the issue: keep the full replace snapshot and only skip _render_blocks for pages that haven't changed. A few details decide whether that's actually safe, though.

    First, Notion rounds last_edited_time down to the minute (changelog). If a sync reads a page at 10:05:20 and someone edits it at 10:05:40, the timestamp still says 10:05, so a plain "same timestamp, skip it" check would miss that edit until the page changes again. I'd re-render any page whose timestamp falls in the minute the previous sync started.

    Second, _render_blocks follows every block with has_children, which can pull in content from child pages and synced blocks. That content can change without the parent page's last_edited_time moving. So while rendering I'd note those dependencies and their timestamps, and re-render the parent when one of them changes. Checking them only needs page/block metadata, so a sync with no changes still makes zero blocks.children calls. I'll confirm both cases with fixtures.

    Third, where the cached content lives. With replace, every unchanged page still has to be sent again with its text. I see two options:

    • Keep it in dlt resource state. Simple, and state is saved together with the load, so a failed run can't leave the cache ahead of the snapshot. The downside is that the whole workspace's text sits in one state blob that's rewritten every run.
    • Keep only a small {id: timestamp, hash, deps} map in state and read unchanged content back from the previously staged notion_pages table, rendering again if a row is missing or doesn't match.

    The first is simpler and the second scales better. Do you have a preference?

    Everything else stays as it is: the content-hash data_id, _is_gone for unshared pages, skipping archived and trashed pages, and a render error aborting the run. If the cache is missing or broken, it just does a full render.

    On top of the existing tests I'd add: a no-change sync with zero blocks.children calls, one edit causing exactly one re-render, a same-minute edit, a child page or synced block edit re-rendering its parent, delete/trash/unshare still forgetting, and a failed render leaving the cache alone. I'd document the watermark and the same-minute rule in the README, and leave OAuth for a follow-up PR.

  9. harshitsaxena214 commented on Oct 7, 2026

    @harshitsaxena214
    Contributor

    Hi @NMZivkovic, I've opened a PR for the incremental sync part: topoteretes/cognee-community#272

    Design, against the constraints in the issue

    • Snapshot stays authoritative. write_disposition="replace" is unchanged and every visible page is still enumerated each run, so orphan_cleanup never mistakes a skipped page for a deleted one.
    • Only the expensive part is skipped. Each page's last_edited_time is compared with a stored watermark, and _render_blocks runs only for pages that changed. Unchanged pages reuse their stored content.
    • data_id is still the content hash, so metadata-only edits cause at worst one extra render, never re-cognification.
    • Failure semantics preserved. A render error still aborts the run, and the watermark lives in dlt source state, which dlt commits only after a successful run, so a failed run can't advance it.
    • No new storage layer. It uses dlt.current.source_state() instead of a separate file. Diff is limited to packages/connector/notion/.

    Trade-offs, stated openly

    • State holds rendered content per page, so it grows with workspace size.
    • Entries for vanished pages aren't pruned yet.
    • Nested child-block edits that don't bump the parent's last_edited_time aren't handled in code. This is documented in the README instead of adding extra API calls.

    Tests cover the first sync, a no-change sync (zero blocks.children calls), a single-page edit re-rendering only that page, and render-failure behaviour. OAuth is not part of this PR.

  10. dexters1 commented on Oct 7, 2026

    @dexters1
    Collaborator

    Issue assigned to @Anurag13075

  11. aryanXcode1 commented on Oct 8, 2026

    @aryanXcode1

    The Notion connector in cognee-community (packages/connector/notion/) works via a full snapshot (write_disposition="replace"), but suffers from two main issues:

    High API Read Cost: Every sync re-reads all pages by calling _render_blocks on every page, making thousands of blocks.children API calls against Notion's rate limits just to rediscover unchanged text.

    Missing Public OAuth: Auth relies solely on an internal integration token (NOTION_API_KEY), which prevents hosted multi-tenant deployment ("Connect your Notion").

    Deletion Detection Constraint: The graph relies on orphan_cleanup reading a page's absence as a deletion. Because Notion omits trashed or unshared pages instead of flagging them, skipping unedited pages during search would cause orphan_cleanup to misinterpret live pages as deleted.

    Proposed Solution
    Implement an incremental fetch while preserving full snapshot enumeration:

    Full Page Enumeration: Continue listing every visible page on every run using paginated search or database.query calls to keep the snapshot complete and preserve deletion/unsharing logic (_is_gone).

    Watermark Comparison: Compare each page’s last_edited_time against the previous run’s stored watermark.

    Selective Rendering: Only call _render_blocks for pages whose last_edited_time changed; reuse stored content for unchanged pages.

    Preserve Failure Semantics: If rendering fails mid-run, abort the entire process so a partial snapshot is never emitted (preventing accidental memory loss).

    Retain Content Hash: Continue using the content hash as data_id (not last_edited_time) so metadata-only edits do not trigger downstream LLM re-cognification.

    Public OAuth (Optional/Follow-up): Implement Notion's public OAuth flow alongside the existing integration token path.

  12. sejal-Kamble18 commented on Oct 9, 2026

    @sejal-Kamble18

    @NMZivkovic i like to work on this issue.
    As part of the Mergetober. I read through notion.py and agree with the approach in the issue: keep the full replace snapshot and only skip _render_blocks for pages that haven't changed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions