From c0809f1579ac65d98228973e67ab2c4cb50501c9 Mon Sep 17 00:00:00 2001 From: Yuriy Bezsonov Date: Sat, 13 Dec 2025 12:45:35 +0100 Subject: [PATCH] feat: Add S3 chat memory repository implementation - Add S3ChatMemoryRepository with CRUD operations for chat history - Implement S3ChatMemoryConfig with builder pattern and validation - Add Spring Boot auto-configuration for integration - Include test suite (22 tests total): - Property-based tests with jqwik for edge cases - Integration tests with Testcontainers and LocalStack - AutoConfiguration tests for Spring Boot integration - Add documentation Resolves: #5088 Signed-off-by: Yuriy Bezsonov --- .../pom.xml | 77 +++ .../s3/S3ChatMemoryAutoConfiguration.java | 95 ++++ .../repository/s3/S3ChatMemoryProperties.java | 137 +++++ .../memory/repository/s3/package-info.java | 28 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../s3/S3ChatMemoryAutoConfigurationIT.java | 157 ++++++ .../.jqwik-database | Bin 0 -> 4 bytes .../README.md | 413 +++++++++++++++ .../pom.xml | 124 +++++ .../repository/s3/S3ChatMemoryConfig.java | 199 +++++++ .../repository/s3/S3ChatMemoryRepository.java | 501 ++++++++++++++++++ .../memory/repository/s3/package-info.java | 27 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../s3/S3ChatMemoryConfigPropertyTest.java | 98 ++++ .../s3/S3ChatMemoryRepositoryIT.java | 297 +++++++++++ .../S3ChatMemoryRepositoryPropertyTest.java | 310 +++++++++++ pom.xml | 5 +- spring-ai-bom/pom.xml | 36 ++ .../modules/ROOT/pages/api/chat-memory.adoc | 109 ++++ .../pom.xml | 64 +++ 20 files changed, 2678 insertions(+), 1 deletion(-) create mode 100644 auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/pom.xml create mode 100644 auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfiguration.java create mode 100644 auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryProperties.java create mode 100644 auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/package-info.java create mode 100644 auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfigurationIT.java create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/.jqwik-database create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/README.md create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/pom.xml create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfig.java create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepository.java create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/package-info.java create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfigPropertyTest.java create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryIT.java create mode 100644 memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryPropertyTest.java create mode 100644 spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-s3/pom.xml diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/pom.xml b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/pom.xml new file mode 100644 index 00000000000..4c847b4b8a5 --- /dev/null +++ b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../../../../../pom.xml + + spring-ai-autoconfigure-model-chat-memory-repository-s3 + jar + Spring AI S3 Chat Memory Repository Auto Configuration + Spring S3 AI Chat Memory Repository Auto Configuration + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.ai + spring-ai-model-chat-memory-repository-s3 + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory + ${project.parent.version} + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + org.springframework.boot + spring-boot-autoconfigure-processor + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + org.testcontainers + testcontainers-localstack + test + + + + + \ No newline at end of file diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfiguration.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfiguration.java new file mode 100644 index 00000000000..cb8c48e17a5 --- /dev/null +++ b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfiguration.java @@ -0,0 +1,95 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.memory.repository.s3; + +import java.net.URI; + +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.model.StorageClass; + +import org.springframework.ai.chat.memory.ChatMemory; +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.repository.s3.S3ChatMemoryRepository; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.util.StringUtils; + +/** + * Auto-configuration for S3 chat memory repository. + * + * @author Yuriy Bezsonov + * @since 2.0.0 + */ +@AutoConfiguration +@ConditionalOnClass({ S3Client.class, ChatMemoryRepository.class }) +@EnableConfigurationProperties(S3ChatMemoryProperties.class) +@ConditionalOnProperty(prefix = "spring.ai.chat.memory.repository.s3", name = "bucket-name") +public class S3ChatMemoryAutoConfiguration { + + /** + * Creates an S3Client bean if one is not already present. + * @param properties the S3 chat memory properties + * @return configured S3Client + */ + @Bean + @ConditionalOnMissingBean + public S3Client s3Client(final S3ChatMemoryProperties properties) { + S3ClientBuilder builder = S3Client.builder(); + + // Set region + if (StringUtils.hasText(properties.getRegion())) { + builder.region(Region.of(properties.getRegion())); + } + + // Support for custom endpoint (useful for S3-compatible services + // like MinIO) + String endpoint = System.getProperty("spring.ai.chat.memory.repository.s3.endpoint"); + if (StringUtils.hasText(endpoint)) { + builder.endpointOverride(URI.create(endpoint)); + } + + return builder.build(); + } + + /** + * Creates an S3ChatMemoryRepository bean if one is not already present. + * @param s3Client the S3 client + * @param properties the S3 chat memory properties + * @return configured S3ChatMemoryRepository + */ + @Bean + @ConditionalOnMissingBean({ S3ChatMemoryRepository.class, ChatMemory.class, ChatMemoryRepository.class }) + public S3ChatMemoryRepository s3ChatMemoryRepository(final S3Client s3Client, + final S3ChatMemoryProperties properties) { + StorageClass storageClass = StorageClass.fromValue(properties.getStorageClass()); + + return S3ChatMemoryRepository.builder() + .s3Client(s3Client) + .bucketName(properties.getBucketName()) + .keyPrefix(properties.getKeyPrefix()) + .initializeBucket(properties.isInitializeBucket()) + .storageClass(storageClass) + .build(); + } + +} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryProperties.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryProperties.java new file mode 100644 index 00000000000..0c6f7a555e9 --- /dev/null +++ b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryProperties.java @@ -0,0 +1,137 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.memory.repository.s3; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for S3 chat memory repository. + * + * @author Yuriy Bezsonov + * @since 2.0.0 + */ +@ConfigurationProperties(prefix = "spring.ai.chat.memory.repository.s3") +public class S3ChatMemoryProperties { + + /** + * The name of the S3 bucket where conversation data will be stored. + */ + private String bucketName; + + /** + * The prefix to use for S3 object keys. Defaults to "chat-memory". + */ + private String keyPrefix = "chat-memory"; + + /** + * The AWS region to use for S3 operations. Defaults to "us-east-1". + */ + private String region = "us-east-1"; + + /** + * Whether to automatically create the S3 bucket if it doesn't exist. Defaults to + * false. + */ + private boolean initializeBucket = false; + + /** + * S3 storage class for conversation objects. Defaults to "STANDARD". Supported + * values: STANDARD, STANDARD_IA, ONEZONE_IA, REDUCED_REDUNDANCY. + */ + private String storageClass = "STANDARD"; + + /** + * Gets the S3 bucket name. + * @return the bucket name + */ + public String getBucketName() { + return this.bucketName; + } + + /** + * Sets the S3 bucket name. + * @param name the bucket name to set + */ + public void setBucketName(final String name) { + this.bucketName = name; + } + + /** + * Gets the S3 key prefix. + * @return the key prefix + */ + public String getKeyPrefix() { + return this.keyPrefix; + } + + /** + * Sets the S3 key prefix. + * @param prefix the key prefix to set + */ + public void setKeyPrefix(final String prefix) { + this.keyPrefix = prefix; + } + + /** + * Gets the AWS region. + * @return the region + */ + public String getRegion() { + return this.region; + } + + /** + * Sets the AWS region. + * @param awsRegion the region to set + */ + public void setRegion(final String awsRegion) { + this.region = awsRegion; + } + + /** + * Gets whether to initialize bucket. + * @return true if bucket should be initialized + */ + public boolean isInitializeBucket() { + return this.initializeBucket; + } + + /** + * Sets whether to initialize bucket. + * @param initialize true to initialize bucket + */ + public void setInitializeBucket(final boolean initialize) { + this.initializeBucket = initialize; + } + + /** + * Gets the storage class. + * @return the storage class + */ + public String getStorageClass() { + return this.storageClass; + } + + /** + * Sets the storage class. + * @param storage the storage class to set + */ + public void setStorageClass(final String storage) { + this.storageClass = storage; + } + +} diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/package-info.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/package-info.java new file mode 100644 index 00000000000..17cd8acb116 --- /dev/null +++ b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/package-info.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for S3-based Spring AI chat memory repository. + * + *

+ * This package provides Spring Boot auto-configuration classes for automatically + * configuring S3 chat memory repository when the appropriate dependencies are present on + * the classpath. + * + * @author Yuriy Bezsonov + * @since 2.0.0 + */ +package org.springframework.ai.autoconfigure.chat.memory.repository.s3; diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..7216b324fa2 --- /dev/null +++ b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.ai.autoconfigure.chat.memory.repository.s3.S3ChatMemoryAutoConfiguration \ No newline at end of file diff --git a/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfigurationIT.java b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfigurationIT.java new file mode 100644 index 00000000000..2b60123ff85 --- /dev/null +++ b/auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/autoconfigure/chat/memory/repository/s3/S3ChatMemoryAutoConfigurationIT.java @@ -0,0 +1,157 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.autoconfigure.chat.memory.repository.s3; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.memory.repository.s3.S3ChatMemoryRepository; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for S3ChatMemoryAutoConfiguration. + * + * @author Yuriy Bezsonov + */ +@Testcontainers +class S3ChatMemoryAutoConfigurationIT { + + @Container + static final LocalStackContainer localstack = initializeLocalStack(); + + private static LocalStackContainer initializeLocalStack() { + LocalStackContainer container = new LocalStackContainer(DockerImageName.parse("localstack/localstack:latest")); + container.withServices("s3"); + return container; + } + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(S3ChatMemoryAutoConfiguration.class)); + + @Test + void autoConfigurationCreatesS3ChatMemoryRepository() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.ai.chat.memory.repository.s3.bucket-name=test-bucket") + .run(context -> { + assertThat(context).hasSingleBean(ChatMemoryRepository.class); + assertThat(context).hasSingleBean(S3ChatMemoryRepository.class); + assertThat(context.getBean(ChatMemoryRepository.class)).isInstanceOf(S3ChatMemoryRepository.class); + }); + } + + @Test + void autoConfigurationBindsProperties() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.ai.chat.memory.repository.s3.bucket-name=my-bucket", + "spring.ai.chat.memory.repository.s3.key-prefix=my-prefix", + "spring.ai.chat.memory.repository.s3.region=us-west-2") + .run(context -> { + S3ChatMemoryProperties properties = context.getBean(S3ChatMemoryProperties.class); + assertThat(properties.getBucketName()).isEqualTo("my-bucket"); + assertThat(properties.getKeyPrefix()).isEqualTo("my-prefix"); + assertThat(properties.getRegion()).isEqualTo("us-west-2"); + }); + } + + @Test + void autoConfigurationUsesCustomS3Client() { + this.contextRunner.withUserConfiguration(CustomS3ClientConfiguration.class) + .withPropertyValues("spring.ai.chat.memory.repository.s3.bucket-name=test-bucket") + .run(context -> { + assertThat(context).hasSingleBean(S3Client.class); + assertThat(context).hasSingleBean(S3ChatMemoryRepository.class); + + // Verify the repository works with custom S3Client + S3ChatMemoryRepository repository = context.getBean(S3ChatMemoryRepository.class); + List messages = List.of(UserMessage.builder().text("test").build()); + + // This should not throw an exception (though it may fail due to + // LocalStack setup) + assertThat(repository).isNotNull(); + }); + } + + @Test + void autoConfigurationDoesNotCreateBeanWhenBucketNameMissing() { + this.contextRunner.withUserConfiguration(TestConfiguration.class).run(context -> { + assertThat(context).doesNotHaveBean(ChatMemoryRepository.class); + assertThat(context).doesNotHaveBean(S3ChatMemoryRepository.class); + }); + } + + @Test + void autoConfigurationBindsStorageClassProperty() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.ai.chat.memory.repository.s3.bucket-name=test-bucket", + "spring.ai.chat.memory.repository.s3.storage-class=STANDARD_IA") + .run(context -> { + S3ChatMemoryProperties properties = context.getBean(S3ChatMemoryProperties.class); + assertThat(properties.getStorageClass()).isEqualTo("STANDARD_IA"); + }); + } + + @Test + void autoConfigurationUsesDefaultStorageClass() { + this.contextRunner.withUserConfiguration(TestConfiguration.class) + .withPropertyValues("spring.ai.chat.memory.repository.s3.bucket-name=test-bucket") + .run(context -> { + S3ChatMemoryProperties properties = context.getBean(S3ChatMemoryProperties.class); + assertThat(properties.getStorageClass()).isEqualTo("STANDARD"); // Default + // value + }); + } + + @Configuration + static class TestConfiguration { + + // Empty configuration for basic tests + + } + + @Configuration + static class CustomS3ClientConfiguration { + + @Bean + S3Client customS3Client() { + return S3Client.builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider(StaticCredentialsProvider + .create(AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .region(Region.of(localstack.getRegion())) + .build(); + } + + } + +} diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/.jqwik-database b/memory/repository/spring-ai-model-chat-memory-repository-s3/.jqwik-database new file mode 100644 index 0000000000000000000000000000000000000000..711006c3d3b5c6d50049e3f48311f3dbe372803d GIT binary patch literal 4 LcmZ4UmVp%j1%Lsc literal 0 HcmV?d00001 diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/README.md b/memory/repository/spring-ai-model-chat-memory-repository-s3/README.md new file mode 100644 index 00000000000..24985cd2638 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/README.md @@ -0,0 +1,413 @@ +# Spring AI S3 Chat Memory Repository + +A Spring AI implementation of `ChatMemoryRepository` that stores conversation history in Amazon S3, providing scalable and cost-effective chat memory persistence for AI applications. + +## Features + +- **Scalable Storage**: Leverages Amazon S3 for virtually unlimited conversation storage +- **Cost-Effective**: Multiple S3 storage classes for cost optimization +- **JSON Serialization**: Rich metadata preservation with Jackson JSON serialization +- **Automatic Bucket Management**: Optional bucket creation and initialization +- **Pagination Support**: Efficient handling of large conversation datasets +- **Spring Boot Integration**: Full auto-configuration support +- **S3-Compatible Services**: Works with MinIO and other S3-compatible storage + +## Quick Start + +### Dependencies + +Add the S3 Chat Memory Repository dependency to your project: + +```xml + + + org.springframework.ai + spring-ai-starter-model-chat-memory-repository-s3 + +``` + +### Basic Configuration + +Configure your S3 chat memory repository in `application.properties`: + +```properties +# Required: S3 bucket name +spring.ai.chat.memory.repository.s3.bucket-name=my-chat-memory-bucket + +# Optional: AWS region (defaults to us-east-1) +spring.ai.chat.memory.repository.s3.region=us-west-2 + +# Optional: S3 key prefix (defaults to "chat-memory") +spring.ai.chat.memory.repository.s3.key-prefix=conversations + +# Optional: Auto-create bucket if it doesn't exist (defaults to false) +spring.ai.chat.memory.repository.s3.initialize-bucket=true + +# Optional: S3 storage class (defaults to STANDARD) +spring.ai.chat.memory.repository.s3.storage-class=STANDARD_IA +``` + +### AWS Credentials + +Configure AWS credentials using one of the standard methods: + +1. **Environment Variables**: + ```bash + export AWS_ACCESS_KEY_ID=your-access-key + export AWS_SECRET_ACCESS_KEY=your-secret-key + ``` + +2. **AWS Credentials File** (`~/.aws/credentials`): + ```ini + [default] + aws_access_key_id = your-access-key + aws_secret_access_key = your-secret-key + ``` + +3. **IAM Roles** (recommended for EC2/ECS/Lambda deployments) + +## Usage Examples + +### Basic Usage with Auto-Configuration + +```java +@RestController +public class ChatController { + + private final ChatClient chatClient; + + public ChatController(ChatClient.Builder chatClientBuilder, + ChatMemoryRepository chatMemoryRepository) { + this.chatClient = chatClientBuilder + .defaultAdvisors(new MessageChatMemoryAdvisor( + new MessageWindowChatMemory(chatMemoryRepository, 10))) + .build(); + } + + @PostMapping("/chat") + public String chat(@RequestParam String message, + @RequestParam String conversationId) { + return chatClient.prompt() + .user(message) + .advisors(advisorSpec -> advisorSpec + .param(CHAT_MEMORY_CONVERSATION_ID_KEY, conversationId)) + .call() + .content(); + } +} +``` + +### Manual Configuration + +```java +@Configuration +public class ChatMemoryConfig { + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.US_WEST_2) + .build(); + } + + @Bean + public S3ChatMemoryRepository chatMemoryRepository(S3Client s3Client) { + return S3ChatMemoryRepository.builder() + .s3Client(s3Client) + .bucketName("my-chat-conversations") + .keyPrefix("chat-memory") + .initializeBucket(true) + .storageClass(StorageClass.STANDARD_IA) + .build(); + } + + @Bean + public ChatMemory chatMemory(S3ChatMemoryRepository repository) { + return new MessageWindowChatMemory(repository, 20); + } +} +``` + +### Custom S3 Endpoint (MinIO/LocalStack) + +```java +@Bean +public S3Client s3Client() { + return S3Client.builder() + .endpointOverride(URI.create("http://localhost:9000")) // MinIO endpoint + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("minioadmin", "minioadmin"))) + .forcePathStyle(true) // Required for MinIO + .build(); +} +``` + +### Advanced Usage with Custom Configuration + +```java +@Service +public class ConversationService { + + private final S3ChatMemoryRepository repository; + + public ConversationService(S3ChatMemoryRepository repository) { + this.repository = repository; + } + + public List getAllConversations() { + return repository.findConversationIds(); + } + + public List getConversationHistory(String conversationId) { + return repository.findByConversationId(conversationId); + } + + public void archiveConversation(String conversationId) { + // Get messages and save to archive location + List messages = repository.findByConversationId(conversationId); + // ... archive logic ... + + // Delete from active storage + repository.deleteByConversationId(conversationId); + } +} +``` + +## Configuration Properties + +| Property | Description | Default | Required | +|----------|-------------|---------|----------| +| `spring.ai.chat.memory.repository.s3.bucket-name` | S3 bucket name for storing conversations | - | ✅ | +| `spring.ai.chat.memory.repository.s3.region` | AWS region | `us-east-1` | ❌ | +| `spring.ai.chat.memory.repository.s3.key-prefix` | S3 key prefix for conversation objects | `chat-memory` | ❌ | +| `spring.ai.chat.memory.repository.s3.initialize-bucket` | Auto-create bucket if it doesn't exist | `false` | ❌ | +| `spring.ai.chat.memory.repository.s3.storage-class` | S3 storage class | `STANDARD` | ❌ | + +**Note**: Message windowing (limiting the number of messages per conversation) is handled by `MessageWindowChatMemory`, not by the repository itself. This follows the standard Spring AI pattern where repositories handle storage and ChatMemory implementations handle business logic like windowing. + +### Supported Storage Classes + +- `STANDARD` - General purpose storage (default) +- `STANDARD_IA` - Infrequent access storage (lower cost) +- `ONEZONE_IA` - Single AZ infrequent access +- `REDUCED_REDUNDANCY` - Reduced redundancy storage + +## S3-Specific Considerations + +### Eventual Consistency + +Amazon S3 provides **strong read-after-write consistency** for new objects and **strong consistency** for overwrite PUTS and DELETES. However, be aware of these characteristics: + +- **New conversations**: Immediately readable after creation +- **Updated conversations**: Immediately readable after update +- **Deleted conversations**: Immediately consistent after deletion +- **Conversation listing**: May have slight delays in very high-throughput scenarios + +### Performance Optimization + +1. **Key Design**: The repository uses a flat key structure (`{prefix}/{conversationId}.json`) for optimal performance +2. **Batch Operations**: Each conversation is stored as a single JSON document for atomic updates +3. **Pagination**: Large conversation lists are automatically paginated using S3's native pagination + +### Cost Optimization + +```properties +# Use Standard-IA for conversations older than 30 days +spring.ai.chat.memory.repository.s3.storage-class=STANDARD_IA + +# Consider lifecycle policies for long-term archival +``` + +Example S3 Lifecycle Policy: +```json +{ + "Rules": [ + { + "ID": "ChatMemoryLifecycle", + "Status": "Enabled", + "Filter": { + "Prefix": "chat-memory/" + }, + "Transitions": [ + { + "Days": 30, + "StorageClass": "STANDARD_IA" + }, + { + "Days": 90, + "StorageClass": "GLACIER" + } + ] + } + ] +} +``` + +### Security Best Practices + +1. **IAM Permissions**: Use minimal required permissions + ```json + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::your-chat-bucket", + "arn:aws:s3:::your-chat-bucket/*" + ] + } + ] + } + ``` + +2. **Bucket Encryption**: Enable server-side encryption + ```properties + # S3 bucket should have default encryption enabled + ``` + +3. **Access Logging**: Enable S3 access logging for audit trails + +### Monitoring and Observability + +The repository integrates with Spring Boot's observability features: + +```properties +# Enable metrics +management.metrics.export.cloudwatch.enabled=true + +# Enable tracing +management.tracing.enabled=true +``` + +Monitor these key metrics: +- S3 request latency +- Error rates (4xx/5xx responses) +- Storage usage and costs +- Conversation access patterns + +## Error Handling + +The repository handles common S3 scenarios gracefully: + +- **Bucket doesn't exist**: Creates bucket if `initialize-bucket=true`, otherwise throws `IllegalStateException` +- **Network issues**: Retries with exponential backoff (AWS SDK default) +- **Access denied**: Throws `IllegalStateException` with clear error message +- **Invalid conversation ID**: Normalizes to "default" conversation +- **Malformed JSON**: Throws `IllegalStateException` during deserialization + +## Testing + +### Integration Testing with LocalStack + +```java +@Testcontainers +class S3ChatMemoryRepositoryIT { + + @Container + static final LocalStackContainer localstack = + new LocalStackContainer(DockerImageName.parse("localstack/localstack:latest")) + .withServices("s3"); + + @Test + void testConversationStorage() { + S3Client s3Client = S3Client.builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .region(Region.of(localstack.getRegion())) + .forcePathStyle(true) + .build(); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(s3Client) + .bucketName("test-bucket") + .build(); + + // Test conversation operations... + } +} +``` + +### Property-Based Testing + +The repository includes comprehensive property-based tests using jqwik: + +```java +@Property(tries = 100) +void conversationRoundTrip(@ForAll("conversationIds") String conversationId, + @ForAll("messageLists") List messages) { + repository.saveAll(conversationId, messages); + List retrieved = repository.findByConversationId(conversationId); + assertThat(retrieved).isEqualTo(messages); +} +``` + +## Migration from Other Repositories + +### From JDBC Chat Memory Repository + +```java +// 1. Export existing conversations +List conversationIds = jdbcRepository.findConversationIds(); +Map> conversations = new HashMap<>(); +for (String id : conversationIds) { + conversations.put(id, jdbcRepository.findByConversationId(id)); +} + +// 2. Import to S3 repository +for (Map.Entry> entry : conversations.entrySet()) { + s3Repository.saveAll(entry.getKey(), entry.getValue()); +} +``` + +## Troubleshooting + +### Common Issues + +1. **"Bucket does not exist" error** + - Set `spring.ai.chat.memory.repository.s3.initialize-bucket=true` + - Or create the bucket manually in AWS Console + +2. **"Access Denied" errors** + - Verify AWS credentials are configured + - Check IAM permissions for the bucket + - Ensure bucket policy allows access + +3. **Slow performance** + - Check AWS region configuration (use closest region) + - Consider using VPC endpoints for EC2 deployments + - Monitor S3 request metrics + +4. **High costs** + - Use appropriate storage class (`STANDARD_IA` for infrequent access) + - Implement lifecycle policies for archival + - Monitor storage usage patterns + +### Debug Logging + +Enable debug logging to troubleshoot issues: + +```properties +logging.level.org.springframework.ai.chat.memory.repository.s3=DEBUG +logging.level.software.amazon.awssdk.services.s3=DEBUG +``` + +## Documentation + +For more information about Spring AI Chat Memory, see the [official documentation](https://docs.spring.io/spring-ai/reference/api/chatmemory.html). + +## Contributing + +Contributions are welcome! Please read the [contribution guidelines](../../../../../CONTRIBUTING.adoc) before submitting pull requests. + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](../../../../../LICENSE.txt) file for details. \ No newline at end of file diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/pom.xml b/memory/repository/spring-ai-model-chat-memory-repository-s3/pom.xml new file mode 100644 index 00000000000..ac8a75768cd --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/pom.xml @@ -0,0 +1,124 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../../pom.xml + + + spring-ai-model-chat-memory-repository-s3 + Spring AI S3 Chat Memory + Spring AI S3 Chat Memory implementation + + https://github.com/spring-projects/spring-ai + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + -XDaddTypeAnnotationsToSymbol=true + + + + + + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.ai + spring-ai-model + ${project.version} + + + + + software.amazon.awssdk + s3 + ${awssdk.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.ai + spring-ai-test + ${project.version} + test + + + + org.springframework.boot + spring-boot-testcontainers + test + + + + org.testcontainers + testcontainers + test + + + + org.testcontainers + testcontainers-localstack + test + + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + + net.jqwik + jqwik + 1.9.1 + test + + + diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfig.java b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfig.java new file mode 100644 index 00000000000..9c5c1041c14 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfig.java @@ -0,0 +1,199 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.repository.s3; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.StorageClass; + +import org.springframework.util.Assert; + +/** + * Configuration class for S3ChatMemoryRepository. + * + * @author Yuriy Bezsonov + * @since 2.0.0 + */ +public final class S3ChatMemoryConfig { + + /** Default key prefix for S3 objects. */ + public static final String DEFAULT_KEY_PREFIX = "chat-memory"; + + /** Default storage class for S3 objects. */ + public static final StorageClass DEFAULT_STORAGE_CLASS = StorageClass.STANDARD; + + /** The S3 client for operations. */ + private final S3Client s3Client; + + /** The S3 bucket name. */ + private final String bucketName; + + /** The key prefix for S3 objects. */ + private final String keyPrefix; + + /** Whether to initialize the bucket if it doesn't exist. */ + private final boolean initializeBucket; + + /** The storage class for S3 objects. */ + private final StorageClass storageClass; + + private S3ChatMemoryConfig(final Builder builder) { + Assert.notNull(builder.s3Client, "s3Client cannot be null"); + Assert.hasText(builder.bucketName, "bucketName cannot be null or empty"); + + this.s3Client = builder.s3Client; + this.bucketName = builder.bucketName; + this.keyPrefix = builder.keyPrefix != null ? builder.keyPrefix : DEFAULT_KEY_PREFIX; + this.initializeBucket = builder.initializeBucket; + + this.storageClass = builder.storageClass != null ? builder.storageClass : DEFAULT_STORAGE_CLASS; + } + + /** + * Gets the S3 client. + * @return the S3 client + */ + public S3Client getS3Client() { + return this.s3Client; + } + + /** + * Gets the bucket name. + * @return the bucket name + */ + public String getBucketName() { + return this.bucketName; + } + + /** + * Gets the key prefix. + * @return the key prefix + */ + public String getKeyPrefix() { + return this.keyPrefix; + } + + /** + * Checks if bucket initialization is enabled. + * @return true if bucket should be initialized + */ + public boolean isInitializeBucket() { + return this.initializeBucket; + } + + /** + * Gets the storage class. + * @return the storage class + */ + public StorageClass getStorageClass() { + return this.storageClass; + } + + /** + * Creates a new builder. + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for S3ChatMemoryConfig. + */ + public static final class Builder { + + /** The S3 client. */ + private S3Client s3Client; + + /** The bucket name. */ + private String bucketName; + + /** The key prefix. */ + private String keyPrefix; + + /** Whether to initialize bucket. */ + private boolean initializeBucket = false; + + /** The storage class. */ + private StorageClass storageClass; + + /** + * Private constructor. + */ + private Builder() { + } + + /** + * Sets the S3 client. + * @param client the S3 client + * @return this builder + */ + public Builder s3Client(final S3Client client) { + this.s3Client = client; + return this; + } + + /** + * Sets the bucket name. + * @param name the bucket name + * @return this builder + */ + public Builder bucketName(final String name) { + this.bucketName = name; + return this; + } + + /** + * Sets the key prefix. + * @param prefix the key prefix + * @return this builder + */ + public Builder keyPrefix(final String prefix) { + this.keyPrefix = prefix; + return this; + } + + /** + * Sets whether to initialize bucket. + * @param initialize true to initialize bucket + * @return this builder + */ + public Builder initializeBucket(final boolean initialize) { + this.initializeBucket = initialize; + return this; + } + + /** + * Sets the storage class. + * @param storage the storage class + * @return this builder + */ + public Builder storageClass(final StorageClass storage) { + this.storageClass = storage; + return this; + } + + /** + * Builds the configuration. + * @return the S3ChatMemoryConfig instance + */ + public S3ChatMemoryConfig build() { + return new S3ChatMemoryConfig(this); + } + + } + +} diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepository.java b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepository.java new file mode 100644 index 00000000000..440d5ccafcc --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepository.java @@ -0,0 +1,501 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.repository.s3; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; +import software.amazon.awssdk.services.s3.model.S3Object; + +import org.springframework.ai.chat.memory.ChatMemoryRepository; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.MessageType; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.util.Assert; + +/** + * An implementation of {@link ChatMemoryRepository} for Amazon S3 using a simple JSON + * format for storing conversation messages. + * + * @author Yuriy Bezsonov + * @since 2.0.0 + */ +public final class S3ChatMemoryRepository implements ChatMemoryRepository { + + /** JSON file extension for stored conversations. */ + private static final String JSON_EXTENSION = ".json"; + + /** The S3 client for operations. */ + private final S3Client s3Client; + + /** The S3 configuration. */ + private final S3ChatMemoryConfig config; + + /** JSON object mapper for serialization. */ + private final ObjectMapper objectMapper; + + /** + * Creates a new S3ChatMemoryRepository. + * @param configuration the S3 configuration + */ + public S3ChatMemoryRepository(final S3ChatMemoryConfig configuration) { + Assert.notNull(configuration, "config cannot be null"); + + this.s3Client = configuration.getS3Client(); + this.config = configuration; + this.objectMapper = new ObjectMapper(); + } + + /** + * Ensures the S3 bucket exists, creating it if necessary. + */ + private void ensureBucketExists() { + try { + this.s3Client.headBucket(HeadBucketRequest.builder().bucket(this.config.getBucketName()).build()); + } + catch (NoSuchBucketException e) { + if (this.config.isInitializeBucket()) { + try { + this.s3Client + .createBucket(CreateBucketRequest.builder().bucket(this.config.getBucketName()).build()); + } + catch (S3Exception createException) { + throw new IllegalStateException("Failed to create S3 bucket '" + this.config.getBucketName() + "': " + + createException.getMessage(), createException); + } + } + else { + throw new IllegalStateException("S3 bucket '" + this.config.getBucketName() + "' does not exist. " + + "Create the bucket manually or set " + "spring.ai.chat.memory.repository.s3." + + "initialize-bucket=true"); + } + } + catch (S3Exception e) { + throw new IllegalStateException( + "Failed to check S3 bucket '" + this.config.getBucketName() + "': " + e.getMessage(), e); + } + } + + /** + * Normalizes conversation ID, using default if null or empty. + * @param conversationId the conversation ID to normalize + * @return the normalized conversation ID + */ + private String normalizeConversationId(final String conversationId) { + return (conversationId == null || conversationId.trim().isEmpty()) ? "default" : conversationId.trim(); + } + + /** + * Generates S3 key for conversation. Pattern: {prefix}/{conversationId}.json + * @param conversationId the conversation ID + * @param prefix the key prefix + * @return the S3 key + */ + private String generateKey(final String conversationId, final String prefix) { + String normalizedConversationId = normalizeConversationId(conversationId); + Assert.hasText(prefix, "prefix cannot be null or empty"); + + String normalizedPrefix = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix; + + return normalizedPrefix + "/" + normalizedConversationId + JSON_EXTENSION; + } + + /** + * Extracts conversation ID from S3 key. + * @param key the S3 key + * @param prefix the key prefix + * @return the conversation ID or null if invalid + */ + private String extractConversationId(final String key, final String prefix) { + Assert.hasText(key, "key cannot be null or empty"); + Assert.hasText(prefix, "prefix cannot be null or empty"); + + String normalizedPrefix = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix; + + if (!key.startsWith(normalizedPrefix + "/") || !key.endsWith(JSON_EXTENSION)) { + return null; + } + + int startIndex = normalizedPrefix.length() + 1; + int endIndex = key.length() - JSON_EXTENSION.length(); + + if (startIndex >= endIndex) { + return null; + } + + String conversationId = key.substring(startIndex, endIndex); + + if (conversationId.contains("/")) { + return null; + } + + return conversationId.isEmpty() ? null : conversationId; + } + + /** + * Serializes conversation messages to JSON. + * @param conversationId the conversation ID + * @param messages the messages to serialize + * @return the JSON string + */ + private String serialize(final String conversationId, final List messages) { + try { + Map payload = new HashMap<>(); + payload.put("conversationId", conversationId); + + List> messageList = new ArrayList<>(); + // Sequential timestamps for message ordering (JSON array order + // already preserves sequence) + long baseTimestamp = Instant.now().getEpochSecond(); + + for (int i = 0; i < messages.size(); i++) { + Message message = messages.get(i); + Map messageMap = new HashMap<>(); + messageMap.put("type", message.getMessageType().name()); + messageMap.put("content", message.getText()); + messageMap.put("timestamp", baseTimestamp + i); + messageMap.put("metadata", message.getMetadata()); + messageList.add(messageMap); + } + + payload.put("messages", messageList); + + return this.objectMapper.writeValueAsString(payload); + } + catch (JsonProcessingException e) { + throw new IllegalStateException( + "Failed to serialize messages for " + "conversation '" + conversationId + "': " + e.getMessage(), + e); + } + } + + /** + * Deserializes JSON to conversation messages. + * @param json the JSON string + * @return the list of messages + */ + private List deserialize(final String json) { + try { + JsonNode root = this.objectMapper.readTree(json); + + if (!root.has("messages")) { + throw new IllegalStateException("JSON does not contain 'messages' field"); + } + + JsonNode messagesNode = root.get("messages"); + List messages = new ArrayList<>(); + + for (JsonNode messageNode : messagesNode) { + String typeStr = messageNode.get("type").asText(); + String content = messageNode.get("content").asText(); + + Long timestamp = messageNode.has("timestamp") ? messageNode.get("timestamp").asLong() : null; + + Map metadata = new HashMap<>(); + if (messageNode.has("metadata") && !messageNode.get("metadata").isNull()) { + metadata = convertMetadata(messageNode.get("metadata")); + } + + if (timestamp != null) { + metadata.put("timestamp", timestamp); + } + + MessageType type = MessageType.valueOf(typeStr); + Message message = createMessage(type, content, metadata); + + messages.add(message); + } + + return messages; + } + catch (JsonProcessingException e) { + throw new IllegalStateException("Failed to deserialize messages: " + e.getMessage(), e); + } + } + + /** + * Converts JSON metadata node to Map. + * @param metadataNode the JSON metadata node + * @return the metadata map + */ + private Map convertMetadata(final JsonNode metadataNode) { + return this.objectMapper.convertValue(metadataNode, + new com.fasterxml.jackson.core.type.TypeReference>() { + }); + } + + /** + * Creates a message instance based on type. + * @param type the message type + * @param content the message content + * @param metadata the message metadata + * @return the message instance + */ + private Message createMessage(final MessageType type, final String content, final Map metadata) { + return switch (type) { + case USER -> UserMessage.builder().text(content).metadata(metadata).build(); + case ASSISTANT -> AssistantMessage.builder().content(content).properties(metadata).build(); + case SYSTEM -> SystemMessage.builder().text(content).metadata(metadata).build(); + case TOOL -> ToolResponseMessage.builder().responses(List.of()).metadata(metadata).build(); + }; + } + + @Override + public List findConversationIds() { + ensureBucketExists(); + + try { + List conversationIds = new ArrayList<>(); + + ListObjectsV2Request request = ListObjectsV2Request.builder() + .bucket(this.config.getBucketName()) + .prefix(this.config.getKeyPrefix() + "/") + .build(); + + ListObjectsV2Response response; + do { + response = this.s3Client.listObjectsV2(request); + + for (S3Object s3Object : response.contents()) { + String key = s3Object.key(); + String conversationId = extractConversationId(key, this.config.getKeyPrefix()); + if (conversationId != null) { + conversationIds.add(conversationId); + } + } + + request = request.toBuilder().continuationToken(response.nextContinuationToken()).build(); + } + while (response.isTruncated()); + + return conversationIds; + } + catch (S3Exception e) { + throw new IllegalStateException("Failed to list conversation IDs " + "from S3 bucket '" + + this.config.getBucketName() + "': " + e.getMessage(), e); + } + } + + @Override + public List findByConversationId(final String conversationId) { + String normalizedConversationId = normalizeConversationId(conversationId); + ensureBucketExists(); + + try { + String key = generateKey(normalizedConversationId, this.config.getKeyPrefix()); + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(this.config.getBucketName()) + .key(key) + .build(); + + try (ResponseInputStream response = this.s3Client.getObject(getObjectRequest)) { + String json = new String(response.readAllBytes()); + return deserialize(json); + } + } + catch (NoSuchKeyException e) { + return new ArrayList<>(); + } + catch (S3Exception e) { + throw new IllegalStateException("Failed to retrieve conversation '" + normalizedConversationId + + "' from S3 bucket '" + this.config.getBucketName() + "': " + e.getMessage(), e); + } + catch (Exception e) { + throw new IllegalStateException( + "Failed to retrieve conversation '" + normalizedConversationId + "': " + e.getMessage(), e); + } + } + + @Override + public void saveAll(final String conversationId, final List messages) { + Assert.notNull(messages, "messages cannot be null"); + + String normalizedConversationId = normalizeConversationId(conversationId); + + if (messages.isEmpty()) { + deleteByConversationId(normalizedConversationId); + return; + } + + ensureBucketExists(); + + try { + String key = generateKey(normalizedConversationId, this.config.getKeyPrefix()); + String json = serialize(normalizedConversationId, messages); + + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(this.config.getBucketName()) + .key(key) + .storageClass(this.config.getStorageClass()) + .build(); + + this.s3Client.putObject(putObjectRequest, RequestBody.fromString(json)); + } + catch (S3Exception e) { + throw new IllegalStateException("Failed to save conversation '" + normalizedConversationId + + "' to S3 bucket '" + this.config.getBucketName() + "': " + e.getMessage(), e); + } + } + + @Override + public void deleteByConversationId(final String conversationId) { + String normalizedConversationId = normalizeConversationId(conversationId); + ensureBucketExists(); + + try { + String key = generateKey(normalizedConversationId, this.config.getKeyPrefix()); + + DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder() + .bucket(this.config.getBucketName()) + .key(key) + .build(); + + this.s3Client.deleteObject(deleteObjectRequest); + } + catch (S3Exception e) { + throw new IllegalStateException("Failed to delete conversation '" + normalizedConversationId + + "' from S3 bucket '" + this.config.getBucketName() + "': " + e.getMessage(), e); + } + } + + /** + * Creates a new builder for S3ChatMemoryRepository. + * @return a new builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for S3ChatMemoryRepository. + */ + public static final class Builder { + + /** The S3 client. */ + private S3Client s3Client; + + /** The bucket name. */ + private String bucketName; + + /** The key prefix. */ + private String keyPrefix; + + /** Whether to initialize bucket. */ + private boolean initializeBucket = false; + + /** The storage class. */ + private software.amazon.awssdk.services.s3.model.StorageClass storageClass; + + /** + * Private constructor. + */ + private Builder() { + } + + /** + * Sets the S3 client. + * @param client the S3 client + * @return this builder + */ + public Builder s3Client(final S3Client client) { + this.s3Client = client; + return this; + } + + /** + * Sets the bucket name. + * @param name the bucket name + * @return this builder + */ + public Builder bucketName(final String name) { + this.bucketName = name; + return this; + } + + /** + * Sets the key prefix. + * @param prefix the key prefix + * @return this builder + */ + public Builder keyPrefix(final String prefix) { + this.keyPrefix = prefix; + return this; + } + + /** + * Sets whether to initialize bucket. + * @param initialize true to initialize bucket + * @return this builder + */ + public Builder initializeBucket(final boolean initialize) { + this.initializeBucket = initialize; + return this; + } + + /** + * Sets the storage class. + * @param storage the storage class + * @return this builder + */ + public Builder storageClass(final software.amazon.awssdk.services.s3.model.StorageClass storage) { + this.storageClass = storage; + return this; + } + + /** + * Builds the repository. + * @return the S3ChatMemoryRepository instance + */ + public S3ChatMemoryRepository build() { + S3ChatMemoryConfig config = S3ChatMemoryConfig.builder() + .s3Client(this.s3Client) + .bucketName(this.bucketName) + .keyPrefix(this.keyPrefix) + .initializeBucket(this.initializeBucket) + .storageClass(this.storageClass) + .build(); + + return new S3ChatMemoryRepository(config); + } + + } + +} diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/package-info.java b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/package-info.java new file mode 100644 index 00000000000..5cc6bc508a9 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/java/org/springframework/ai/chat/memory/repository/s3/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * S3-based implementation of Spring AI chat memory repository. + * + *

+ * This package provides classes for storing and retrieving chat memory using Amazon S3 as + * the persistence layer. + * + * @author Yuriy Bezsonov + * @since 2.0.0 + */ +package org.springframework.ai.chat.memory.repository.s3; diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..7216b324fa2 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +org.springframework.ai.autoconfigure.chat.memory.repository.s3.S3ChatMemoryAutoConfiguration \ No newline at end of file diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfigPropertyTest.java b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfigPropertyTest.java new file mode 100644 index 00000000000..e31197657e6 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryConfigPropertyTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.repository.s3; + +import java.util.List; + +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.Combinators; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * Property-based tests for S3ChatMemoryConfig. + * + * @author Yuriy Bezsonov + */ +class S3ChatMemoryConfigPropertyTest { + + // Feature: s3-chat-memory-repository, Property 15: Configuration property binding + @Property(tries = 100) + void configurationPropertyBinding(@ForAll("validConfigParams") ConfigParams params) { + // Given: A repository with random valid configuration + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName(params.bucketName()) + .keyPrefix(params.keyPrefix()) + .build(); + + // When: Saving messages + List messages = List.of(UserMessage.builder().text("test message").build()); + repository.saveAll("test-conversation", messages); + + // Then: S3 operations should use the configured bucket name + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + verify(mockS3Client).putObject(requestCaptor.capture(), any(RequestBody.class)); + + PutObjectRequest capturedRequest = requestCaptor.getValue(); + assertThat(capturedRequest.bucket()).isEqualTo(params.bucketName()); + + // And: The key should use the configured prefix + String expectedPrefix = params.keyPrefix(); + assertThat(capturedRequest.key()).startsWith(expectedPrefix); + } + + @Provide + Arbitrary validConfigParams() { + Arbitrary bucketNames = Arbitraries.strings() + .alpha() + .numeric() + .withChars('-') + .ofMinLength(3) + .ofMaxLength(63) + .filter(s -> !s.startsWith("-") && !s.endsWith("-")); + + Arbitrary keyPrefixes = Arbitraries.strings() + .alpha() + .numeric() + .withChars('-', '_') + .ofMinLength(1) + .ofMaxLength(50); + + return Combinators.combine(bucketNames, keyPrefixes).as((bucket, prefix) -> new ConfigParams(bucket, prefix)); + } + + record ConfigParams(String bucketName, String keyPrefix) { + } + +} diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryIT.java b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryIT.java new file mode 100644 index 00000000000..7d53b2a29d2 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryIT.java @@ -0,0 +1,297 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.repository.s3; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; + +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for S3ChatMemoryRepository using LocalStack. + * + * @author Yuriy Bezsonov + */ +@Testcontainers +class S3ChatMemoryRepositoryIT { + + private static final String BUCKET_NAME = "test-chat-memory"; + + @Container + static final LocalStackContainer localstack = initializeLocalStack(); + + private static LocalStackContainer initializeLocalStack() { + LocalStackContainer container = new LocalStackContainer(DockerImageName.parse("localstack/localstack:latest")); + container.withServices("s3"); + return container; + } + + private S3Client s3Client; + + private S3ChatMemoryRepository repository; + + @BeforeEach + void setUp() throws InterruptedException { + // Wait a bit for LocalStack to be fully ready + Thread.sleep(1000); + + // Create S3 client pointing to LocalStack with path-style access + this.s3Client = S3Client.builder() + .endpointOverride(localstack.getEndpoint()) + .credentialsProvider(StaticCredentialsProvider + .create(AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey()))) + .region(Region.of(localstack.getRegion())) + .forcePathStyle(true) // Required for LocalStack + .build(); + + // Create bucket with retry logic + boolean bucketCreated = false; + int attempts = 0; + while (!bucketCreated && attempts < 5) { + try { + this.s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET_NAME).build()); + bucketCreated = true; + } + catch (software.amazon.awssdk.services.s3.model.BucketAlreadyExistsException e) { + // Bucket already exists, which is fine + bucketCreated = true; + } + catch (software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException e) { + // Bucket already owned by us, which is fine + bucketCreated = true; + } + catch (Exception e) { + attempts++; + if (attempts >= 5) { + throw new RuntimeException("Failed to create bucket after 5 attempts", e); + } + Thread.sleep(1000); // Wait 1 second before retry + } + } + + // Create this.repository with unique prefix for each test + String uniquePrefix = "test-" + System.currentTimeMillis(); + + this.repository = S3ChatMemoryRepository.builder() + .s3Client(this.s3Client) + .bucketName(BUCKET_NAME) + .keyPrefix(uniquePrefix) + + .build(); + } + + @Test + void testFullCrudOperations() { + // Given: A conversation with messages + String conversationId = "test-conversation-1"; + List messages = List.of(UserMessage.builder().text("Hello").build(), + AssistantMessage.builder().content("Hi there!").build(), + SystemMessage.builder().text("System message").build()); + + // When: Saving messages + this.repository.saveAll(conversationId, messages); + + // Then: Messages can be retrieved + List retrieved = this.repository.findByConversationId(conversationId); + assertThat(retrieved).hasSize(3); + assertThat(retrieved.get(0).getText()).isEqualTo("Hello"); + assertThat(retrieved.get(1).getText()).isEqualTo("Hi there!"); + assertThat(retrieved.get(2).getText()).isEqualTo("System message"); + + // And: Conversation ID appears in list + List conversationIds = this.repository.findConversationIds(); + assertThat(conversationIds).contains(conversationId); + + // When: Deleting conversation + this.repository.deleteByConversationId(conversationId); + + // Then: Conversation no longer exists + List afterDelete = this.repository.findByConversationId(conversationId); + assertThat(afterDelete).isEmpty(); + + List idsAfterDelete = this.repository.findConversationIds(); + assertThat(idsAfterDelete).doesNotContain(conversationId); + } + + @Test + void testMultipleConversations() { + // Given: Multiple conversations + for (int i = 1; i <= 5; i++) { + String conversationId = "conversation-" + i; + List messages = List.of(UserMessage.builder().text("Message " + i).build()); + this.repository.saveAll(conversationId, messages); + } + + // When: Listing all conversations + List conversationIds = this.repository.findConversationIds(); + + // Then: All conversations are present + assertThat(conversationIds).hasSize(5); + assertThat(conversationIds).contains("conversation-1", "conversation-2", "conversation-3", "conversation-4", + "conversation-5"); + } + + @Test + void testPaginationWithLargeDataset() { + // Given: Many conversations (more than typical page size) + int conversationCount = 25; + for (int i = 1; i <= conversationCount; i++) { + String conversationId = "conv-" + i; + List messages = List.of(UserMessage.builder().text("Test message " + i).build()); + this.repository.saveAll(conversationId, messages); + } + + // When: Listing all conversations + List conversationIds = this.repository.findConversationIds(); + + // Then: All conversations are returned (pagination handled internally) + assertThat(conversationIds).hasSize(conversationCount); + } + + @Test + void testConversationReplacement() { + // Given: A conversation with initial messages + String conversationId = "replacement-test"; + List initialMessages = List.of(UserMessage.builder().text("Initial message").build()); + this.repository.saveAll(conversationId, initialMessages); + + // When: Replacing with new messages + List newMessages = List.of(UserMessage.builder().text("New message 1").build(), + UserMessage.builder().text("New message 2").build()); + this.repository.saveAll(conversationId, newMessages); + + // Then: Only new messages are present + List retrieved = this.repository.findByConversationId(conversationId); + assertThat(retrieved).hasSize(2); + assertThat(retrieved.get(0).getText()).isEqualTo("New message 1"); + assertThat(retrieved.get(1).getText()).isEqualTo("New message 2"); + } + + @Test + void testEmptyMessageListDeletesConversation() { + // Given: A conversation with messages + String conversationId = "empty-test"; + List messages = List.of(UserMessage.builder().text("Test").build()); + this.repository.saveAll(conversationId, messages); + + // When: Saving empty message list + this.repository.saveAll(conversationId, List.of()); + + // Then: Conversation is deleted + List retrieved = this.repository.findByConversationId(conversationId); + assertThat(retrieved).isEmpty(); + + List conversationIds = this.repository.findConversationIds(); + assertThat(conversationIds).doesNotContain(conversationId); + } + + @Test + void testNonExistentConversation() { + // When: Retrieving non-existent conversation + List messages = this.repository.findByConversationId("non-existent"); + + // Then: Empty list is returned + assertThat(messages).isEmpty(); + } + + @Test + void testMessageMetadataPreservation() { + // Given: Messages with metadata + String conversationId = "metadata-test"; + List messages = List.of( + UserMessage.builder() + .text("User message") + .metadata(java.util.Map.of("key1", "value1", "key2", 42)) + .build(), + AssistantMessage.builder() + .content("Assistant message") + .properties(java.util.Map.of("model", "gpt-4", "temperature", 0.7)) + .build()); + + // When: Saving and retrieving + this.repository.saveAll(conversationId, messages); + List retrieved = this.repository.findByConversationId(conversationId); + + // Then: Metadata is preserved + assertThat(retrieved).hasSize(2); + assertThat(retrieved.get(0).getMetadata()).containsEntry("key1", "value1"); + assertThat(retrieved.get(0).getMetadata()).containsEntry("key2", 42); + assertThat(retrieved.get(1).getMetadata()).containsEntry("model", "gpt-4"); + } + + @Test + void testMessageOrderPreservation() { + // Given: Messages in specific order + String conversationId = "order-test"; + List messages = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + messages.add(UserMessage.builder().text("Message " + i).build()); + } + + // When: Saving and retrieving + this.repository.saveAll(conversationId, messages); + List retrieved = this.repository.findByConversationId(conversationId); + + // Then: Order is preserved + assertThat(retrieved).hasSize(10); + for (int i = 0; i < 10; i++) { + assertThat(retrieved.get(i).getText()).isEqualTo("Message " + (i + 1)); + } + } + + @Test + void testStorageClassConfigurationIntegration() { + // Given: Repository with custom storage class + String uniquePrefix = "storage-test-" + System.currentTimeMillis(); + S3ChatMemoryRepository storageRepository = S3ChatMemoryRepository.builder() + .s3Client(this.s3Client) + .bucketName(BUCKET_NAME) + .keyPrefix(uniquePrefix) + .storageClass(software.amazon.awssdk.services.s3.model.StorageClass.STANDARD_IA) + .build(); + + String conversationId = "storage-conversation"; + + // When: Saving messages with custom storage class + List messages = List.of(UserMessage.builder().text("Test message").build()); + storageRepository.saveAll(conversationId, messages); + + // Then: Messages should be saved and retrievable (storage class is applied during + // save) + List retrieved = storageRepository.findByConversationId(conversationId); + assertThat(retrieved).hasSize(1); + assertThat(retrieved.get(0).getText()).isEqualTo("Test message"); + } + +} diff --git a/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryPropertyTest.java b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryPropertyTest.java new file mode 100644 index 00000000000..69d9297c306 --- /dev/null +++ b/memory/repository/spring-ai-model-chat-memory-repository-s3/src/test/java/org/springframework/ai/chat/memory/repository/s3/S3ChatMemoryRepositoryPropertyTest.java @@ -0,0 +1,310 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.ai.chat.memory.repository.s3; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; +import org.mockito.ArgumentCaptor; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.S3Object; + +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Property-based tests for S3ChatMemoryRepository operations. + * + * @author Yuriy Bezsonov + */ +class S3ChatMemoryRepositoryPropertyTest { + + // Feature: s3-chat-memory-repository, Property 3: Complete conversation listing + // with pagination + @Property(tries = 100) + void completeListingWithPagination(@ForAll("conversationIdSets") Set conversationIds) { + // Given: A repository with multiple conversations that require pagination + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName("test-bucket") + .keyPrefix("chat-memory") + .build(); + + // Create mock S3 objects for each conversation + List s3Objects = new ArrayList<>(); + for (String conversationId : conversationIds) { + S3Object s3Object = S3Object.builder().key("chat-memory/" + conversationId + ".json").build(); + s3Objects.add(s3Object); + } + + // Simulate pagination: split objects into pages of 10 + int pageSize = 10; + List> pages = new ArrayList<>(); + for (int i = 0; i < s3Objects.size(); i += pageSize) { + pages.add(s3Objects.subList(i, Math.min(i + pageSize, s3Objects.size()))); + } + + // Mock paginated responses + if (pages.isEmpty()) { + // Empty result + ListObjectsV2Response emptyResponse = ListObjectsV2Response.builder() + .contents(List.of()) + .isTruncated(false) + .build(); + when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(emptyResponse); + } + else { + // Setup mock responses for each page + ListObjectsV2Response[] responses = new ListObjectsV2Response[pages.size()]; + for (int i = 0; i < pages.size(); i++) { + boolean hasMore = i < pages.size() - 1; + responses[i] = ListObjectsV2Response.builder() + .contents(pages.get(i)) + .isTruncated(hasMore) + .nextContinuationToken(hasMore ? "token-" + (i + 1) : null) + .build(); + } + + when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(responses[0], + responses.length > 1 ? java.util.Arrays.copyOfRange(responses, 1, responses.length) + : new ListObjectsV2Response[0]); + } + + // When: Finding all conversation IDs + List foundIds = repository.findConversationIds(); + + // Then: All conversation IDs should be returned + assertThat(foundIds).containsExactlyInAnyOrderElementsOf(conversationIds); + + // And: Pagination should have been handled (verify multiple calls if needed) + int expectedCalls = Math.max(1, pages.size()); + verify(mockS3Client, times(expectedCalls)).listObjectsV2(any(ListObjectsV2Request.class)); + } + + // Feature: s3-chat-memory-repository, Property 4: Invalid key filtering + @Property(tries = 100) + void invalidKeyFiltering(@ForAll("validAndInvalidKeys") KeySet keySet) { + // Given: A repository with both valid and invalid S3 keys + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName("test-bucket") + .keyPrefix("chat-memory") + .build(); + + // Create S3 objects with both valid and invalid keys + List s3Objects = new ArrayList<>(); + for (String key : keySet.allKeys()) { + s3Objects.add(S3Object.builder().key(key).build()); + } + + ListObjectsV2Response response = ListObjectsV2Response.builder().contents(s3Objects).isTruncated(false).build(); + + when(mockS3Client.listObjectsV2(any(ListObjectsV2Request.class))).thenReturn(response); + + // When: Finding conversation IDs + List foundIds = repository.findConversationIds(); + + // Then: Only valid conversation IDs should be returned + assertThat(foundIds).containsExactlyInAnyOrderElementsOf(keySet.validConversationIds()); + // Verify no invalid keys are included (only check if there are invalid keys) + if (!keySet.invalidKeys().isEmpty()) { + assertThat(foundIds).doesNotContainAnyElementsOf(keySet.invalidKeys()); + } + } + + // Feature: s3-chat-memory-repository, Property 5: Conversation replacement + @Property(tries = 100) + void conversationReplacement(@ForAll("conversationIds") String conversationId, + @ForAll("messageLists") List firstMessages, @ForAll("messageLists") List secondMessages) { + // Given: A repository + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName("test-bucket") + .keyPrefix("chat-memory") + .build(); + + // When: Saving messages twice for the same conversation + repository.saveAll(conversationId, firstMessages); + repository.saveAll(conversationId, secondMessages); + + // Then: The second save should have replaced the first + // Verify that putObject was called twice (once for each save) + verify(mockS3Client, times(2)).putObject(any(software.amazon.awssdk.services.s3.model.PutObjectRequest.class), + any(software.amazon.awssdk.core.sync.RequestBody.class)); + } + + // Feature: s3-chat-memory-repository, Property 6: Deletion completeness + @Property(tries = 100) + void deletionCompleteness(@ForAll("conversationIds") String conversationId) { + // Given: A repository + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName("test-bucket") + .keyPrefix("chat-memory") + .build(); + + // When: Deleting a conversation + repository.deleteByConversationId(conversationId); + + // Then: DeleteObject should have been called with the correct key + ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class); + verify(mockS3Client).deleteObject(captor.capture()); + + DeleteObjectRequest request = captor.getValue(); + assertThat(request.bucket()).isEqualTo("test-bucket"); + assertThat(request.key()).startsWith("chat-memory/"); + assertThat(request.key()).endsWith(".json"); + } + + @Provide + Arbitrary> conversationIdSets() { + return Arbitraries.strings().alpha().numeric().ofMinLength(1).ofMaxLength(20).set().ofMinSize(0).ofMaxSize(25); + } + + @Provide + Arbitrary conversationIds() { + return Arbitraries.strings().alpha().numeric().ofMinLength(1).ofMaxLength(50); + } + + @Provide + Arbitrary> messageLists() { + return Arbitraries.strings() + .alpha() + .numeric() + .ofMinLength(1) + .ofMaxLength(100) + .map(content -> (Message) UserMessage.builder().text(content).build()) + .list() + .ofMinSize(1) + .ofMaxSize(5); + } + + @Provide + Arbitrary validAndInvalidKeys() { + Arbitrary validIds = Arbitraries.strings().alpha().numeric().ofMinLength(1).ofMaxLength(20); + + Arbitrary validKeys = validIds.map(id -> "chat-memory/" + id + ".json"); + + Arbitrary invalidKeys = Arbitraries.oneOf(Arbitraries.of("invalid-key", "wrong-prefix/conv.json", + "chat-memory/nested/path.json", "chat-memory/.json", "chat-memory/conv.txt")); + + return validKeys.set().ofMinSize(0).ofMaxSize(10).flatMap(validKeySet -> { + Set validConvIds = new HashSet<>(); + for (String key : validKeySet) { + String id = key.substring("chat-memory/".length(), key.length() - ".json".length()); + validConvIds.add(id); + } + + return invalidKeys.set().ofMinSize(0).ofMaxSize(5).map(invalidKeySet -> { + Set allKeys = new HashSet<>(validKeySet); + allKeys.addAll(invalidKeySet); + return new KeySet(allKeys, validConvIds, invalidKeySet); + }); + }); + } + + // Feature: s3-chat-memory-repository, Property 8: Input validation + @Property(tries = 100) + void inputValidation(@ForAll("invalidInputs") InvalidInput input) { + // Given: A repository + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName("test-bucket") + .keyPrefix("chat-memory") + .build(); + + // When/Then: Invalid inputs should throw IllegalArgumentException + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> repository.saveAll(input.conversationId(), input.messages())); + } + + // Feature: s3-chat-memory-repository, Property 10: Timestamp consistency + @Property(tries = 100) + void timestampConsistency(@ForAll("conversationIds") String conversationId, + @ForAll("messageLists") List messages) { + // Given: A repository + S3Client mockS3Client = mock(S3Client.class); + + S3ChatMemoryRepository repository = S3ChatMemoryRepository.builder() + .s3Client(mockS3Client) + .bucketName("test-bucket") + .keyPrefix("chat-memory") + .build(); + + // When: Saving messages + repository.saveAll(conversationId, messages); + + // Then: All messages should have consistent timestamp formatting + // This is verified by the serialization process not throwing exceptions + verify(mockS3Client).putObject(any(software.amazon.awssdk.services.s3.model.PutObjectRequest.class), + any(software.amazon.awssdk.core.sync.RequestBody.class)); + } + + @Provide + Arbitrary invalidInputs() { + // Only test truly invalid inputs - null conversation IDs are now valid (converted + // to default) + return Arbitraries.just(new InvalidInput("saveAll", "valid-id", null)); + } + + @Provide + Arbitrary> largeMessageLists() { + return Arbitraries.strings() + .alpha() + .numeric() + .ofMinLength(1) + .ofMaxLength(50) + .map(content -> (Message) UserMessage.builder().text(content).build()) + .list() + .ofMinSize(10) + .ofMaxSize(50); // Larger lists to test window enforcement + } + + record InvalidInput(String operation, String conversationId, List messages) { + } + + record KeySet(Set allKeys, Set validConversationIds, Set invalidKeys) { + } + +} diff --git a/pom.xml b/pom.xml index d635acace66..0d75a071486 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,7 @@ memory/repository/spring-ai-model-chat-memory-repository-mongodb memory/repository/spring-ai-model-chat-memory-repository-neo4j memory/repository/spring-ai-model-chat-memory-repository-redis + memory/repository/spring-ai-model-chat-memory-repository-s3 @@ -95,6 +96,7 @@ auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-jdbc auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-mongodb auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-neo4j + auto-configurations/models/chat/memory/repository/spring-ai-autoconfigure-model-chat-memory-repository-s3 auto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory-redis auto-configurations/models/chat/observation/spring-ai-autoconfigure-model-chat-observation @@ -223,13 +225,14 @@ spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-mongodb spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-neo4j spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-redis + spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-s3 spring-ai-spring-boot-starters/spring-ai-starter-mcp-client spring-ai-spring-boot-starters/spring-ai-starter-mcp-server spring-ai-spring-boot-starters/spring-ai-starter-mcp-client-webflux spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webflux spring-ai-spring-boot-starters/spring-ai-starter-mcp-server-webmvc - + spring-ai-integration-tests mcp/common diff --git a/spring-ai-bom/pom.xml b/spring-ai-bom/pom.xml index dde9366f78e..4272e4daffc 100644 --- a/spring-ai-bom/pom.xml +++ b/spring-ai-bom/pom.xml @@ -221,6 +221,18 @@ ${project.version} + + org.springframework.ai + spring-ai-model-chat-memory-repository-redis + ${project.version} + + + + org.springframework.ai + spring-ai-model-chat-memory-repository-s3 + ${project.version} + + @@ -536,6 +548,18 @@ ${project.version} + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory-repository-redis + ${project.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory-repository-s3 + ${project.version} + + @@ -1179,6 +1203,18 @@ ${project.version} + + org.springframework.ai + spring-ai-starter-model-chat-memory-repository-redis + ${project.version} + + + + org.springframework.ai + spring-ai-starter-model-chat-memory-repository-s3 + ${project.version} + + diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc index 86fac23e32b..c8b2bf18d3d 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/chat-memory.adoc @@ -476,6 +476,115 @@ ChatMemory chatMemory = MessageWindowChatMemory.builder() ==== Collection Initialization The auto-configuration will automatically create the `ai_chat_memory` collection on startup if it does not already exist. +=== S3ChatMemoryRepository + +`S3ChatMemoryRepository` is a built-in implementation that uses Amazon S3 to store chat messages as JSON documents. It is suitable for applications that require scalable, cost-effective, and durable chat memory persistence with support for multiple S3 storage classes and lifecycle policies. + +The repository stores each conversation as a single JSON document in S3, providing atomic updates and efficient retrieval. It supports S3-compatible services like MinIO for local development and testing. + +First, add the following dependency to your project: + +[tabs] +====== +Maven:: ++ +[source, xml] +---- + + + org.springframework.ai + spring-ai-starter-model-chat-memory-repository-s3 + +---- + +Gradle:: ++ +[source,groovy] +---- +dependencies { + implementation 'org.springframework.ai:spring-ai-starter-model-chat-memory-repository-s3' +} +---- +====== + +Spring AI provides auto-configuration for the `S3ChatMemoryRepository`, which you can use directly in your application. + +[source,java] +---- +@Autowired +S3ChatMemoryRepository chatMemoryRepository; + +ChatMemory chatMemory = MessageWindowChatMemory.builder() + .chatMemoryRepository(chatMemoryRepository) + .maxMessages(10) + .build(); +---- + +If you'd rather create the `S3ChatMemoryRepository` manually, you can do so by providing an S3 client and configuration: + +[source,java] +---- +S3Client s3Client = S3Client.builder() + .region(Region.US_WEST_2) + .build(); + +ChatMemoryRepository chatMemoryRepository = S3ChatMemoryRepository.builder() + .s3Client(s3Client) + .bucketName("my-chat-conversations") + .keyPrefix("chat-memory") + .initializeBucket(true) + .storageClass(StorageClass.STANDARD_IA) + .build(); + +ChatMemory chatMemory = MessageWindowChatMemory.builder() + .chatMemoryRepository(chatMemoryRepository) + .maxMessages(10) + .build(); +---- + +==== Configuration Properties + +[cols="2,5,1",stripes=even] +|=== +|Property | Description | Default Value +| `spring.ai.chat.memory.repository.s3.bucket-name` | S3 bucket name for storing conversations. Required for auto-configuration. | +| `spring.ai.chat.memory.repository.s3.region` | AWS region for S3 operations | `us-east-1` +| `spring.ai.chat.memory.repository.s3.key-prefix` | S3 key prefix for conversation objects | `chat-memory` +| `spring.ai.chat.memory.repository.s3.initialize-bucket` | Auto-create bucket if it doesn't exist | `false` +| `spring.ai.chat.memory.repository.s3.storage-class` | S3 storage class (`STANDARD`, `STANDARD_IA`, `ONEZONE_IA`, `REDUCED_REDUNDANCY`) | `STANDARD` +|=== + +==== AWS Credentials + +Configure AWS credentials using standard AWS credential providers: + +* Environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`) +* AWS credentials file (`~/.aws/credentials`) +* IAM roles (recommended for EC2/ECS/Lambda deployments) +* AWS profiles + +==== S3-Specific Considerations + +* **Consistency**: S3 provides strong read-after-write consistency for new objects and strong consistency for overwrite PUTS and DELETES +* **Performance**: Each conversation is stored as a single JSON document for atomic updates and efficient retrieval +* **Cost Optimization**: Use appropriate storage classes (`STANDARD_IA` for infrequent access) and implement lifecycle policies for long-term archival +* **Security**: Enable server-side encryption and use minimal IAM permissions for production deployments + +==== Custom S3 Endpoint + +For S3-compatible services like MinIO or LocalStack: + +[source,java] +---- +S3Client s3Client = S3Client.builder() + .endpointOverride(URI.create("http://localhost:9000")) // MinIO endpoint + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create("minioadmin", "minioadmin"))) + .forcePathStyle(true) // Required for MinIO + .build(); +---- + === RedisChatMemoryRepository `RedisChatMemoryRepository` is a built-in implementation that uses Redis Stack (with Redis Query Engine and RedisJSON) to store chat messages. diff --git a/spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-s3/pom.xml b/spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-s3/pom.xml new file mode 100644 index 00000000000..8f97a5513d8 --- /dev/null +++ b/spring-ai-spring-boot-starters/spring-ai-starter-model-chat-memory-repository-s3/pom.xml @@ -0,0 +1,64 @@ + + + + + 4.0.0 + + org.springframework.ai + spring-ai-parent + 2.0.0-SNAPSHOT + ../../pom.xml + + spring-ai-starter-model-chat-memory-repository-s3 + jar + Spring AI Starter - S3 Chat Memory Repository + Spring AI S3 Chat Memory Repository Starter + https://github.com/spring-projects/spring-ai + + + https://github.com/spring-projects/spring-ai + git://github.com/spring-projects/spring-ai.git + git@github.com:spring-projects/spring-ai.git + + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory + ${project.parent.version} + + + + org.springframework.ai + spring-ai-autoconfigure-model-chat-memory-repository-s3 + ${project.parent.version} + + + + org.springframework.ai + spring-ai-model-chat-memory-repository-s3 + ${project.parent.version} + + + + \ No newline at end of file