Skip to content

RPC in-flight recovery across a tb-core rollout - #35

Open
ShvaykaD wants to merge 32 commits into
rpc-testsfrom
rpc-tests-inflight-recovery
Open

RPC in-flight recovery across a tb-core rollout#35
ShvaykaD wants to merge 32 commits into
rpc-testsfrom
rpc-tests-inflight-recovery

Conversation

@ShvaykaD

@ShvaykaD ShvaykaD commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Problem

During a tb-core rolling restart / device-actor migration, in-flight RPCs were lost: persistent RPCs got stuck in DELIVERED forever and were never re-published or resolved (~47% loss observed in load testing). Root cause: the per-actor rpcSeq counter resets to 0 when the actor is recreated, and the actor did not reload the in-flight (SENT/DELIVERED) rows it had persisted, so responses arriving after the move could not be matched back to a request.

Goal

Persistent RPCs = zero-loss and no mis-match across a core rollout. Non-persistent RPCs remain best-effort (unchanged).

Approach

Persist the transport requestId on the rpc row and rebuild actor state from it on init():

  • Schema — nullable request_id and oneway columns on rpc, plus a partial index idx_rpc_in_flight, applied via the LTS 4.3.1.4 patch (auto-applies on core startup through SystemPatchApplier; nullable so pre-migration rows load fine).
  • Persist — the integer requestId and the oneway flag are written to the row at RPC creation.
  • Reload (one-way-aware) — on actor init(), in-flight rows are reloaded under their persisted id, sorted by createdTime (then requestId) to preserve submission order, and their timeouts re-armed. The reload query + partial index deliberately exclude one-way DELIVERED (a terminal success that would otherwise accumulate and be loaded for nothing).
  • SeedrpcSeq is seeded once past the highest reloaded id, so a new RPC never collides with a reloaded one; with nothing in flight it still starts from 0 (the gateway dedups in-flight only and forgets an id once answered).
  • Never re-publish an answered RPCDELIVERED is terminal for re-publication; on resubscribe it is not pushed again (removes a latent double-execution). One-way SENT is re-published (only one-way DELIVERED is terminal); a null/corrupt persisted request is logged and skipped without aborting the reload.
  • Legacy backlog — rows written before this feature have no request_id and can't be tracked across the upgrade: QUEUED are still delivered; stuck SENT / two-way DELIVERED are closed to a terminal state with an explanatory response. The bulk cleanup runs outside the schema transaction, in keyset-paginated self-committing batches (a ported applyAfterCommit LTS hook) so it never blocks a serving node; rolling-window stragglers are closed by the actor's reload guard.

No proto / wire / device / transport change. Transport and rule-engine rolls are unaffected — the reload path runs only when the actor is recreated.

Tests

  • DeviceActorMessageProcessorTest — reload/seed/guard behavior: persist-on-create, DELIVERED reload matches a late device response, one-way DELIVERED not re-expired, one-way SENT re-published, legacy null-request_id SENT closed (not re-published), rpcSeq seeding, resubscribe skips DELIVERED.
  • JpaRpcDaoTestrequest_id/oneway DB round-trip and the reload query excluding one-way DELIVERED / terminal rows.
  • V4_3_1_4MigrationIntegrationTest — the batched applyAfterCommit() cleanup driven across multiple keyset windows against a real DB: only stuck legacy rows are closed; one-way DELIVERED, future-expiry, tracked (request_id set) and QUEUED rows are left untouched.
  • LtsMigrationServiceTest / LtsMigrationIntegrationTest — the two-phase applyMigrations (schema, then out-of-transaction backfill, then version record) and dir↔bean pairing.

ShvaykaD added 30 commits July 22, 2026 18:43
…ingRpc

Terminal one-way DELIVERED/SENT rows past expiry were being force-updated
to EXPIRED on every actor init(), corrupting the persisted status of an
already-successful one-way RPC. Move the one-way-terminal check ahead of
the expiry check so those rows are left untouched; two-way rows still
re-expire as before. Also drops the unused fixed-id
createToDeviceRpcRequestMsg overload, and adds a guard test asserting
TbRpcService.update is never called for a past-due one-way DELIVERED row.
restorePendingRpc's oneway guard skipped SENT (QoS-1, publish->PUBACK
window) alongside DELIVERED, permanently stranding a one-way SENT row
on a core move. Only one-way DELIVERED is terminal per spec; narrow
the guard so SENT falls through to reload/re-arm/re-publish.

Also add the missing @Schema annotation on Rpc.requestId (REST-
serialized entity) and cover both the one-way-SENT re-publish path and
the legacy null-requestId reload path with unit tests.
If a persisted rpc row's request JSON fails to deserialize (null or
corrupt), JacksonUtil.convertValue returns null; without this guard the
next msg.isOneway() call NPEs and aborts the actor's whole init() reload.
Log and skip that unrecoverable row so the remaining RPCs still restore.
A reloaded row whose request JSON deserializes to null is skipped and
logged; the reload loop continues, so a following valid row still
restores and re-publishes. Guards the null-safe branch added alongside.
…wline

Drop the fully-qualified org.assertj.core.api.Assertions.assertThat in
the reload tests for a static import (arity keeps it distinct from the
file's pre-existing hamcrest assertThat). Add the missing EOF newline.
… reload, unify register, hide requestId

- Seed rpcSeq past the highest persisted id in init() before restoring, so a
  legacy (null request_id) row's fallback id cannot collide with a persisted
  id reloaded in the same batch; drop the redundant per-row seeding and warn
  on any pending-map overwrite.
- Break same-createdTime ties by requestId (nullsLast) for deterministic
  reload / sequential re-publish order.
- Reload in-flight rows with a single findAllByDeviceIdAndStatusIn query via
  PageDataIterable instead of three per-status paging loops.
- Unify registerRestoredRpc into registerPendingRpcRequest (one register path,
  put + timeout arming together).
- Extract the undelivered(md) predicate; document why V4_3_1_4Migration exists.
- Mark Rpc.requestId @JsonIgnore — it is internal actor-recovery plumbing, not
  part of the public REST/rule-engine contract (DB round-trip via JPA is
  unaffected).
- Tests: single-query stubbing + shared helpers; add reload-EXPIRED coverage,
  a legacy-null/reused-id collision regression, and a DAO status-IN query test.
…ration backfills)

Adds LtsMigration.applyAfterCommit() and reworks LtsMigrationService to a
two-phase apply (schema+apply atomically, then afterSchemaPhase, then per-
migration applyAfterCommit + version record), matching PE's lts-4.3 so a
future CE->PE merge is conflict-free. SystemPatchApplier uses the 3-arg
applyMigrations with CE's own updateSqlViews() in the afterSchemaPhase
Runnable. Test callers updated for the new signature.
- RpcRepository.findInFlightForReload: extract the in-flight predicate to a
  single constant, reused by value + countQuery.
- Extract the legacy-close message text to one shared constant on
  DeviceActorMessageProcessor; the migration references it.
- Drop the now-unused TbRpcService.findAllByDeviceIdAndStatus (dead after the
  reload rework).
- Test: default MAX_CONCURRENT_SESSIONS_PER_DEVICE to 1 (the production default;
  >1 is the rare case) and use a non-system tenant id.
getAllowedFromStatuses(boolean oneway): TIMEOUT is a SENT-peer in-flight state; terminal trio excludes DELIVERED for one-way. Guarded UPDATE drops the SQL COALESCE(oneway) clause and passes the write's oneway from Java. Reload predicate + idx_rpc_in_flight (both schema files) include TIMEOUT. Actor sources oneway from msg.isOneway() at reload sites.
The out-of-transaction backlog cleanup now also force-closes past-expiry legacy (request_id IS NULL) rows stuck in TIMEOUT, alongside SENT and two-way DELIVERED.
ShvaykaD added 2 commits July 27, 2026 12:33
A past-expiry TIMEOUT row is force-closed to EXPIRED on reload; a not-yet-expired TIMEOUT row is re-tracked (no terminal write).
The allowed-from set is a pure function of (target status, oneway), so build the String[] arrays once at class load into EnumMaps and look them up per row, instead of rebuilding an EnumSet + stream + array for every row on the batch hot path. Behavior-preserving (JpaRpcDaoTest unchanged, 14/14).
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