Skip to content

Seed session-recovery reconnect baseline from FEATUREEXTACK - #230

Open
gargsaumya wants to merge 1 commit into
dev/tkotian/ReconnectInvalidatesPrepHandlesfrom
dev/saumya/session-recovery-baseline
Open

Seed session-recovery reconnect baseline from FEATUREEXTACK#230
gargsaumya wants to merge 1 commit into
dev/tkotian/ReconnectInvalidatesPrepHandlesfrom
dev/saumya/session-recovery-baseline

Conversation

@gargsaumya

@gargsaumya gargsaumya commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

This pull request implements critical fixes and enhancements to the SQL Server session recovery feature in the TDS client, ensuring that session state is correctly seeded and replayed during reconnects. The main changes include capturing and forwarding the session state baseline provided by the server at login, updating the session recovery context and table to handle this state, and extending the connection and client initialization code to manage session state tokens throughout the connection lifecycle. This allows transparent reconnects to succeed where they previously failed due to missing or incomplete session state.

Session Recovery State Handling:

  • Added SessionStateToken support throughout the connection stack, ensuring that the baseline session state (including database, language, collation, and server-provided tokens) is captured at login and replayed during reconnects. This includes passing session state tokens from the connection provider to the TdsClient and into the session recovery context. [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] [14] [15] [16] [17]

  • Enhanced RecoveryContext::initialize to take and store all relevant session state information, and to seed the session state table with the baseline provided at login. [1] [2]

Session State Table Improvements:

  • Added SessionStateTable::seed_initial_state and seed_initial_state_from_feature_ack to correctly parse and store the session state baseline from server-provided FEATUREEXTACK payloads, ensuring reconnect attempts include all required state and are accepted by the server.

Testing and Validation:

  • Added comprehensive tests for session state seeding and parsing, covering normal, extended, and truncated FEATUREEXTACK payloads, and verifying that session recovery remains robust and non-panicking in edge cases.

Test Enablement:

  • Re-enabled the session recovery end-to-end test, which was previously skipped due to reconnect failures, as the reconnect logic now correctly replays the required session state.

These changes collectively ensure that session recovery is robust and compliant with SQL Server requirements, enabling transparent reconnects to succeed in scenarios where they previously failed.

Related Issues

Checklist

  • cargo bfmt passes
  • cargo bclippy passes
  • cargo btest passes
  • New/changed functionality has tests
  • Public API changes are documented

@gargsaumya
gargsaumya requested a review from a team as a code owner August 12, 2026 09:02
The server delivers the recoverable session-state baseline in the
SESSIONRECOVERY FEATUREEXTACK at login, but the driver captured that payload
and dropped it instead of parsing it. The reconnect LOGIN7 therefore omitted
those state entries and the server rejected it (error 17897, state 81:
"session recovery feature data ... structurally or semantically invalid"), so
transparent reconnect never succeeded.

Parse the FEATUREEXTACK baseline (StateId, Length, Value entries) into
initial_state so it is echoed on reconnect, alongside the initial
database/language/collation captured at login.

Also fix transparent_reconnect_after_kill: it asserted the SPID changes after
recovery, but SQL Server recycles SPID numbers, so a recovered session often
reuses the same one; rely on connection_recovery_count instead. Re-enable the
previously skipped C++ e2e session recovery test.
@gargsaumya
gargsaumya force-pushed the dev/saumya/session-recovery-baseline branch from e3b45ce to dd774e5 Compare August 12, 2026 09:13
@gargsaumya

Copy link
Copy Markdown
Contributor Author

Depends on: #148
This is a stacked PR - please merge/review that one first. GitHub will auto-retarget this to main once it lands.

@Theekshna

Copy link
Copy Markdown
Contributor

@David-Engel David Engel (David-Engel) 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.

Summary

Seeds the reconnect baseline from the SESSIONRECOVERY FEATUREEXTACK and wires session_recovery_negotiated from the ack, which is what finally makes idle connection resiliency function — it was requested on the wire and acked by the server, but the client-side gate was never set, so check_and_reconnect short-circuited on every path. The parse/serialize format matches JDBC's parseInitialSessionStateData exactly (StateId | 1-byte len or 0xFF + DWORD | Value), and the initial-block field order (database, collation, language) matches both msodbcsql SerializeRecoveryData and JDBC.

Verified locally: cargo fmt --all --check, cargo clippy -p mssql-tds --all-targets, and cargo test -p mssql-tds --lib session_recovery (79 passed) are all clean.

One blocking item below, plus suggestions on robustness and coverage inline.

Blocking — second reconnect replays a stale baseline

mssql-tds/src/connection/tds_client.rs:375 (unchanged line, so commenting here rather than inline). After a successful reconnect, self.recovery_context.session_state_table.reset() clears delta while initial_state keeps the login-time baseline. That was harmless before this PR because initial_state was always empty; now it means:

connect → SET options accumulate in delta → reconnect #1 sends initial+delta ✅ → delta cleared → reconnect #2 sends the bare login baseline — every numbered session state acquired before the first reconnect is silently lost.

Neither reference driver does this. JDBC only allocates a table when null == sessionRecovery.getSessionStateTable(), so the delta survives reconnects; SessionStateTable.reset() is reserved for sp_reset_connection (ENVCHANGE 18). msodbcsql keeps m_InitialSessionStateTable as the permanent login baseline and only calls ResetCurrentState() from the SQL_RESET_CONNECTION path in sqlcmisc.cpp. Smallest fix is to drop the reset() call at line 375; alternatively fold delta into initial_state before clearing.

CI

The mssql-rs Pull request validation check is currently NEUTRAL (not completed) — worth a green run before merge, particularly the Windows enableOdbcE2E leg, since that is the only place the re-enabled session_recovery_test.cpp actually executes.

Comment on lines +352 to +378
fn seed_initial_state_from_feature_ack(&mut self, data: &[u8]) {
let mut i = 0;
while i < data.len() {
let state_id = data[i] as usize;
i += 1;
let Some(&len_byte) = data.get(i) else { break };
i += 1;
let len = if len_byte == 0xFF {
let Some(bytes) = data.get(i..i + 4) else {
break;
};
i += 4;
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize
} else {
len_byte as usize
};
let Some(value) = data.get(i..i + len) else {
break;
};
i += len;
self.initial_state[state_id] = Some(SessionStateRecord {
recoverable: true,
sequence: 0,
data: value.to_vec(),
});
}
}

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.

Suggestion. Every break here drops the remaining entries silently. The consequence is exactly the failure this PR is fixing: an incomplete baseline goes out in the reconnect LOGIN7 and the server rejects it with 17897/81, with nothing driver-side to explain why. JDBC calls throwInvalidTDS() on the equivalent condition.

At minimum a warn! with the byte offset; better would be self.master_recovery_disabled = true so recovery fails fast with a driver error rather than an opaque server rejection. Note that seed_from_feature_ack_truncated_is_safe currently locks in the silent behavior, so it would need updating too.

Minor, same block: data.get(i..i + len) at line 368 can have len up to u32::MAX. Safe on 64-bit and there are no 32-bit targets in .pipeline, but i.checked_add(len) is free.

Comment on lines +328 to +344
for entry in &token.states {
if !entry.recoverable {
self.unrecoverable_state_count += 1;
}
self.initial_state[entry.state_id as usize] = Some(SessionStateRecord {
recoverable: entry.recoverable,
sequence: token.sequence_number,
data: entry.data.clone(),
});
}
}

// The server delivers the recoverable baseline as a packed FEATUREEXTACK
// payload, not SESSIONSTATE tokens. Each entry must be echoed in the
// reconnect LOGIN7 or the server rejects it as semantically invalid
// (error 17897, state 81).
if let Some(data) = feature_ack_initial_state {

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.

Suggestion. Two bookkeeping inconsistencies between the token pass and the ack pass:

  1. The ack pass overwrites initial_state[id] with recoverable: true but never decrements unrecoverable_state_count, so a state the token pass counted stays counted while its record is now recoverable — the session is permanently un-recoverable with nothing in the table to show for it.
  2. reset() zeroes unrecoverable_state_count wholesale, which wipes the initial-state contribution while the records themselves survive (they are deliberately not cleared).

Tracking initial-state recoverability separately from the delta count would resolve both. Low practical impact since login-time SESSIONSTATE tokens are rare, but the counter is the sole gate in is_session_recoverable().

/// needed for reconnection validation and orchestration.
/// needed for reconnection validation and orchestration, and to seed the
/// baseline session state a reconnect's LOGIN7 must replay.
#[allow(clippy::too_many_arguments)]

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.

Suggestion. Five of the six new parameters come straight off NegotiatedSettings, which this module already imports for validate_reconnection. Taking &NegotiatedSettings instead would collapse them and remove the need for #[allow(clippy::too_many_arguments)].

}

#[test]
fn seed_initial_state_forwards_feature_ack() {

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.

Suggestion. The tokens branch of seed_initial_state is untested — all four new tests pass &[]. Worth covering the sequence_number == u32::MAXmaster_recovery_disabled path and the !entry.recoverable increment, since those are the paths that can permanently disable recovery.

A round-trip test would also be valuable here: seed from an ack blob → snapshot()SessionRecoveryFeature::serialize() → assert the emitted state entries byte-match the original blob. That pins reader and writer against each other in one assertion.

Comment on lines +197 to +202
negotiated_settings.is_session_recovery_acknowledged(),
negotiated_settings.database.clone(),
negotiated_settings.language.clone(),
negotiated_settings.database_collation,
&login_session_state_tokens,
negotiated_settings.session_recovery_initial_state(),

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.

Suggestion. is_session_recovery_acknowledged() on line 197 is the single highest-impact line in the diff — on the base branch session_recovery_negotiated was only ever assigned inside #[cfg(test)] code, so check_and_reconnect returned Ok(Duration::ZERO) on all seven call sites and reconnect never ran despite the feature being requested and acked. Two asks:

  • Please name this in the description. It reads as being only about FEATUREEXTACK seeding, but this line is what takes idle connection resiliency from inert to functional, and that belongs in the release notes.
  • Nothing asserts this wiring. A unit test building NegotiatedSettings with an acked SessionRecoveryFeature carrying ack bytes, passing it through TdsClient::new, and checking both recovery_context.session_recovery_negotiated and session_state_table.initial_state[..] would be cheap via create_test_negotiated_settings_internal() and is the strongest regression guard available. Worth noting the base branch already asserted is_session_recovery_enabled() in connectivity.rs:428 and would have caught this — but every stage in .pipeline/templates/validation-stages.yml passes -E 'not (test(connectivity))', so a dead feature gate sat undetected.

new_settings,
new_exec_ctx,
info_messages,
_session_state_tokens,

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.

Suggestion. Worth a comment explaining why the reconnect's session state is discarded, because msodbcsql deliberately does not discard it. In sqlctokn.cpp the FEACK_SESSIONRECOVERY handler branches on FIsRecoveryInProgress(): first login goes to ReadDefaultSessionState() → initial table, recovery login goes to ReadUpdatedSessionState(recoverable=true, seq=0, ...)current table. The initial table is written exactly once and stays the original login baseline.

Combined with the reset() issue in the top-level comment, mssql-rs currently does the inverse on both halves. If dropping these is intentional, a one-line note would save the next reader the trip through the ODBC source.

Comment on lines +347 to +350
let language = change_props
.language
.clone()
.unwrap_or_else(|| self.factory.context.language.clone());

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.

Nit. Falling back to context.language means that when the server sends no language ENVCHANGE we seed the baseline with the requested language rather than the server's actual one, and replay that in the reconnect initial block. Low risk in practice since SQL Server sends ENVCHANGE 2 at login, but "" may be the safer fallback.

Separately, this changes public TdsClient::language() from "" to the negotiated value at login. That makes it match its own doc comment, so it is a fix rather than a break — but it is an observable behavior change and deserves a CHANGELOG line.

// numbers, so a recovered session frequently reuses the same one.
// connection_recovery_count is the reliable signal that a reconnect
// actually occurred.
get_spid(&mut client).await?;

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.

Nit. The reasoning for dropping the SPID assertion is sound (SQL Server recycles SPID numbers, and connection_recovery_count() is the reliable signal). let _ = get_spid(&mut client).await?; would read more clearly as "called for its side effect."

@saurabh500

Copy link
Copy Markdown
Contributor

What is the dynamics of this PR going to be ? :)

This is sent to another dev branch.

@Theekshna

Copy link
Copy Markdown
Contributor

What is the dynamics of this PR going to be ? :)

This is sent to another dev branch.

Yeah it was stacked on my PR. gargsaumya, my PR is merged now. Pls change the target branch of this PR to main

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.

4 participants