Skip to content

Refactor InMemoryTable entity storage to track changes - #1258

Merged
DZakh merged 8 commits into
mainfrom
claude/vibrant-galileo-g3QYt
Jun 1, 2026
Merged

Refactor InMemoryTable entity storage to track changes#1258
DZakh merged 8 commits into
mainfrom
claude/vibrant-galileo-g3QYt

Conversation

@DZakh

@DZakh DZakh commented May 29, 2026

Copy link
Copy Markdown
Member

Summary

Refactored the InMemoryTable.Entity module to store entity changes directly instead of wrapping them in an intermediate entityWithIndices record. This simplifies the data structure and makes change tracking more explicit.

Key Changes

  • Replaced entityWithIndices record with a flatter structure:

    • entities: dict<entityWithIndices>latestEntityChangeById: dict<Change.t<Internal.entity>>
    • Added prevEntityChanges: array<Change.t<Internal.entity>> to track historical changes
    • Added indicesByEntityId: dict<entityIndices> to separate index tracking
  • Removed inMemoryStoreEntityStatus type from Internal.res:

    • Eliminated the Updated and Loaded variants
    • Status is now implicit: checkpoint ID of 0n marks database-loaded values that never become history
  • Updated entity change handling:

    • set function now directly stores Change.t<Internal.entity> instead of constructing status wrappers
    • History is built on-demand in InMemoryStore.writeBatch by collecting prevEntityChanges
    • Checkpoint ID 0n is used as a sentinel to distinguish database-loaded entities from user changes
  • Simplified index management:

    • getOrCreateEntityIndices now takes self: t and entityId instead of a row reference
    • updateIndices and deleteEntityFromIndices no longer require passing row objects
    • Index lookups use indicesByEntityId dictionary directly
  • Updated rowToEntity helper to extract entity from Change.t instead of accessing a latest field

Notable Implementation Details

  • The refactoring maintains the same external behavior while reducing indirection in the data model
  • Change history is now computed from prevEntityChanges array during persistence, rather than stored inline with each entity
  • The sentinel checkpoint ID 0n eliminates the need for an explicit Loaded status variant

https://claude.ai/code/session_01VnnXgvyEfHMFBCURZyhaqw

Summary by CodeRabbit

  • Refactor
    • Unified in-memory entity state to a flat change-based format and aligned persistence layers to consume that format.
    • Updated downstream sinks and storage writers to process per-entity change lists consistently.
  • Tests
    • Made history query ordering deterministic for reliable test assertions.
  • Behavior
    • Load-by-ID deduplication and change filtering improved to avoid replaying DB-loaded checkpoints.

Replace the entityWithIndices row (latest + status + entityIndices) with
three flat structures on Entity.t:
- latestEntityChangeById: the latest Change per entity (checkpointId 0n
  marks a value loaded from the db that never becomes history)
- prevEntityChanges: demotion-ordered superseded changes
- indicesByEntityId: per-entity index set

writeBatch regroups prevEntityChanges by entity id to rebuild the
inMemoryStoreEntityUpdate boundary, so PgStorage/ClickHouse consumers are
unchanged. Removes the now-unused inMemoryStoreEntityStatus type.
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cc25b26e-c205-4b7e-abd7-024b7d18d076

📥 Commits

Reviewing files that changed from the base of the PR and between 7ba55dd and 5cbee36.

📒 Files selected for processing (2)
  • packages/envio/src/PgStorage.res
  • packages/envio/src/TestIndexer.res

📝 Walkthrough

Walkthrough

This PR converts in-memory entity storage to a change-log model (latestEntityChangeById + prevEntityChanges), adds loadedFromDbCheckpointId to mark DB-loaded changes, and propagates a flat per-entity changes array through InMemoryStore.writeBatch, Postgres/ClickHouse persistence, sink wiring, and test serialization/processing.

Changes

Entity Storage and Persistence Refactor

Layer / File(s) Summary
In-memory entity storage refactor
packages/envio/src/InMemoryTable.res
Entity.t now uses latestEntityChangeById, prevEntityChanges, and indicesByEntityId; index maintenance, init/set/get, and index rebuilds operate on Change.t values instead of row records.
Types & checkpoint contract
packages/envio/src/Internal.res, packages/envio/src/Persistence.res
Adds loadedFromDbCheckpointId: checkpointId = 0n; removes prior in-memory update/status union types; Persistence.updatedEntity now carries changes: array<Change.t<Internal.entity>>.
InMemoryStore updatedEntities computation
packages/envio/src/InMemoryStore.res
writeBatch builds per-entity change arrays by combining prevEntityChanges with latestEntityChangeById entries excluding loadedFromDbCheckpointId, omitting empty results.
Load-by-ID presence check
packages/envio/src/LoadLayer.res
loadById uses latestEntityChangeById->Dict.has for in-memory presence when deduplicating loads.
Postgres persistence (writeBatch)
packages/envio/src/PgStorage.res
Processes flat changes arrays per entity: single-pass classification of latest Set/Delete for entity-table writes, and precomputes history/backfill batches with rollback-diff exclusion.
ClickHouse persistence & Sink
packages/envio/src/bindings/ClickHouse.res, packages/envio/src/Sink.res
ClickHouse conversion caches now accept an array of Change.t and validate/convert batches (S.array). Sink.writeBatch passes changes to ClickHouse conversion.
Test infra: serialization and processing
packages/envio/src/TestIndexer.res, packages/envio/src/TestIndexerProxyStorage.res, scenarios/test_codegen/test/helpers/MockIndexer.res
TestIndexer processes each Change directly and accumulates per-checkpoint sets/deletes; TestIndexerProxyStorage serializes changes arrays; MockIndexer.queryHistory sorts parsed history by entityId then checkpointId for deterministic tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • JonoPrest
  • JasoonS
🚥 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: refactoring InMemoryTable entity storage to track changes via a new structure replacing the previous row-based approach.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

claude added 3 commits May 29, 2026 14:37
Replace the per-entity-id {latestChange, history} grouping in the
writeBatch boundary (Persistence.updatedEntity) with a flat
`changes: array<Change.t>` log. Consumers regroup as needed:
- PgStorage/ClickHouse via Internal.groupChangesByEntityId
- TestIndexer locally over its serialized change form

Introduce Internal.loadedFromDbCheckpointId for the 0n sentinel marking
db-loaded changes, and rename InMemoryTable.Entity.rowToEntity to
mapChangeToEntity now that it maps a Change.
…o-g3QYt

# Conflicts:
#	packages/envio/src/bindings/ClickHouse.res
entity_history insert order isn't meaningful (checkpointId is the
ordering key), so groupChangesByEntityId no longer preserves it and the
queryHistory test helper sorts by (entityId, checkpointId) for stable
assertions.
- ClickHouse setUpdatesOrThrow compiles a single S.array converter instead
  of mapping each change, converting the whole batch in one call.
- Inline the per-id grouping into PgStorage's setEntities (its only
  remaining user) as a single-pass dict of change groups, iterating group
  arrays by index to avoid intermediate update records and history slices.
- Drop the now-unused Internal.groupChangesByEntityId and
  inMemoryStoreEntityUpdate.

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
scenarios/test_codegen/test/helpers/MockIndexer.res (1)

427-456: 💤 Low value

Potential precision loss when comparing large checkpoint IDs.

BigInt.toFloat on line 451 can lose precision for checkpoint IDs exceeding 2^53 (~9 quadrillion). While unlikely in practice, this could cause non-deterministic ordering for very large IDs.

Consider using BigInt.compare or string comparison for full precision:

Alternative using string comparison
           ->Array.toSorted((a, b) => {
             switch String.compare(a->Change.getEntityId, b->Change.getEntityId) {
             | 0. =>
-              Float.compare(
-                a->Change.getCheckpointId->BigInt.toFloat,
-                b->Change.getCheckpointId->BigInt.toFloat,
-              )
+              // Compare as strings with zero-padding for consistent ordering
+              let aId = a->Change.getCheckpointId->BigInt.toString
+              let bId = b->Change.getCheckpointId->BigInt.toString
+              String.compare(aId->String.padStart(20, "0"), bId->String.padStart(20, "0"))
             | order => order
             }
           })
🤖 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 `@scenarios/test_codegen/test/helpers/MockIndexer.res` around lines 427 - 456,
The comparator passed to Array.toSorted currently converts checkpoint IDs via
BigInt.toFloat (used on Change.getCheckpointId) which can lose precision for
very large IDs; replace the Float conversion with a full-precision comparison
(e.g., use BigInt.compare on a->Change.getCheckpointId and
b->Change.getCheckpointId) or compare the checkpointId strings to determine
order so stable ordering is preserved; update the comparator in the
Array.toSorted block to call BigInt.compare (or String.compare on
BigInt.toString) instead of BigInt.toFloat.
packages/envio/src/TestIndexer.res (1)

161-161: 💤 Low value

Missing type annotation on Utils.magic.

Per coding guidelines, Utils.magic should have explicit type annotations.

Suggested fix
-        entityChange.sets->Array.push(parsedEntity->Utils.magic)->ignore
+        entityChange.sets->Array.push(parsedEntity->(Utils.magic: Internal.entity => unknown))->ignore

As per coding guidelines: "When using Utils.magic for type casting in ReScript, always add explicit type annotations: value->(Utils.magic: inputType => outputType)"

🤖 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 `@packages/envio/src/TestIndexer.res` at line 161, The call to Utils.magic used
when pushing into entityChange.sets lacks an explicit ReScript type annotation;
change parsedEntity->Utils.magic to use an explicit cast like
parsedEntity->(Utils.magic: inputType => outputType) where inputType is the
actual type of parsedEntity and outputType is the element type expected by
entityChange.sets (the collection element type used by Array.push). Update the
expression referenced
(entityChange.sets->Array.push(parsedEntity->Utils.magic)->ignore) to include
this explicit annotation so the compiler and readers know the intended
conversion.
🤖 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 `@packages/envio/src/TestIndexer.res`:
- Around line 200-209: The current loop over changesByEntityId turns each
entityChanges into history that excludes the last change and only calls
processChange for the last change when history is empty, which drops the latest
change for multi-change entities; update the logic in the block that iterates
over changesByEntityId/Dict.valuesToArray/Array.forEach so that after computing
lastIdx and history you always call processChange for the last element
(entityChanges->Array.getUnsafe(lastIdx)) in addition to processing history (or
change the slice to include the last element), ensuring processChange is invoked
for the final/latest change for every entity.

---

Nitpick comments:
In `@packages/envio/src/TestIndexer.res`:
- Line 161: The call to Utils.magic used when pushing into entityChange.sets
lacks an explicit ReScript type annotation; change parsedEntity->Utils.magic to
use an explicit cast like parsedEntity->(Utils.magic: inputType => outputType)
where inputType is the actual type of parsedEntity and outputType is the element
type expected by entityChange.sets (the collection element type used by
Array.push). Update the expression referenced
(entityChange.sets->Array.push(parsedEntity->Utils.magic)->ignore) to include
this explicit annotation so the compiler and readers know the intended
conversion.

In `@scenarios/test_codegen/test/helpers/MockIndexer.res`:
- Around line 427-456: The comparator passed to Array.toSorted currently
converts checkpoint IDs via BigInt.toFloat (used on Change.getCheckpointId)
which can lose precision for very large IDs; replace the Float conversion with a
full-precision comparison (e.g., use BigInt.compare on a->Change.getCheckpointId
and b->Change.getCheckpointId) or compare the checkpointId strings to determine
order so stable ordering is preserved; update the comparator in the
Array.toSorted block to call BigInt.compare (or String.compare on
BigInt.toString) instead of BigInt.toFloat.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 16f31282-5a65-4b93-9f9c-5314af10de95

📥 Commits

Reviewing files that changed from the base of the PR and between 3798ba8 and 7ba55dd.

📒 Files selected for processing (11)
  • packages/envio/src/InMemoryStore.res
  • packages/envio/src/InMemoryTable.res
  • packages/envio/src/Internal.res
  • packages/envio/src/LoadLayer.res
  • packages/envio/src/Persistence.res
  • packages/envio/src/PgStorage.res
  • packages/envio/src/Sink.res
  • packages/envio/src/TestIndexer.res
  • packages/envio/src/TestIndexerProxyStorage.res
  • packages/envio/src/bindings/ClickHouse.res
  • scenarios/test_codegen/test/helpers/MockIndexer.res

Comment thread packages/envio/src/TestIndexer.res Outdated
TestIndexer.handleWriteBatch dropped an entity's latest change whenever it
had history in the batch (a pre-existing quirk). Each (id, checkpointId)
appears at most once in the flat change log, so just process every change
into its checkpoint bucket.

PgStorage.setEntities now builds the latest-per-id map, the entity-table
sets/deletes, the history-table batches, and the backfill set in a single
pass over the change log plus one pass over the unique ids, instead of
grouping into per-id arrays and re-walking them. History batches are built
eagerly alongside entitiesToSet so they survive transaction retries.
@DZakh
DZakh merged commit dfffe40 into main Jun 1, 2026
8 checks passed
@DZakh
DZakh deleted the claude/vibrant-galileo-g3QYt branch June 1, 2026 10:48
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.

2 participants