Skip to content

Run SQL persistence cleanup in bounded batches#6564

Open
AVtheking wants to merge 1 commit into
Effect-TS:mainfrom
AVtheking:codex/batch-persistence-expiration-cleanup
Open

Run SQL persistence cleanup in bounded batches#6564
AVtheking wants to merge 1 commit into
Effect-TS:mainfrom
AVtheking:codex/batch-persistence-expiration-cleanup

Conversation

@AVtheking

@AVtheking AVtheking commented Jul 23, 2026

Copy link
Copy Markdown

Summary

  • add expiry indexes for the shared SQL persistence table across PostgreSQL, MySQL, SQL Server, and SQLite
  • replace the unbounded startup deletion with a scoped worker that deletes expired entries in batches of 1,000 until drained
  • use dialect-appropriate atomic deletion queries and retain a short delay between batches
  • add integration coverage for bounded cleanup while preserving live and future entries

Motivation

Previously, expiration cleanup was coupled to BackingPersistence.make. There was no independent cleanup process, so expired entries for a store were only considered when that store was opened again. If make was never called again, those rows could remain in effect_persistence indefinitely.

When cleanup did run, it issued one unbounded DELETE for every expired entry in that store. The expires column also had no supporting index, so the database could scan a large part of the table and delete an arbitrary number of rows in a single statement. That combination could cause significant database load, longer-held locks, and unpredictable startup latency.

Validation

  • pnpm lint-fix
  • pnpm check
  • pnpm test packages/sql/pg/test/Persistence.test.ts (14 tests)
  • pnpm test packages/sql/mysql2/test/Persistence.test.ts (12 tests)
  • pnpm test packages/sql/sqlite-node/test/Persistence.test.ts (11 tests)
  • SQL Server query compiler verification

Summary by CodeRabbit

  • New Features

    • SQL-backed shared-table persistence now automatically cleans up expired entries in indexed, bounded background batches.
    • Cleanup runs periodically and processes expirations without affecting active/non-expiring entries.
    • Added/ensured expiration indexing across supported SQL dialects to improve cleanup efficiency.
  • Tests

    • Added SQL cleanup coverage for MySQL, PostgreSQL, and SQLite, including verification of eventual deletion of expired rows and correct expiration index creation.

This PR was raised with the help of Codex ( GPT-5.6-Sol-high )

@changeset-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4af19bf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 28 packages
Name Type
effect Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-node-shared Patch
@effect/platform-node Patch
@effect/vitest Patch
@effect/ai-anthropic Patch
@effect/ai-openai-compat Patch
@effect/ai-openai Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/docgen Patch
@effect/openapi-generator Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-project-automation github-project-automation Bot moved this to Discussion Ongoing in PR Backlog Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Shared SQL persistence now creates an expiration index and performs periodic bounded cleanup of expired rows across SQL dialects. MySQL, PostgreSQL, and SQLite tests verify deletion of expired records, retention of live records, and index creation.

Changes

Persistence cleanup

Layer / File(s) Summary
Indexed background cleanup
packages/effect/src/unstable/persistence/Persistence.ts, .changeset/batch-persistence-expiration-cleanup.md
Shared-table SQL persistence creates an expiration index, deletes expired rows in scheduled bounded batches, logs cleanup failures, removes store-scoped startup deletion, and declares a patch release.
Cross-dialect cleanup validation
packages/sql/mysql2/test/Persistence.test.ts, packages/sql/pg/test/Persistence.test.ts, packages/sql/sqlite-node/test/Persistence.test.ts
Database-specific tests verify batched expiration cleanup, preservation of live rows, and creation of the expiration index.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PersistenceLayer
  participant TestClock
  participant SQLDatabase
  PersistenceLayer->>SQLDatabase: create expiration index
  PersistenceLayer->>TestClock: schedule periodic cleanup
  TestClock-->>PersistenceLayer: advance cleanup time
  PersistenceLayer->>SQLDatabase: delete expired rows in batches
  SQLDatabase-->>PersistenceLayer: return cleanup result
Loading

Suggested labels: enhancement

Suggested reviewers: xianjianlf2

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
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.

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

@AVtheking
AVtheking marked this pull request as ready for review July 23, 2026 22:39
@coderabbitai coderabbitai Bot added the enhancement New feature or request label Jul 23, 2026
Comment thread packages/effect/src/unstable/persistence/Persistence.ts Outdated

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

🧹 Nitpick comments (5)
packages/effect/src/unstable/persistence/Persistence.ts (2)

606-659: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Dialect-specific batched delete queries look sound.

  • pg: CTE with ctid, bounded by LIMIT, atomic single statement.
  • mysql: single-table DELETE ... ORDER BY ... LIMIT, wrapped in sql.withTransaction so the follow-up SELECT ROW_COUNT() reads from the same session/connection as the DELETE — this is the right call given connection pooling.
  • mssql: UPDLOCK, READPAST, READCOMMITTEDLOCK hints reduce lock contention with concurrent readers/writers, and the whole thing is one statement.
  • sqlite: rowid IN (...) bounded subquery + RETURNING, avoiding interactive transactions.

Minor nit on the mysql branch (Lines 619-631): wrapping an inner Effect.gen(...).pipe(sql.withTransaction) inside an outer Effect.fnUntraced(function*(...) { return yield* ... }) is a bit redundant — the outer generator only does one yield* and returns. Could flatten to a single function.

♻️ Flatten the mysql branch
-    mysql: () =>
-      Effect.fnUntraced(function*(expiresAtOrBefore: number) {
-        return yield* Effect.gen(function*() {
-          yield* sql`
-            DELETE FROM ${table}
-            WHERE expires IS NOT NULL AND expires <= ${expiresAtOrBefore}
-            ORDER BY expires
-            LIMIT ${sql.literal(String(cleanupBatchSize))}
-          `
-          const rows = yield* sql<{ readonly count: number }>`SELECT ROW_COUNT() AS count`
-          return Number(rows[0].count)
-        }).pipe(sql.withTransaction)
-      }),
+    mysql: () => (expiresAtOrBefore: number) =>
+      Effect.gen(function*() {
+        yield* sql`
+          DELETE FROM ${table}
+          WHERE expires IS NOT NULL AND expires <= ${expiresAtOrBefore}
+          ORDER BY expires
+          LIMIT ${sql.literal(String(cleanupBatchSize))}
+        `
+        const rows = yield* sql<{ readonly count: number }>`SELECT ROW_COUNT() AS count`
+        return Number(rows[0].count)
+      }).pipe(sql.withTransaction),
🤖 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/effect/src/unstable/persistence/Persistence.ts` around lines 606 -
659, Flatten the mysql branch’s redundant Effect wrapper by replacing the outer
Effect.fnUntraced generator with a single function that directly returns the
existing Effect.gen(...).pipe(sql.withTransaction) flow. Preserve the DELETE,
same-connection ROW_COUNT query, transaction wrapping, and returned count
behavior.

602-604: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider making cleanup tuning knobs configurable.

cleanupBatchSize, cleanupBatchDelay, and cleanupInterval are hardcoded. Different deployments may want to tune batch size/interval for their table size or DB load; exposing these via layer options (with these as defaults) would avoid a code change for tuning.

🤖 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/effect/src/unstable/persistence/Persistence.ts` around lines 602 -
604, Expose cleanupBatchSize, cleanupBatchDelay, and cleanupInterval as
configurable layer options in the persistence setup, while retaining 1000, 10
milliseconds, and 5 minutes as their defaults. Update the cleanup scheduling and
batching logic to use the configured option values instead of the hardcoded
constants.
packages/sql/mysql2/test/Persistence.test.ts (1)

27-89: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Solid cross-dialect cleanup test; a couple of minor robustness notes.

The dual-phase polling (real-time wait for async batch 1, then TestClock.adjust to trigger the scheduled batch 2) correctly models the mix of virtual schedule delay and real DB I/O latency.

Two minor points (shared with the pg/sqlite variants, flagged once here and noted at each site):

  1. The magic number 1001/expected remainder 1 is implicitly coupled to the source's hardcoded cleanupBatchSize = 1000. If that constant changes, three test files across dialects need matching updates independently.
  2. The retry budget of 100 * 10ms (~1s of live wait) per polling loop could be flaky under slow CI/test containers.
🤖 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/sql/mysql2/test/Persistence.test.ts` around lines 27 - 89, Update
the “deletes expired entries in batches” test to avoid hardcoding the cleanup
batch size and remainder values; reuse the shared cleanupBatchSize symbol or
derive the inserted count and expected remainder from it. Also replace the fixed
100-iteration polling limits with a less flaky wait mechanism or sufficiently
tolerant timeout while preserving both asynchronous cleanup phases.
packages/sql/sqlite-node/test/Persistence.test.ts (1)

110-172: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consistent with the mysql/pg variants; same duplication/coupling notes apply.

See the consolidated comment for the shared magic-number coupling and duplicated polling-loop pattern across dialect test files.

🤖 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/sql/sqlite-node/test/Persistence.test.ts` around lines 110 - 172,
Refactor the “deletes expired entries in batches” test to remove duplicated
polling logic and shared magic-number coupling across dialect variants. Extract
the polling behavior and batch/timing values into reusable test helpers or
constants, then reuse them for both cleanup loops while preserving the existing
assertions and expiration behavior.
packages/sql/pg/test/Persistence.test.ts (1)

107-169: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consistent with the mysql/sqlite variants; same duplication/coupling notes apply.

See the consolidated comment for the shared magic-number coupling and duplicated polling-loop pattern across dialect test files.

🤖 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/sql/pg/test/Persistence.test.ts` around lines 107 - 169, Update the
“deletes expired entries in batches” test to remove duplicated polling logic and
shared magic-number coupling. Reuse the established polling helper/constants
from the other persistence dialect tests for batch size, retry limits, and
polling interval, while preserving the assertions for eventual deletion and
live-entry retention.
🤖 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.

Nitpick comments:
In `@packages/effect/src/unstable/persistence/Persistence.ts`:
- Around line 606-659: Flatten the mysql branch’s redundant Effect wrapper by
replacing the outer Effect.fnUntraced generator with a single function that
directly returns the existing Effect.gen(...).pipe(sql.withTransaction) flow.
Preserve the DELETE, same-connection ROW_COUNT query, transaction wrapping, and
returned count behavior.
- Around line 602-604: Expose cleanupBatchSize, cleanupBatchDelay, and
cleanupInterval as configurable layer options in the persistence setup, while
retaining 1000, 10 milliseconds, and 5 minutes as their defaults. Update the
cleanup scheduling and batching logic to use the configured option values
instead of the hardcoded constants.

In `@packages/sql/mysql2/test/Persistence.test.ts`:
- Around line 27-89: Update the “deletes expired entries in batches” test to
avoid hardcoding the cleanup batch size and remainder values; reuse the shared
cleanupBatchSize symbol or derive the inserted count and expected remainder from
it. Also replace the fixed 100-iteration polling limits with a less flaky wait
mechanism or sufficiently tolerant timeout while preserving both asynchronous
cleanup phases.

In `@packages/sql/pg/test/Persistence.test.ts`:
- Around line 107-169: Update the “deletes expired entries in batches” test to
remove duplicated polling logic and shared magic-number coupling. Reuse the
established polling helper/constants from the other persistence dialect tests
for batch size, retry limits, and polling interval, while preserving the
assertions for eventual deletion and live-entry retention.

In `@packages/sql/sqlite-node/test/Persistence.test.ts`:
- Around line 110-172: Refactor the “deletes expired entries in batches” test to
remove duplicated polling logic and shared magic-number coupling across dialect
variants. Extract the polling behavior and batch/timing values into reusable
test helpers or constants, then reuse them for both cleanup loops while
preserving the existing assertions and expiration behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0e780bf3-ce79-4f5c-96a8-18755a9ef732

📥 Commits

Reviewing files that changed from the base of the PR and between b6392e1 and 0558e81.

📒 Files selected for processing (5)
  • .changeset/batch-persistence-expiration-cleanup.md
  • packages/effect/src/unstable/persistence/Persistence.ts
  • packages/sql/mysql2/test/Persistence.test.ts
  • packages/sql/pg/test/Persistence.test.ts
  • packages/sql/sqlite-node/test/Persistence.test.ts

@AVtheking
AVtheking force-pushed the codex/batch-persistence-expiration-cleanup branch from 0558e81 to 4af19bf Compare July 23, 2026 22:53

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

🧹 Nitpick comments (1)
packages/effect/src/unstable/persistence/Persistence.ts (1)

661-666: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a short comment explaining the schedule's stop condition.

The combinator chain (setInputType + passthrough + while + addDelay) is non-obvious: it stops draining once a batch deletes 0 rows and otherwise waits cleanupBatchDelay between batches. A one-line comment would save future readers from re-deriving this from the Schedule API.

📝 Suggested comment
+  // Repeatedly delete a batch of expired rows, pausing cleanupBatchDelay between
+  // batches, until a batch deletes nothing (deletedCount === 0).
   const deleteExpiredSchedule = Schedule.forever.pipe(
     Schedule.setInputType<number>(),
     Schedule.passthrough,
     Schedule.while(({ input: deletedCount }) => deletedCount !== 0),
     Schedule.addDelay(() => Effect.succeed(cleanupBatchDelay))
   )
🤖 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/effect/src/unstable/persistence/Persistence.ts` around lines 661 -
666, Add a concise one-line comment immediately above deleteExpiredSchedule
explaining that it repeatedly deletes batches, stops when a batch deletes zero
rows, and waits cleanupBatchDelay between batches.
🤖 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.

Nitpick comments:
In `@packages/effect/src/unstable/persistence/Persistence.ts`:
- Around line 661-666: Add a concise one-line comment immediately above
deleteExpiredSchedule explaining that it repeatedly deletes batches, stops when
a batch deletes zero rows, and waits cleanupBatchDelay between batches.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c3d863a2-91da-45bf-8dcf-0fab51c36285

📥 Commits

Reviewing files that changed from the base of the PR and between 0558e81 and 4af19bf.

📒 Files selected for processing (5)
  • .changeset/batch-persistence-expiration-cleanup.md
  • packages/effect/src/unstable/persistence/Persistence.ts
  • packages/sql/mysql2/test/Persistence.test.ts
  • packages/sql/pg/test/Persistence.test.ts
  • packages/sql/sqlite-node/test/Persistence.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/sql/mysql2/test/Persistence.test.ts
  • .changeset/batch-persistence-expiration-cleanup.md
  • packages/sql/pg/test/Persistence.test.ts

@github-actions

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

File Name Current Size Previous Size Difference
basic.ts 6.64 KB 6.64 KB 0.00 KB (0.00%)
batching.ts 9.33 KB 9.33 KB 0.00 KB (0.00%)
brand.ts 6.20 KB 6.20 KB 0.00 KB (0.00%)
cache.ts 10.13 KB 10.13 KB 0.00 KB (0.00%)
config.ts 19.02 KB 19.02 KB 0.00 KB (0.00%)
differ.ts 18.39 KB 18.39 KB 0.00 KB (0.00%)
http-client.ts 20.65 KB 20.65 KB 0.00 KB (0.00%)
logger.ts 10.24 KB 10.24 KB 0.00 KB (0.00%)
metric.ts 8.52 KB 8.52 KB 0.00 KB (0.00%)
optic.ts 7.35 KB 7.35 KB 0.00 KB (0.00%)
pubsub.ts 14.17 KB 14.17 KB 0.00 KB (0.00%)
queue.ts 11.10 KB 11.10 KB 0.00 KB (0.00%)
schedule.ts 10.28 KB 10.28 KB 0.00 KB (0.00%)
schema-class.ts 18.12 KB 18.12 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 27.27 KB 27.27 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 24.12 KB 24.12 KB 0.00 KB (0.00%)
schema-string-transformation.ts 12.59 KB 12.59 KB 0.00 KB (0.00%)
schema-string.ts 10.26 KB 10.26 KB 0.00 KB (0.00%)
schema-template-literal.ts 14.31 KB 14.31 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 20.89 KB 20.89 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 23.37 KB 23.37 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 18.28 KB 18.28 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 17.96 KB 17.96 KB 0.00 KB (0.00%)
schema-toFormatter.ts 17.82 KB 17.82 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 21.46 KB 21.46 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 18.51 KB 18.51 KB 0.00 KB (0.00%)
schema.ts 17.38 KB 17.38 KB 0.00 KB (0.00%)
stm.ts 12.02 KB 12.02 KB 0.00 KB (0.00%)
stream.ts 9.28 KB 9.28 KB 0.00 KB (0.00%)

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

Labels

enhancement New feature or request

Projects

Status: Discussion Ongoing

Development

Successfully merging this pull request may close these issues.

2 participants