Skip to content

feat: add opt-in log+cursor pub/sub#459

Merged
pratyush618 merged 9 commits into
masterfrom
feat/s28-log-pubsub
Jul 19, 2026
Merged

feat: add opt-in log+cursor pub/sub#459
pratyush618 merged 9 commits into
masterfrom
feat/s28-log-pubsub

Conversation

@pratyush618

@pratyush618 pratyush618 commented Jul 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

S28 — the last deferred Tier-4 item. Taskito pub/sub is fan-out-on-write: publish_to_topic writes one jobs row per active subscriber (O(subscribers)/publish). This adds an opt-in log topic mode: one durable topic_messages row per publish (O(1)), consumed by pulling via a per-subscription cursor. Fan-out stays the default; nothing changes unless a subscription opts into log mode. A topic may mix both — a publish stores the log message once and fans out jobs to fan-out subscribers.

Consumer contract is at-least-once: read → process → ack. The cursor is an opaque monotonic high-water mark (acking id X acks everything ≤ X); a consumer that dies before acking re-reads. Retention is min-cursor compaction (a message drops once every log subscriber has acked past it), reaper-gated in the retention sweep.

Backends (full parity)

  • SQLite/Postgres: new topic_messages table (migration m0005) + mode/cursor columns on topic_subscriptions; message id = UUIDv7 (time-ordered cursor key).
  • Redis: Streams (XADD/XRANGE (cursor +/XTRIM MINID); stream id is the cursor). Enabled the redis crate's streams feature.

SDKs (full parity)

  • Python: subscribe_log/read_topic/ack_topic/topic_log_stats + TopicMessage dataclass.
  • Node: subscribeLog/readTopic/ackTopic/topicLogStats + TopicMessage type.
  • Java: subscribeLog/readTopic/ackTopic/topicLogStats + TopicMessage/TopicLogStat models (payload as byte[] — statically-typed decode is the caller's).

New ## Topic pub/sub section in BINDING_CONTRACT.md + log-topic docs across the shared guide and all three API references.

Testing

  • cargo test --workspace green (all feature combos); new cross-backend contract tests test_topic_log_messages/test_topic_log_purge. clippy --all-targets --all-features + doc -D warnings clean. The Redis Streams path runs against the CI Redis service (no local hosted URL).
  • Python: test_pubsub_log.py (8) + existing pub/sub (23); ruff + mypy clean.
  • Node: pubsub-log.test.ts (6) + existing (18); tsc + biome clean.
  • Java: PubSubLogTest (7) + existing PubSubTest (14).

Deferred (noted in the plan)

Managed consumer loop (auto-invoke + auto-ack), a first-class topics registry (retain messages with zero subscribers at publish time), and per-message ack/redelivery.

Summary by CodeRabbit

  • New Features
    • Added durable log-topic subscriptions with cursor-based read/ack and per-subscription lag statistics across supported SDKs.
    • Added topic message models with pagination and at-least-once delivery semantics; log and fan-out subscriptions can coexist on the same topic.
  • Bug Fixes
    • Improved topic message retention via bounded purge/expiry cleanup based on cursor progress.
  • Documentation
    • Expanded Pub/Sub docs to cover log topics, cursor workflow, retention/compaction rules, and message formats.

S28: a log topic stores one topic_messages row per publish (vs one job
per subscriber) and each log subscription tracks a cursor. Adds the
topic_messages table (m0005), mode/cursor columns, four Storage methods
(publish_message/read_topic_messages/ack_topic_cursor/topic_log_stats)
plus purge_topic_messages across SQLite, Postgres, and Redis Streams.
publish_to_topic writes one topic_messages row when a topic has a log
subscriber and fans out jobs only to fan-out subscribers. The retention
sweep now compacts log messages every subscriber has acked past.
subscribe_log registers a durable cursor; read_topic pulls decoded
messages after it; ack_topic advances it. Adds the TopicMessage
dataclass and topic_log_stats.
subscribeLog registers a durable cursor; readTopic pulls decoded messages after it; ackTopic advances it. Adds TopicMessage/TopicLogStat types and topicLogStats.
subscribeLog registers a durable cursor; readTopic pulls messages after it (payload as bytes); ackTopic advances it. Adds TopicMessage/TopicLogStat models and topicLogStats.
Add the Topic pub/sub section to BINDING_CONTRACT (fanout vs log mode, cursor/ack/at-least-once semantics, salting test vector) and log-topic docs across the shared guide and Python/Node/Java API references.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1b05a962-359b-4c54-b5a0-6dcb32f14eea

📥 Commits

Reviewing files that changed from the base of the PR and between 80e7d96 and 6cd76fd.

📒 Files selected for processing (6)
  • crates/taskito-core/BINDING_CONTRACT.md
  • crates/taskito-core/src/storage/diesel_common/pubsub.rs
  • crates/taskito-core/src/storage/redis_backend/pubsub.rs
  • crates/taskito-core/tests/rust/storage_tests.rs
  • docs/content/docs/shared/guides/core/pubsub.mdx
  • sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java
🚧 Files skipped from review as they are similar to previous changes (5)
  • crates/taskito-core/tests/rust/storage_tests.rs
  • docs/content/docs/shared/guides/core/pubsub.mdx
  • sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java
  • crates/taskito-core/src/storage/redis_backend/pubsub.rs
  • crates/taskito-core/src/storage/diesel_common/pubsub.rs

📝 Walkthrough

Walkthrough

Changes

Topic log pub/sub

Layer / File(s) Summary
Contracts, schema, and publish routing
Cargo.toml, crates/taskito-core/BINDING_CONTRACT.md, crates/taskito-core/migrations/..., crates/taskito-core/src/pubsub.rs, crates/taskito-core/src/storage/...
Adds fan-out/log subscription modes, durable topic-message schema, cursor fields, publish routing, and contract documentation.
Storage operations and retention
crates/taskito-core/src/storage/..., crates/taskito-core/src/scheduler/maintenance.rs, crates/taskito-core/tests/...
Implements message reads, monotonic cursor acknowledgements, log statistics, backend-specific storage, retention purging, and storage contract tests.
Java API and JNI binding
crates/taskito-java/..., sdks/java/src/main/java/..., sdks/java/src/test/...
Adds Java topic-log models, public APIs, JNI plumbing, serialization, and end-to-end tests.
Node API and native conversion
crates/taskito-node/..., sdks/node/src/..., sdks/node/test/...
Adds Node topic-log APIs, native message/stat conversions, public types, and behavior tests.
Python API and extension binding
crates/taskito-python/..., sdks/python/taskito/..., sdks/python/tests/...
Adds Python topic-log APIs, tuple conversions, public TopicMessage, type stubs, and behavior tests.
Cross-SDK documentation
docs/content/docs/...
Documents log subscriptions, cursor operations, message formats, delivery semantics, and retention behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SDK
  participant NativeBinding
  participant Storage
  participant TopicLog
  SDK->>NativeBinding: subscribeLog and readTopic
  NativeBinding->>Storage: register or read subscription
  Storage->>TopicLog: fetch messages after cursor
  TopicLog-->>Storage: ordered topic messages
  Storage-->>NativeBinding: messages and log stats
  NativeBinding-->>SDK: decoded message or statistics
  SDK->>NativeBinding: ackTopic
  NativeBinding->>Storage: advance cursor
Loading

Possibly related PRs

  • ByteVeda/taskito#412: Extends the existing topic pub/sub plumbing with durable log-mode subscriptions and cursor-based topic message operations.
  • ByteVeda/taskito#415: Shares the topic-subscription storage and registration/upsert changes extended here with log modes and cursors.
  • ByteVeda/taskito#451: Provides the records-based Storage interfaces extended here for topic-log operations.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: an opt-in log and cursor-based pub/sub mode.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/s28-log-pubsub

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/taskito-core/src/storage/sqlite/pubsub.rs (1)

32-52: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject unknown subscription modes before persisting them.

sub.mode is written verbatim, while publish_to_topic treats only "log" specially and silently treats every other value as fan-out. A typo such as "logs" can produce fan-out jobs with the empty task name used by log subscribers instead of storing messages. Validate against the two shared constants before registration across all backends.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/src/storage/sqlite/pubsub.rs` around lines 32 - 52,
Validate sub.mode in the subscription registration flow before constructing or
persisting the row, accepting only the two shared mode constants used by
publish_to_topic and rejecting any other value. Apply the same validation across
every backend’s registration implementation, including the SQLite path around
topic_subscriptions, without changing valid log or fan-out behavior.
crates/taskito-core/src/pubsub.rs (1)

75-105: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make mixed-mode publishing atomic.

publish_message succeeds before enqueue_batch/enqueue_unique_batch runs. If the enqueue fails, callers receive an error although the log entry is already durable; a retry appends another entry and can leave log and fan-out deliveries inconsistent. Add one backend-level publish operation that atomically appends the log entry and enqueues fan-out jobs (a transaction for Diesel and an atomic Redis operation).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/src/pubsub.rs` around lines 75 - 105, Make the publishing
flow around publish_message and enqueue_batch/enqueue_unique_batch atomic by
introducing a backend-level operation that performs the log append and all
fan-out enqueues as one unit. Implement the operation transactionally for Diesel
and as a single atomic Redis operation, then update the caller to use it while
preserving log-only and fan-out-only behavior.
🧹 Nitpick comments (3)
sdks/python/tests/core/test_pubsub_log.py (1)

12-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the metadata and notes mapping.

read_topic() decodes both fields, but this test only verifies payload decoding. Assert them to protect the public TopicMessage contract.

Proposed test update
-        assert queue.publish("events", 1, kind="a") == []
+        assert queue.publish(
+            "events",
+            1,
+            kind="a",
+            metadata={"tenant": "acme"},
+            notes={"source": "test"},
+        ) == []
         assert queue.publish("events", 2, kind="b") == []

         msgs = queue.read_topic("events", "analytics")
         assert [(m.args, m.kwargs) for m in msgs] == [((1,), {"kind": "a"}), ((2,), {"kind": "b"})]
+        assert msgs[0].metadata == {"tenant": "acme"}
+        assert msgs[0].notes == {"source": "test"}
         assert all(isinstance(m, TopicMessage) for m in msgs)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdks/python/tests/core/test_pubsub_log.py` around lines 12 - 21, Update
test_publish_stores_one_message_per_call to also assert each decoded
TopicMessage’s metadata and notes fields returned by read_topic(). Verify the
expected values for both published messages, preserving the existing payload and
TopicMessage type assertions.
crates/taskito-core/tests/rust/storage_tests.rs (1)

1356-1385: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the expires_at retention path.

Both log tests only exercise cursor-based purge; nothing publishes with expires_at or asserts TTL-based deletion. That leaves the TopicMessage.expires_at "safety net" untested — and it's exactly the path that behaves differently across backends (Diesel deletes expired regardless of ack; Redis ignores it). A test setting expires_at in the past and asserting purge behavior would document/guard that contract.

Want me to draft this test case?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/tests/rust/storage_tests.rs` around lines 1356 - 1385,
Extend the topic purge tests around test_topic_log_purge to publish a message
with TopicMessage.expires_at set in the past, then invoke purge_topic_messages
and assert the documented TTL-based deletion behavior. Ensure the test covers an
expired message independently of cursor acknowledgements and makes the backend
contract explicit, including the expected behavior for expired messages across
implementations.
crates/taskito-core/src/storage/redis_backend/pubsub.rs (1)

648-681: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

topic_log_stats still scans the full stream suffix (crates/taskito-core/src/storage/redis_backend/pubsub.rs:650-671). XRANGE returns every matching entry here, so large backlogs pay O(unacked) network/memory cost just to compute lag and the oldest age. If this path matters, move the count server-side or keep a separate lag index.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/src/storage/redis_backend/pubsub.rs` around lines 648 -
681, Update topic_log_stats to avoid fetching the full unacknowledged stream
suffix through XRANGE. Use a server-side count/range operation or an existing
lag index to compute lag and oldest_unacked_age_ms with bounded response size,
while preserving cursor handling and the existing TopicLogStats output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/taskito-core/BINDING_CONTRACT.md`:
- Around line 148-162: Update read_topic_messages and ack_topic_cursor to
require mode = "log" and validate the cursor belongs to the requested topic
before reading or acknowledging. For acknowledgements, atomically verify cursor
membership before advancing the stored cursor, preserving no-op behavior for
older or equal cursors; apply these checks consistently in both Diesel and Redis
implementations.

In `@crates/taskito-core/src/storage/redis_backend/pubsub.rs`:
- Around line 683-724: Update purge_topic_messages to perform an expiry-based
cleanup using TopicMessage.expires_at in addition to the existing minimum-cursor
XTRIM pass. Ensure expired Redis log messages are removed independently of
subscription cursor state, including NULL or stalled cursors, while preserving
the current cursor-based compaction behavior and removal count reporting.

In `@docs/content/docs/shared/guides/core/pubsub.mdx`:
- Around line 518-521: Scope the operational notes to fan-out subscriptions: in
docs/content/docs/shared/guides/core/pubsub.mdx lines 518-521, replace the
generic durable-subscriber wording with fan-out-specific wording or explicitly
distinguish log cursor and stored-message behavior; in lines 527-531, restrict
the write-amplification statement to fan-out subscribers while preserving the
log publishing behavior.

In `@sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java`:
- Around line 238-268: Update the default mode-aware registerSubscription method
to preserve delegation only for mode "fanout"; when mode is "log", reject the
request explicitly instead of calling the legacy fan-out overload. Keep existing
fan-out compatibility and ensure unsupported or invalid modes fail clearly
without silently changing subscription semantics.

---

Outside diff comments:
In `@crates/taskito-core/src/pubsub.rs`:
- Around line 75-105: Make the publishing flow around publish_message and
enqueue_batch/enqueue_unique_batch atomic by introducing a backend-level
operation that performs the log append and all fan-out enqueues as one unit.
Implement the operation transactionally for Diesel and as a single atomic Redis
operation, then update the caller to use it while preserving log-only and
fan-out-only behavior.

In `@crates/taskito-core/src/storage/sqlite/pubsub.rs`:
- Around line 32-52: Validate sub.mode in the subscription registration flow
before constructing or persisting the row, accepting only the two shared mode
constants used by publish_to_topic and rejecting any other value. Apply the same
validation across every backend’s registration implementation, including the
SQLite path around topic_subscriptions, without changing valid log or fan-out
behavior.

---

Nitpick comments:
In `@crates/taskito-core/src/storage/redis_backend/pubsub.rs`:
- Around line 648-681: Update topic_log_stats to avoid fetching the full
unacknowledged stream suffix through XRANGE. Use a server-side count/range
operation or an existing lag index to compute lag and oldest_unacked_age_ms with
bounded response size, while preserving cursor handling and the existing
TopicLogStats output.

In `@crates/taskito-core/tests/rust/storage_tests.rs`:
- Around line 1356-1385: Extend the topic purge tests around
test_topic_log_purge to publish a message with TopicMessage.expires_at set in
the past, then invoke purge_topic_messages and assert the documented TTL-based
deletion behavior. Ensure the test covers an expired message independently of
cursor acknowledgements and makes the backend contract explicit, including the
expected behavior for expired messages across implementations.

In `@sdks/python/tests/core/test_pubsub_log.py`:
- Around line 12-21: Update test_publish_stores_one_message_per_call to also
assert each decoded TopicMessage’s metadata and notes fields returned by
read_topic(). Verify the expected values for both published messages, preserving
the existing payload and TopicMessage type assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f16dcd80-cc66-4fc0-90fb-baa40cd852e9

📥 Commits

Reviewing files that changed from the base of the PR and between f66da14 and 80e7d96.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (44)
  • Cargo.toml
  • crates/taskito-core/BINDING_CONTRACT.md
  • crates/taskito-core/migrations/m0005_topic_messages.rs
  • crates/taskito-core/src/pubsub.rs
  • crates/taskito-core/src/scheduler/maintenance.rs
  • crates/taskito-core/src/storage/diesel_common/pubsub.rs
  • crates/taskito-core/src/storage/mod.rs
  • crates/taskito-core/src/storage/models.rs
  • crates/taskito-core/src/storage/postgres/pubsub.rs
  • crates/taskito-core/src/storage/records.rs
  • crates/taskito-core/src/storage/redis_backend/pubsub.rs
  • crates/taskito-core/src/storage/schema.rs
  • crates/taskito-core/src/storage/sqlite/pubsub.rs
  • crates/taskito-core/src/storage/sqlite/tests.rs
  • crates/taskito-core/src/storage/traits.rs
  • crates/taskito-core/tests/rust/storage_tests.rs
  • crates/taskito-java/Cargo.toml
  • crates/taskito-java/src/convert.rs
  • crates/taskito-java/src/queue/pubsub.rs
  • crates/taskito-java/src/worker.rs
  • crates/taskito-node/src/convert/mod.rs
  • crates/taskito-node/src/convert/pubsub.rs
  • crates/taskito-node/src/queue/pubsub.rs
  • crates/taskito-python/src/py_queue/pubsub.rs
  • docs/content/docs/java/api-reference/queue/pubsub.mdx
  • docs/content/docs/node/api-reference/queue/pubsub.mdx
  • docs/content/docs/python/api-reference/queue/pubsub.mdx
  • docs/content/docs/shared/guides/core/pubsub.mdx
  • sdks/java/src/main/java/org/byteveda/taskito/DefaultTaskito.java
  • sdks/java/src/main/java/org/byteveda/taskito/Taskito.java
  • sdks/java/src/main/java/org/byteveda/taskito/internal/JniQueueBackend.java
  • sdks/java/src/main/java/org/byteveda/taskito/internal/NativeQueue.java
  • sdks/java/src/main/java/org/byteveda/taskito/model/TopicLogStat.java
  • sdks/java/src/main/java/org/byteveda/taskito/model/TopicMessage.java
  • sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java
  • sdks/java/src/test/java/org/byteveda/taskito/core/PubSubLogTest.java
  • sdks/node/src/native.ts
  • sdks/node/src/queue.ts
  • sdks/node/src/types.ts
  • sdks/node/test/core/pubsub-log.test.ts
  • sdks/python/taskito/__init__.py
  • sdks/python/taskito/_taskito.pyi
  • sdks/python/taskito/mixins/pubsub.py
  • sdks/python/tests/core/test_pubsub_log.py

Comment thread crates/taskito-core/BINDING_CONTRACT.md Outdated
Comment thread crates/taskito-core/src/storage/redis_backend/pubsub.rs Outdated
Comment thread docs/content/docs/shared/guides/core/pubsub.mdx Outdated
Comment thread sdks/java/src/main/java/org/byteveda/taskito/spi/QueueBackend.java
read_topic_messages/ack_topic_cursor now filter mode='log', so a fan-out
subscription can't read a mixed topic's log or advance a cursor. Redis
purge_topic_messages gains an expires_at XDEL pass, matching the Diesel
TTL safety net so a stalled cursor can't block reclamation.
The mode-aware registerSubscription default silently downgraded 'log' to
fan-out; now it throws UnsupportedOperationException for any non-fanout
mode, keeping the compatibility fallback for fan-out only.
Backlog accumulation and per-subscriber write amplification are fan-out properties; a log topic writes one row per publish regardless of subscriber count.
@pratyush618
pratyush618 merged commit 807db05 into master Jul 19, 2026
33 checks passed
@pratyush618
pratyush618 deleted the feat/s28-log-pubsub branch July 19, 2026 08:18
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.

1 participant