Skip to main content

hydracache_client_protocol/
java_migration.rs

1//! Java/Spring migration contract for legacy Hazelcast consumers.
2//!
3//! The real Java artifacts live outside the Cargo workspace, but their public
4//! behavior is anchored here so the Rust protocol gate can enforce stable error
5//! mapping, safe codec registration, and fail-loud unsupported Hazelcast APIs.
6
7use std::collections::BTreeMap;
8
9use thiserror::Error;
10
11use crate::{ClientErrorCode, ClientErrorEnvelope};
12
13/// Current Java migration contract version.
14pub const JAVA_MIGRATION_CONTRACT_VERSION: u16 = 1;
15
16/// Checked-in manifest of Hazelcast APIs that HydraCache refuses to emulate.
17pub const UNSUPPORTED_HAZELCAST_APIS_MANIFEST: &str =
18    include_str!("../manifests/unsupported_hazelcast_apis.txt");
19
20/// Supported Spring Boot generations for the 0.49 Java migration contract.
21pub const SUPPORTED_SPRING_BOOT_GENERATIONS: &[u8] = &[2, 3, 4];
22
23/// Java exception kind documented for the Java client and Spring toolkit.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum JavaExceptionKind {
26    /// No common protocol or an unsupported protocol feature was used.
27    Protocol,
28    /// The request did not carry an accepted identity.
29    Authentication,
30    /// The identity is known but not allowed to perform the operation.
31    Authorization,
32    /// Tenant quota was exceeded.
33    QuotaExceeded,
34    /// The caller is over its rate or fairness budget.
35    RateLimited,
36    /// Data-residency governance refused the operation.
37    ResidencyDenied,
38    /// Request frame or value bytes exceeded a configured bound.
39    PayloadTooLarge,
40    /// Request deadline expired.
41    Timeout,
42    /// Conditional or optimistic operation conflicted.
43    Conflict,
44    /// The backend is temporarily unavailable.
45    BackendUnavailable,
46    /// The client sent a malformed binary frame.
47    MalformedFrame,
48}
49
50impl JavaExceptionKind {
51    /// Stable Java class name for this exception kind.
52    pub const fn class_name(self) -> &'static str {
53        match self {
54            Self::Protocol => "HydraCacheProtocolException",
55            Self::Authentication => "HydraCacheAuthenticationException",
56            Self::Authorization => "HydraCacheAuthorizationException",
57            Self::QuotaExceeded => "HydraCacheQuotaExceededException",
58            Self::RateLimited => "HydraCacheRateLimitedException",
59            Self::ResidencyDenied => "HydraCacheResidencyDeniedException",
60            Self::PayloadTooLarge => "HydraCachePayloadTooLargeException",
61            Self::Timeout => "HydraCacheTimeoutException",
62            Self::Conflict => "HydraCacheConflictException",
63            Self::BackendUnavailable => "HydraCacheBackendUnavailableException",
64            Self::MalformedFrame => "HydraCacheMalformedFrameException",
65        }
66    }
67}
68
69/// Java-facing mapping for one stable protocol error.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct JavaExceptionMapping {
72    /// Documented exception kind.
73    pub kind: JavaExceptionKind,
74    /// Java class name the SDK must expose.
75    pub class_name: &'static str,
76    /// Retryability copied from the protocol envelope.
77    pub retryable: bool,
78    /// Whether the Java exception must retain the request id.
79    pub preserves_request_id: bool,
80    /// Whether the Java exception must retain retry-after metadata when present.
81    pub preserves_retry_after: bool,
82}
83
84/// Return the Java exception mapping for a stable protocol error.
85pub fn java_exception_mapping(error: &ClientErrorEnvelope) -> JavaExceptionMapping {
86    let kind = match error.code {
87        ClientErrorCode::IncompatibleVersion => JavaExceptionKind::Protocol,
88        ClientErrorCode::Unauthenticated => JavaExceptionKind::Authentication,
89        ClientErrorCode::Unauthorized => JavaExceptionKind::Authorization,
90        ClientErrorCode::TenantQuota => JavaExceptionKind::QuotaExceeded,
91        ClientErrorCode::RateLimited => JavaExceptionKind::RateLimited,
92        ClientErrorCode::ResidencyDenied => JavaExceptionKind::ResidencyDenied,
93        ClientErrorCode::TooLarge => JavaExceptionKind::PayloadTooLarge,
94        ClientErrorCode::DeadlineExceeded => JavaExceptionKind::Timeout,
95        ClientErrorCode::Conflict => JavaExceptionKind::Conflict,
96        ClientErrorCode::BackendUnavailable => JavaExceptionKind::BackendUnavailable,
97        ClientErrorCode::MalformedFrame => JavaExceptionKind::MalformedFrame,
98    };
99
100    JavaExceptionMapping {
101        kind,
102        class_name: kind.class_name(),
103        retryable: error.retryable,
104        preserves_request_id: true,
105        preserves_retry_after: error.retry_after_ms.is_some(),
106    }
107}
108
109/// Java application topology mode for migration from Hazelcast.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum JavaClientTopology {
112    /// Application JVM connects as a client.
113    Client,
114    /// Application JVM tries to become a data-owning member.
115    Member,
116    /// Toolkit is disabled and does not create a client.
117    None,
118}
119
120/// Public identity source for Java clients.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum JavaClientIdentityMode {
123    /// Bearer/API token supplied by the application.
124    Token,
125    /// Client certificate identity over mTLS.
126    Mtls,
127}
128
129/// Retry/backoff defaults for Java clients.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct JavaRetryBackoff {
132    /// Maximum attempts including the original call.
133    pub max_attempts: u8,
134    /// Initial backoff in milliseconds.
135    pub initial_backoff_ms: u64,
136    /// Maximum backoff in milliseconds.
137    pub max_backoff_ms: u64,
138}
139
140impl Default for JavaRetryBackoff {
141    fn default() -> Self {
142        Self {
143            max_attempts: 3,
144            initial_backoff_ms: 25,
145            max_backoff_ms: 1_000,
146        }
147    }
148}
149
150/// Java client runtime settings shared by the Boot 2/3/4 starters.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct JavaClientRuntimeConfig {
153    /// Client endpoints.
154    pub endpoints: Vec<String>,
155    /// Tenant id carried to W4 isolation.
156    pub tenant: String,
157    /// Caller-visible client name.
158    pub client_name: String,
159    /// Whether the Java client may use server-provided routing hints.
160    pub smart_routing: bool,
161    /// Identity source.
162    pub identity: JavaClientIdentityMode,
163    /// Application topology mode.
164    pub topology: JavaClientTopology,
165    /// Retry/backoff policy.
166    pub retry: JavaRetryBackoff,
167    /// Default request deadline in milliseconds.
168    pub deadline_ms: u64,
169    /// Whether customizers are allowed to mutate the transport settings.
170    pub customizer_hooks_enabled: bool,
171}
172
173impl JavaClientRuntimeConfig {
174    /// Build client-first defaults.
175    pub fn client_first(
176        endpoints: impl IntoIterator<Item = impl Into<String>>,
177        tenant: impl Into<String>,
178        client_name: impl Into<String>,
179    ) -> Self {
180        Self {
181            endpoints: endpoints.into_iter().map(Into::into).collect(),
182            tenant: tenant.into(),
183            client_name: client_name.into(),
184            smart_routing: true,
185            identity: JavaClientIdentityMode::Token,
186            topology: JavaClientTopology::Client,
187            retry: JavaRetryBackoff::default(),
188            deadline_ms: 5_000,
189            customizer_hooks_enabled: true,
190        }
191    }
192
193    /// Validate release-0.49 Java client defaults.
194    pub fn validate(&self) -> Result<(), JavaMigrationContractError> {
195        if self.endpoints.is_empty() {
196            return Err(JavaMigrationContractError::InvalidField("endpoints"));
197        }
198        if self.tenant.trim().is_empty() {
199            return Err(JavaMigrationContractError::InvalidField("tenant"));
200        }
201        if self.client_name.trim().is_empty() {
202            return Err(JavaMigrationContractError::InvalidField("client_name"));
203        }
204        if self.deadline_ms == 0 {
205            return Err(JavaMigrationContractError::InvalidField("deadline_ms"));
206        }
207        if self.retry.max_attempts == 0 {
208            return Err(JavaMigrationContractError::InvalidField(
209                "retry.max_attempts",
210            ));
211        }
212        if self.topology == JavaClientTopology::Member {
213            return Err(JavaMigrationContractError::UnsupportedClientTopology(
214                "member",
215            ));
216        }
217        Ok(())
218    }
219}
220
221/// Spring Cache integration mode.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum SpringCacheMode {
224    /// Lazy map-backed cache names for legacy `CacheUtil` style code.
225    Native,
226    /// Bind to JCache when the optional JCache provider is present.
227    JCache,
228    /// Do not create a Spring `CacheManager`.
229    None,
230}
231
232impl SpringCacheMode {
233    /// Return whether this mode must lazily resolve dynamic cache names.
234    pub const fn lazy_dynamic_cache_names(self) -> bool {
235        matches!(self, Self::Native)
236    }
237
238    /// Return whether this mode requires a JCache provider.
239    pub const fn requires_jcache_provider(self) -> bool {
240        matches!(self, Self::JCache)
241    }
242}
243
244/// Java map facade operation exposed by the migration toolkit.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum JavaMapOperation {
247    /// `HydraCacheMap.get`.
248    Get,
249    /// `HydraCacheMap.put`.
250    Put,
251    /// `HydraCacheMap.putIfAbsent`.
252    PutIfAbsent,
253    /// `HydraCacheMap.remove`.
254    Remove,
255    /// `HydraCacheMap.containsKey`.
256    ContainsKey,
257    /// `HydraCacheMap.getAll`.
258    GetAll,
259    /// `HydraCacheMap.putAll`.
260    PutAll,
261    /// Key invalidation.
262    Invalidate,
263    /// Namespace clear for the map.
264    ClearNamespace,
265    /// Region/namespace eviction.
266    EvictRegion,
267}
268
269/// Protocol-level operation family for a Java map facade method.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum JavaMapProtocolFamily {
272    /// Maps to protocol-v1 get.
273    Get,
274    /// Maps to protocol-v1 put.
275    Put,
276    /// Maps to protocol-v1 conflict-aware conditional put-if-absent.
277    ConditionalPutIfAbsent,
278    /// Maps to protocol-v1 invalidation.
279    Invalidate,
280    /// Maps to protocol-v1 batch get.
281    BatchGet,
282    /// Maps to protocol-v1 batch put.
283    BatchPut,
284    /// Maps to protocol-v1 namespace/region eviction.
285    EvictRegion,
286}
287
288impl JavaMapOperation {
289    /// Return the protocol family that backs this facade operation.
290    pub const fn protocol_family(self) -> JavaMapProtocolFamily {
291        match self {
292            Self::Get | Self::ContainsKey => JavaMapProtocolFamily::Get,
293            Self::Put => JavaMapProtocolFamily::Put,
294            Self::PutIfAbsent => JavaMapProtocolFamily::ConditionalPutIfAbsent,
295            Self::Remove | Self::Invalidate => JavaMapProtocolFamily::Invalidate,
296            Self::GetAll => JavaMapProtocolFamily::BatchGet,
297            Self::PutAll => JavaMapProtocolFamily::BatchPut,
298            Self::ClearNamespace | Self::EvictRegion => JavaMapProtocolFamily::EvictRegion,
299        }
300    }
301}
302
303/// Serializer registration kind accepted or rejected by the Java toolkit.
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305pub enum JavaCodecKind {
306    /// Explicit codec instance or generated schema.
307    Explicit,
308    /// Package-scanned `@HydraCacheCodec`.
309    CodecAnnotation,
310    /// Package-scanned `@HydraCacheSchema`.
311    SchemaAnnotation,
312    /// Legacy serializer bridge enabled for migration only.
313    LegacySerializerBridge,
314    /// Reflective fallback serializer.
315    ReflectiveFallback,
316    /// Java native serialization.
317    JavaNativeSerialization,
318}
319
320impl JavaCodecKind {
321    /// Stable config label.
322    pub const fn as_str(self) -> &'static str {
323        match self {
324            Self::Explicit => "explicit",
325            Self::CodecAnnotation => "codec-annotation",
326            Self::SchemaAnnotation => "schema-annotation",
327            Self::LegacySerializerBridge => "legacy-serializer-bridge",
328            Self::ReflectiveFallback => "reflective-fallback",
329            Self::JavaNativeSerialization => "java-native-serialization",
330        }
331    }
332}
333
334/// Codec/schema descriptor registered by the Java migration toolkit.
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct JavaCodecDescriptor {
337    /// Stable codec id.
338    pub codec_id: String,
339    /// Fully qualified Java type name.
340    pub java_type: String,
341    /// Schema version for reviewable migrations.
342    pub schema_version: u32,
343    /// Registration kind.
344    pub kind: JavaCodecKind,
345}
346
347impl JavaCodecDescriptor {
348    /// Create a descriptor.
349    pub fn new(
350        codec_id: impl Into<String>,
351        java_type: impl Into<String>,
352        schema_version: u32,
353        kind: JavaCodecKind,
354    ) -> Result<Self, JavaMigrationContractError> {
355        let descriptor = Self {
356            codec_id: codec_id.into(),
357            java_type: java_type.into(),
358            schema_version,
359            kind,
360        };
361        descriptor.validate()?;
362        Ok(descriptor)
363    }
364
365    /// Create an explicit codec descriptor.
366    pub fn explicit(
367        codec_id: impl Into<String>,
368        java_type: impl Into<String>,
369        schema_version: u32,
370    ) -> Result<Self, JavaMigrationContractError> {
371        Self::new(codec_id, java_type, schema_version, JavaCodecKind::Explicit)
372    }
373
374    fn validate(&self) -> Result<(), JavaMigrationContractError> {
375        if self.codec_id.trim().is_empty() {
376            return Err(JavaMigrationContractError::InvalidField("codec_id"));
377        }
378        if self.java_type.trim().is_empty() {
379            return Err(JavaMigrationContractError::InvalidField("java_type"));
380        }
381        if self.schema_version == 0 {
382            return Err(JavaMigrationContractError::InvalidField("schema_version"));
383        }
384        Ok(())
385    }
386}
387
388/// Safe codec registry contract for Java clients.
389#[derive(Debug, Clone, Default, PartialEq, Eq)]
390pub struct JavaCodecRegistryContract {
391    codecs: BTreeMap<String, JavaCodecDescriptor>,
392    legacy_serializer_bridge_enabled: bool,
393}
394
395impl JavaCodecRegistryContract {
396    /// Create a registry with safe defaults.
397    pub fn new() -> Self {
398        Self::default()
399    }
400
401    /// Explicitly allow the migration-only legacy serializer bridge.
402    pub fn with_legacy_serializer_bridge_enabled(mut self) -> Self {
403        self.legacy_serializer_bridge_enabled = true;
404        self
405    }
406
407    /// Register a descriptor, failing loud on ambiguous or unsafe serializers.
408    pub fn register(
409        &mut self,
410        descriptor: JavaCodecDescriptor,
411    ) -> Result<(), JavaMigrationContractError> {
412        match descriptor.kind {
413            JavaCodecKind::ReflectiveFallback | JavaCodecKind::JavaNativeSerialization => {
414                return Err(JavaMigrationContractError::UnsupportedSerializer(
415                    descriptor.kind.as_str(),
416                ));
417            }
418            JavaCodecKind::LegacySerializerBridge if !self.legacy_serializer_bridge_enabled => {
419                return Err(JavaMigrationContractError::LegacySerializerBridgeDisabled(
420                    descriptor.codec_id,
421                ));
422            }
423            _ => {}
424        }
425
426        if self.codecs.contains_key(&descriptor.codec_id) {
427            return Err(JavaMigrationContractError::AmbiguousCodec {
428                codec_id: descriptor.codec_id,
429            });
430        }
431
432        self.codecs.insert(descriptor.codec_id.clone(), descriptor);
433        Ok(())
434    }
435
436    /// Return a descriptor by codec id.
437    pub fn get(&self, codec_id: &str) -> Option<&JavaCodecDescriptor> {
438        self.codecs.get(codec_id)
439    }
440
441    /// Number of registered descriptors.
442    pub fn len(&self) -> usize {
443        self.codecs.len()
444    }
445
446    /// Return whether the registry is empty.
447    pub fn is_empty(&self) -> bool {
448        self.codecs.is_empty()
449    }
450}
451
452/// One unsupported Hazelcast API and its migration hint.
453#[derive(Debug, Clone, PartialEq, Eq)]
454pub struct UnsupportedHazelcastApi {
455    /// Hazelcast API surface.
456    pub api: String,
457    /// Human migration hint.
458    pub migration_hint: String,
459}
460
461/// Versioned manifest of unsupported Hazelcast APIs.
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub struct UnsupportedHazelcastApiManifest {
464    /// Manifest version.
465    pub version: u16,
466    /// Unsupported APIs.
467    pub entries: Vec<UnsupportedHazelcastApi>,
468}
469
470impl UnsupportedHazelcastApiManifest {
471    /// Parse a manifest and reject unknown future versions.
472    pub fn parse(contents: &str) -> Result<Self, JavaMigrationContractError> {
473        let mut version = None;
474        let mut entries = Vec::new();
475
476        for raw in contents.lines() {
477            let line = raw.trim();
478            if line.is_empty() || line.starts_with('#') {
479                continue;
480            }
481
482            if let Some(value) = line.strip_prefix("version=") {
483                let parsed = value
484                    .parse()
485                    .map_err(|_| JavaMigrationContractError::InvalidManifest("version"))?;
486                version = Some(parsed);
487                continue;
488            }
489
490            let Some((api, migration_hint)) = line.split_once('|') else {
491                return Err(JavaMigrationContractError::InvalidManifest("entry"));
492            };
493            if api.trim().is_empty() || migration_hint.trim().is_empty() {
494                return Err(JavaMigrationContractError::InvalidManifest("entry"));
495            }
496            entries.push(UnsupportedHazelcastApi {
497                api: api.trim().to_owned(),
498                migration_hint: migration_hint.trim().to_owned(),
499            });
500        }
501
502        let version = version.ok_or(JavaMigrationContractError::InvalidManifest("version"))?;
503        if version != JAVA_MIGRATION_CONTRACT_VERSION {
504            return Err(JavaMigrationContractError::UnsupportedManifestVersion {
505                actual: version,
506                supported: JAVA_MIGRATION_CONTRACT_VERSION,
507            });
508        }
509        if entries.is_empty() {
510            return Err(JavaMigrationContractError::InvalidManifest("entries"));
511        }
512
513        Ok(Self { version, entries })
514    }
515
516    /// Parse the checked-in manifest.
517    pub fn checked_in() -> Result<Self, JavaMigrationContractError> {
518        Self::parse(UNSUPPORTED_HAZELCAST_APIS_MANIFEST)
519    }
520
521    /// Find a manifest entry by API name.
522    pub fn find(&self, api: &str) -> Option<&UnsupportedHazelcastApi> {
523        self.entries.iter().find(|entry| entry.api == api)
524    }
525}
526
527/// Java migration contract errors.
528#[derive(Debug, Clone, PartialEq, Eq, Error)]
529pub enum JavaMigrationContractError {
530    /// A field is missing or invalid.
531    #[error("invalid java migration contract field: {0}")]
532    InvalidField(&'static str),
533    /// Application JVM member mode is unsupported in release 0.49.
534    #[error("unsupported java client topology for release 0.49: {0}")]
535    UnsupportedClientTopology(&'static str),
536    /// Codec id is ambiguous.
537    #[error("ambiguous java codec id: {codec_id}")]
538    AmbiguousCodec {
539        /// Duplicate codec id.
540        codec_id: String,
541    },
542    /// Serializer kind is unsupported.
543    #[error("unsupported java serializer kind: {0}")]
544    UnsupportedSerializer(&'static str),
545    /// Legacy serializer bridge is disabled by default.
546    #[error("legacy serializer bridge is disabled for codec id: {0}")]
547    LegacySerializerBridgeDisabled(String),
548    /// Manifest is malformed.
549    #[error("invalid unsupported Hazelcast API manifest: {0}")]
550    InvalidManifest(&'static str),
551    /// Manifest version is newer than this library supports.
552    #[error(
553        "unsupported unsupported-Hazelcast-API manifest version {actual}; supported {supported}"
554    )]
555    UnsupportedManifestVersion {
556        /// Actual manifest version.
557        actual: u16,
558        /// Supported manifest version.
559        supported: u16,
560    },
561}