-
[OPIK-7246] fix: buffer projects.last_updated_trace_at in Redis, flush to MySQL periodically (#7361)
发布于
2026-07-08 09:36:56 +00:00 - [OPIK-7246] fix: buffer projects.last_updated_trace_at in Redis, flush to MySQL periodically
ProjectEventListener wrote projects.last_updated_trace_at synchronously on every
TracesCreated/TracesUpdated event (via ProjectService.recordLastUpdatedTrace) on an
unbounded virtual-thread AsyncEventBus, funneling concurrent WRITE transactions onto a
single hot projects row and producing MySQLTransactionRollbackException (deadlock /
lock-wait) and EOFException / ConnectException on the ingestion path.Buffer the per-project maximum timestamp in a Redis ZSET (addIfGreater, member
"workspaceId:projectId", score = epoch millis) and flush the accumulated maxima to MySQL
under a best-effort distributed lock every 30s (ProjectLastUpdatedFlushJob). Many trace
writes per project collapse into one batched UPDATE per flush from a single flusher,
removing the hot-row contention and connection churn. Gated behind projectLastUpdatedFlush
(default off); the synchronous MySQL path remains the fallback. Monotonic (forward-only
guard preserved), idempotent, and fails open on Redis errors.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] fix: eliminate flush read-then-remove race via atomic rename-drain
Addresses PR review: flushLastUpdatedTraces read entries then removeAll'd them, so a
concurrent recordLastUpdatedTrace (addIfGreater) between the read and the removal could be
dropped, leaving the marker stale. Atomically renamenx the live buffer into a snapshot key
(project:last-updated-trace:flushing); writers immediately continue on a fresh live key and
the drain owns the snapshot exclusively, so its removeAll cannot race a new write. A snapshot
left by an interrupted flush is drained on the next cycle (idempotent; the DB write only moves
forward). Uses only Redisson APIs (isExists/renamenx/entryRange/removeAll) — no raw Redis/Lua.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] fix flush drain re-read, use builder for parsed record, Javadoc
- Drain the flushing snapshot one page at a time, reading the next page only after the
current page's write + removeAll complete. The previous Flux.expand pre-fetched the next
entryRange(0, batchSize-1) before removal, so the same top entries could be read and
written to MySQL repeatedly until the iteration cap stopped the loop (idempotent but
wasteful). Removal now gates pagination, guaranteeing forward progress. - ParsedLastUpdatedTrace: @Builder(toBuilder = true) + @NonNull, built via builder.
- Convert explanatory block comments to Javadoc.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] ProjectServiceImpl: explicit @Inject constructor for @Config param
@Config is placed directly on the constructor parameter rather than relying on Lombok's
generated constructor to carry it. Replaces @RequiredArgsConstructor(onConstructor_ = @Inject)
with an explicit constructor (same pattern as ProjectLastUpdatedFlushJob).Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] separate concerns: buffer-write service, MySQL-only ProjectService, drain in job
Refactor per review:
- ProjectServiceImpl now only does the MySQL project update (recordLastUpdatedTrace);
dropped the Redis/config dependencies, the buffering branch and the drain. Reverted to
the Lombok constructor (no @Config here anymore). - New ProjectLastUpdatedTraceBufferService (domain) owns the ingestion-path Redis buffer
write (addIfGreater) and the enabled/disabled decision; ProjectEventListener uses it. - ProjectLastUpdatedFlushJob owns ProjectLastUpdatedFlushConfig and the Redis read/drain
(renamenx snapshot + paged entryRange/removeAll + parse) and calls ProjectService for the
MySQL write. - Tests: add ProjectLastUpdatedTraceBufferServiceTest; move drain coverage into
ProjectLastUpdatedFlushJobTest; remove ProjectServiceLastUpdatedTraceTest.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] address review: flush logic in service, thin job, sync writes, validation
Per andrescrz review:
- Move all flush/Redis logic into ProjectLastUpdatedTraceBufferService (now owns record()
- flush() + the ZSET key constants). The job is thin: scheduling, distributed lock and
interruption only, delegating to bufferService.flush().
- flush() + the ZSET key constants). The job is thin: scheduling, distributed lock and
- Use the synchronous StringRedisClient so record() no longer hides an async side-effect
behind a void signature; the drain becomes a simple sequential loop. - interrupt() now disposes the in-flight flush subscription, not just the schedule.
- Null-safe argument handling on public methods via CollectionUtils.isEmpty.
- Config: remove Java defaults (single source of truth is config.yml), add @Max bounds;
key constants moved out of config into the service.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] add projectLastUpdatedFlush block to config-test.yml
Removing the Java defaults made the config fields @NotNull with no fallback, so every full
config file must define the block. config-test.yml lacked it, failing config validation at
resource-test bootstrap (TestHttpClientUtils static init). Add the block with test values.Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] address review: interface validation, flush log wording, interrupt test
- Remove @NonNull from the ProjectLastUpdatedTraceBufferService interface method (validation
is an impl detail); keep it on the implementation. - flush() count is markers processed, not rows updated (the MySQL write is a monotonic UPDATE
that skips already-ahead rows). Relabel the job log "wrote" -> "processed" and document it. - Add a job test asserting interrupt() disposes the in-flight flush subscription.
Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com
- [OPIK-7246] address review: propagate record() errors, decouple job scheduling, integration tests
- record() lets Redis failures bubble to the caller instead of swallowing them,
so ProjectEventListener owns the handling like the synchronous fallback branch. - flush()'s drain loop now conditions on the page-size check instead of an
internal while(true)/break, avoiding the appearance of a potential infinite loop. - Split "buffering enabled" from "job scheduled" via a new jobEnabled flag, so
tests can exercise the buffer/flush and job wiring deterministically without
racing the Quartz schedule. - Replaced the mock-heavy unit tests with black-box/integration coverage:
ProjectLastUpdatedTraceBufferServiceTest drives record()/flush() against real
Redis/MySQL and asserts through the project API; ProjectLastUpdatedFlushJobTest
is a single happy-path smoke test mirroring ExperimentProjectMigrationJobTest.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
- [OPIK-7246] move ProjectLastUpdatedFlushJobTest to its job's package
The Job class lives in api.resources.v1.jobs, matching its sibling
ExperimentDenormalizationJob/Test (same periodic-flush-job shape). The domain
package is for migration jobs tightly coupled to a domain migration service,
which this isn't.Dropped the dependency on PENDING_SET_KEY (package-private in the domain
package) by polling via repeated doJob() invocations until the project API
reflects the flush, instead of peeking at the buffer's internal Redis key.Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Co-authored-by: Claude Opus 4.8 (1M context) noreply@anthropic.com
下载附件