Skip to content

Promote CCC to top-level Sourced namespace - #51

Open
ismasan wants to merge 151 commits into
mainfrom
ccc
Open

Promote CCC to top-level Sourced namespace#51
ismasan wants to merge 151 commits into
mainfrom
ccc

Conversation

@ismasan

@ismasan ismasan commented Apr 15, 2026

Copy link
Copy Markdown
Owner

Summary

This branch replaces the original actor/stream-based architecture with the CCC (Command-Context Consistency) design, promoting it to the top-level Sourced namespace.

  • Stream-less event sourcing: flat, globally-ordered log with consistency context assembled dynamically via normalized key/value pairs extracted from event payloads
  • New reactor model: Decider, Projector, DurableWorkflow, and plain Consumer reactors, all supporting #handle_batch
  • New store: SQLite-backed Sourced::Store (with Sequel migration support) replacing SequelBackend / PGBackend / SQLiteBackend / TestBackend
  • Dispatch infrastructure: Dispatcher, Worker, StaleClaimReaper, ScheduledMessagePoller, consumer-group lifecycle hooks
  • Falcon integration with deferred post-fork configuration
  • Command handling: CommandContext with per-message / any hooks, Sourced.handle! for synchronous dispatch
  • Removed: Actor, old backends, pubsub modules, Rails generators, Unit, old handler/consumer tests
  • Example app under examples/app/ demonstrating the new API
  • Extensive specs (store_spec.rb alone has 2400+ lines)

ismasan and others added 30 commits February 24, 2026 18:46
Implements store-level primitives for parallel background processing
of the CCC message log. Partitions are discovered via AND semantics
(messages must have all partition attributes) and fetched via
conditional AND (each message matches all partition attributes it has).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claim_next now builds guard conditions from each handled_type ×
partition key_pair combination, enabling deciders to detect concurrent
writes at append time via store.append(events, guard: result[:guard]).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Guard conditions are now derived from each message class's declared
payload attributes via Message.to_conditions(**partition_attrs).
This avoids nonsensical conditions (e.g. CourseCreated × user_id)
while still covering all handled_types for conflict detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents conditional AND fetch, ConsistencyGuard from claim_next,
Message.to_conditions, cached payload_attribute_names, and the
SQLite DISTINCT requirement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Enable causal chain tracing across CCC messages, matching the pattern
from Sourced::Message. Both IDs default to the message's own id via
Plumb's prepare_attributes hook. Store schema and serialization updated
to persist and round-trip the new fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
highest_position on the consumer group tracks the furthest position
ever successfully acked (advanced in ack, never decreased). claim_next
returns replaying: true when all returned messages are at or below
this watermark, meaning they have been processed before (e.g. after
an offset reset). First-time processing is never replaying.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the plain Hash with an immutable ClaimResult value object
(Data.define) for type safety and a cleaner API. Defined in store.rb
alongside the Store class. Tests updated to use method access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement the Decide/Evolve/React pattern for CCC's stream-less model.
Deciders request history via context_for() conditions, Projectors evolve
from claimed messages directly. Router orchestrates claim→handle→execute→ack
with transactional action execution, partial batch ACK, and error recovery.

New modules: Actions (Append/Sync), Consumer, Evolve, React, Sync.
Store#read now returns ReadResult data struct.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Decider now pre-correlates events with the command, then passes
correlated events as source: to reaction Appends. This gives the
correct causation chain: cmd → event → reaction message, with
correlation_id tracing back to the command throughout.

Append gains source: (override correlation source) and correlated:
(skip re-correlation) options. Router integration tests verify exact
causation_id and correlation_id at each link in the chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add default empty handled_messages_for_evolve to CCC::Consumer so
context_for works for reactors that just extend Consumer, define
handled_messages, and implement handle_batch with manual action pairs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…partition reads

Introduces Sourced::CCC.load(reactor_class, store, **partition_attrs) to
load a reactor's evolved state from the store. Uses Store#read_partition
for SQL-level AND filtering — a message is included only when every
partition attribute it declares matches, avoiding loading irrelevant
messages into memory. Guard's last_position covers the broader OR-context
to prevent false concurrency conflicts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ier)

Signal-driven dispatch for CCC reactors: Store notifies on append/resume,
NotificationQueuer routes types to reactors via WorkQueue, Workers drain
partitions in bounded loops. Reuses generic primitives (WorkQueue,
CatchUpPoller, InlineNotifier) with CCC-specific Worker and Dispatcher.
Also adds batch_size: to Store#claim_next and Router#handle_next_for.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Crashed workers leave partitions permanently claimed. Add a ccc_workers
table with heartbeat upserts and a StaleClaimReaper that periodically
releases claims from workers that stopped heartbeating. Wire the reaper
into the Dispatcher alongside the existing notifier and catchup poller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Mirrors Sourced::Supervisor but simpler: no separate HouseKeepers
since StaleClaimReaper is already embedded in the CCC Dispatcher.
Takes router + config kwargs, sets signal handlers, and spawns
Dispatcher into an executor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ter, store, router, reset!)

Wire CCC components (Supervisor, Dispatcher, Worker, Consumer, StaleClaimReaper) to
pull defaults from CCC.config instead of Sourced.config, keeping executor on Sourced.
Change CCC.load signature from positional store to keyword store: defaulting to global.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Uses Types::Interface to validate custom store objects implement the
12 required methods. Store instances and raw Sequel SQLite connections
are still accepted directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace NullGroup with GroupUpdater that accumulates stop/retry mutations
for atomic persistence. Add Store#updating_consumer_group to load, yield,
and persist group state. Gate claim_next on retry_at so retries are
honoured at the database level. Clear retry_at and error_context in
start_consumer_group.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Encapsulates the validate → load history → decide → append → ACK flow
into a single call. Returns a HandleResult supporting destructuring:
  cmd, reactor, events = CCC.handle!(cmd, MyDecider)

Adds Store#advance_offset to move consumer group offsets without a
prior claim, so background workers skip already-handled commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
stop_consumer_group, start_consumer_group, reset_consumer_group, and
consumer_group_active? now accept either a String or any object
responding to #group_id (e.g. a reactor class).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ismasan and others added 30 commits June 4, 2026 18:14
So that framework integrations can register their own blocks that will be re-applied on fork
Monkey-patch with Sourced-specific methods for now. Rethink this
Also validate that a queue_mode reactor owns its message types
Enable non-Sourced reactors (e.g. Sidereal Commanders) to run on Sourced's
runtime, and make delete-on-ack an explicit, flag-driven concern.

Actions become inert signals:
- Reactors return plain `{type: :append|:schedule|:sync|:after_sync|:ack, ...}`
  hashes (or Sourced::Actions value objects, which now `deconstruct_keys` to the
  same shape). A new Sourced::ActionRunner is the sole code that touches the
  store; Actions#execute is removed. Third parties emit signals without a Sourced
  dependency.
- A `delete: true` flag on a signal is the ONLY thing that deletes a handled
  message. Deletion is never implied by partitioning or exclusivity.

Duck-typed reactor protocol:
- Only `handled_messages` and `handle_claim` are required. ReactorDefaults.apply
  defines any missing optional class-methods (`group_id` -> class name,
  `partition_keys`, `exclusive?`, `on_exception`, `context_for`, lifecycle hooks)
  directly on the reactor, only where absent, so the reactor stays a real class
  (Injector signature reflection keeps working) and its own definitions win.

Queue partitioning via one engine:
- `exclusive` marks sole ownership (routing validation); it may omit
  `partition_by` to get an id-partitioned queue (one partition per message),
  reusing the existing offsets/key_pairs machinery. A reactor must otherwise
  declare `partition_by` — forgetting it raises, never silently deletes.
- Store#append gains `index_by: :payload|:id`; the runner resolves it per target
  message type. Reaper prunes drained queue offsets and orphaned id key_pairs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two gaps found in code review of the durable-messaging feature:

1. index_by was only applied by ActionRunner, so messages appended via
   Sourced.handle!, DurableWorkflow, or scheduled-message promotion
   (update_schedule!) were indexed by payload even when destined for an
   id-partitioned queue reactor — making them unclaimable (notably breaking
   future-scheduled queue commands).

   Fix: the Store now owns the per-type index basis, derived from each group's
   partitioning at register_consumer_group (handled_types:). append() resolves
   the basis per message, so every append path indexes consistently.
   index_by: stays as an optional override. This also removes Router#index_basis_for
   / @index_basis and ActionRunner's index_resolver + per-basis grouping.

2. Deletion is decoupled from exclusivity, but the offset reaper only reaped
   delivery_mode='queue' offsets, so a non-exclusive (log) reactor that deleted
   via a :delete action leaked drained offsets + orphan key_pairs.

   Fix: release_empty_queue_offsets -> release_drained_offsets reaps any
   unclaimed partition offset whose partition has no remaining messages,
   regardless of delivery mode. Safe because retain reactors never drain a
   partition (their messages persist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The register guard only required `exclusive` when partition_by was omitted, so a
reactor could explicitly declare `partition_by :id` while non-exclusive. That
still id-indexes its message types (@type_index_basis[type] = :id) but skips
exclusivity validation — if another reactor handled the same type, its messages
became unclaimable (id-indexed but the co-consumer expects payload keys),
silently breaking multi-consumer fan-out.

Gate the guard on the effective partition keys (== [:id]) instead of "declared
empty", so both the implicit (omitted) and explicit (`partition_by :id`) cases
require exclusive. Restores the invariant: a type is only id-indexed when its
lone exclusive owner is id-partitioned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck queues

Document the durable-messaging additions: inert action signals + ActionRunner,
the duck-typed reactor protocol via ReactorDefaults, exclusive/id-partitioned
delete-on-ack queues, per-type index basis, multi-consumer fan-out, and the
renamed/added store methods (append index_by, register_consumer_group
handled_types/exclusive, release_drained_offsets, prune_orphan_key_pairs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Partitioning by message id was keyed on the name "id", which shadows a
legitimate user payload attribute named `id` — a user could write
`partition_by :id` (or add an `id` payload field) expecting to partition by
their own value, and silently get Message#id instead.

Rename the reserved key to :__id / "__id" (unlikely to collide) across the
router (effective_partition_keys default + exclusive guard) and store
(effective_keys key name, id_partitioned?, register_consumer_group basis
detection). :id is now a normal payload attribute name again. The internal
index-basis symbol (:id vs :payload) is unchanged.

Temporary rename; the stringly-typed magic key is worth replacing with a
first-class flag later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
delivery_mode became nearly vestigial after the review fixes: the offset reaper
dropped its delivery_mode filter, leaving only reset_consumer_group's "no-op for
queue groups" guard reading it. Exclusivity is already cached in-memory per
group (@registered_groups), so derive the guard from that instead and drop the
persisted column.

Bonus: this also fixes existing databases created before delivery_mode was
added. register_consumer_group no longer references the column, so a DB whose
consumer_groups table lacks it (install! only create_table?s, never alters) no
longer crashes on the INSERT — previously an opaque per-worker crash/respawn
loop.

Removes the column from the migration, from register_consumer_group's INSERT,
and the three column-assertion specs (exclusivity is observable via the reset
no-op behavior, which is still tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e public Store#schedule_messages and :schedule action
…ueues

README lagged behind the queue-mode work. Fix the now-broken "Delayed
messages" example (schedule_messages is private — use append with a
future-dated message) and add sections for the duck-typed reactor protocol
(handled_messages/handle_claim + signals + ReactorDefaults) and delete-on-ack
queues (exclusive + delete:true ack + id-partitioning).

Correct the CLAUDE.md delete-on-ack example: a Decider's command DSL never
emits delete:true, so show a Consumer-based queue overriding handle_claim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Durable-messaging runtime: signal actions, duck-typed reactors, and delete-on-ack queues
For transactions that SELECT and then UPDATE, using just BEGIN starts a DEFERRED transaction (it takes a read snapshot of the last known WAL frame).
Then the UPDATE or INSERT tries to promote the transaction to IMMEDIATE (get the global write lock), but if data has been inserted in the meantime (ie the WAL has new frames), the snapshot the process had is stale, so it raises BUSY_SNAPSHOT regardless of the BUSY_TIMEOUT config.

The solution is to start these transaction in explicit IMMEDIATE mode.

Also in this commit: make sure to add the relevant PRAGMAs to every connection checkout out from the pool. Some like foreign_keys = ON apparently need to be applied to every connection.
…lication. Sequel already applies per-connection
claim_next opened with a short-circuit that returns nil when
types_max_pos <= last_nil_types_max_pos. That watermark is derived from
handled_types, which for a Decider excludes its evolve types, and it is
never invalidated by append or release.

Under a concurrent append burst, a worker can poll nil (a sibling held
the then-pending claims, or freshly-appended offsets weren't yet visible
to its snapshot) and latch last_nil_types_max_pos at the max command
position while eager offsets for those very commands are still pending.
Because processing those commands only ever emits events — never a new
command — types_max_pos never rises above the latch, so every subsequent
poll short-circuits and the partitions are stranded forever with no error.

For eager groups the offsets table is the authoritative record of
claimable work, so scan it directly via find_and_claim_partition and skip
the watermark entirely. The short-circuit stays on the legacy path, where
offsets are created on demand and the messages table is the source of
truth. types_max_pos is now computed only there, so idle eager groups
also skip the MAX(position) query per poll.

Adds a regression test that pins last_nil_types_max_pos at a pending
eager offset's position and asserts claim_next still claims it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It looped over every unclaimed offset in Ruby and ran a separate pending
subquery per offset — N round-trips per poll. That was tolerable while the
eager path short-circuited on last_nil_types_max_pos, but now that the
eager path scans on every poll (see previous commit), an idle group with
many partitions paid N queries each catch-up tick.

Replace the loop with one query: a correlated EXISTS applies the AND-match
(a message qualifies only when indexed under every one of the offset's
key_pairs) and ORDER BY last_position LIMIT 1 picks the same offset the
loop would have. EXPLAIN QUERY PLAN confirms it is fully index-backed
(offsets cg/claimed index, message_type index, offset/message key_pair
PKs). The guarded UPDATE ... WHERE claimed = 0 still handles the
lost-race-to-a-sibling case, so semantics are unchanged — covered by the
existing #claim_next specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removing the nil-claim short-circuit from the eager path (two commits ago)
fixed the stranding bug but left every poll scanning all unclaimed offsets
— O(partitions) per catch-up tick even when the group is fully idle.

Bring the short-circuit back, correctly. For partitioned groups,
last_nil_types_max_pos now holds a sign-encoded scan version: >= 0 means
claimable work may have changed since the last empty scan, < 0 means an
empty scan completed at version -value and nothing has changed since — so
claim_next returns nil in O(1).

Invalidation lives in the database as two triggers installed with the
schema, so it holds for every writer — any process (including ones that
never registered the group), the scheduled-message promoter, stale-claim
reaping, or raw SQL:

- AFTER INSERT ON messages: any new message may create claimable work,
  bump every partitioned group.
- AFTER UPDATE OF claimed (1 -> 0) ON offsets: releasing a claim (ack,
  ack_and_delete, release, stale-claim reaping) can expose a batch
  remainder a sibling's nil scan skipped while the partition was claimed.

claim_next latches clean via compare-and-set against the version it read
before scanning; abs(value) + 1 keeps versions strictly monotonic, so a
concurrent bump during the scan always misses the CAS (no ABA) and the
group stays dirty. start_consumer_group resets the latch so resumed
groups rescan. Legacy (NULL partition_by) groups are excluded from the
triggers and keep the column's original watermark semantics.

Benchmark, 5000 caught-up partitions: idle poll 15ms dirty scan once,
then 0.07-0.10ms latched; one append re-opens the gate and is claimed.
Verified end-to-end in the seats app: a full-speed 36-command burst (the
original stranding scenario) drains completely with no errors and both
groups settle latched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dically

The claim scan's query plan is stats-dependent: without sqlite_stat1 the
planner drives the pending-work EXISTS from the low-selectivity
message_type index, degrading an idle scan to ~O(offsets x messages) — in
practice a hang on large stores (a 30k-offset synthetic never completed).
With stats it picks the key_pair-first plan, fully index-backed. Nothing
ever ran ANALYZE.

Add Store#optimize!: a bounded ANALYZE (analysis_limit = 400) with both
statements pinned to one pooled connection, since analysis_limit is
per-connection and would otherwise silently not apply. Run it from
install! so every boot seeds stats, and from the StaleClaimReaper loop
(default every 3600s) so stats track log growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Message codecs: native Ruby types in messages, JSON at the store boundary
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.

1 participant