Add chat-memory module: MessageWindowChatMemory, JDBC and Redis repositories, per-user conversation IDs

Real PostgreSQL 16 and Redis Stack 7.4; 24 tests write output/01-24. Covers the 20-message
default window with no property, the 36-character conversation_id, tool messages dropped by
JdbcChatMemoryRepository, concurrent add() on one conversation, a 1.x table under the 2.0
repository, the Redis repository silently backing off for a custom ChatMemory, and the
removal of PromptChatMemoryAdvisor.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ja4jkzrbQ4LQZBNrb5mkZE
This commit is contained in:
Claude
2026-09-24 15:56:47 +00:00
parent c1e36c6c09
commit 823f6fac5b
65 changed files with 2548 additions and 0 deletions
@@ -0,0 +1,16 @@
# MessageWindowChatMemory, maxMessages = 4 and 5
maxMessages = 4. One system message, then one user+assistant turn at a time:
after turn 1: S:SYS-1 | U:u1 | A:a1
after turn 2: S:SYS-1 | U:u2 | A:a2
after turn 3: S:SYS-1 | U:u3 | A:a3
after turn 4: S:SYS-1 | U:u4 | A:a4
A second, different system message arrives:
after SYS-2: U:u4 | A:a4 | S:SYS-2
maxMessages = 5 (odd), no system message:
after turn 1: U:u1 | A:a1
after turn 2: U:u1 | A:a1 | U:u2 | A:a2
after turn 3: U:u2 | A:a2 | U:u3 | A:a3
after turn 4: U:u3 | A:a3 | U:u4 | A:a4
@@ -0,0 +1,19 @@
# MessageChatMemoryAdvisor: what the model is sent
conv-A, call 1: "My name is Priya"
reply: Nice to meet you, Priya.
model was sent: S:You are a terse assistant. | U:My name is Priya
conv-A, call 2: "What is my name?"
reply: Your name is Priya.
model was sent: S:You are a terse assistant. | U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name?
conv-B, call 1: "What is my name?" (different conversation ID)
reply: I do not know your name yet.
model was sent: S:You are a terse assistant. | U:What is my name?
Stored for conv-A afterwards:
USER My name is Priya
ASSISTANT Nice to meet you, Priya.
USER What is my name?
ASSISTANT Your name is Priya.
@@ -0,0 +1,5 @@
# No conversation ID on the request
chat.prompt().user("hello").call().content() // no advisors(...) param
threw java.lang.IllegalArgumentException
message: conversationId cannot be null
@@ -0,0 +1,5 @@
# ChatMemoryAutoConfiguration, nothing configured
ChatMemory bean: MessageWindowChatMemory
ChatMemoryRepository bean: InMemoryChatMemoryRepository
15 turns (30 messages) added -> 20 stored, oldest kept: u6
@@ -0,0 +1,7 @@
# Guessing a window-size property
spring.ai.chat.memory.max-messages = 6 -> 30 messages added, 20 stored
spring.ai.chat.memory.window-size = 6 -> 30 messages added, 20 stored
spring.ai.chat.memory.repository.max-messages = 6 -> 30 messages added, 20 stored
Spring Boot ignores unknown keys silently, so none of these did anything.
+18
View File
@@ -0,0 +1,18 @@
# Input tokens per call, 30 turns, four pruning strategies
Encoding: o200k_base (JTokkit estimate). Each turn: ~25-token question, ~40-token answer, ~6-token system prompt.
turn unbounded window=10 window=20 budget=400tok
1 26 26 26 26
2 79 79 79 79
5 238 238 238 238
10 503 291 503 397
11 556 291 556 397
15 768 291 556 397
20 1033 291 556 397
21 1086 291 556 397
25 1298 291 556 397
30 1563 291 556 397
sum 23835 7935 13765 10426
sum as % of unbounded: window=10 33%, window=20 58%, budget=400tok 44%
@@ -0,0 +1,4 @@
# TokenBudgetChatMemory, budget = 120 tokens
three short turns: S:Be terse. | U:short question 1 | A:short answer 1 | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3
then one pasted trace: 7 messages kept: S:Be terse. | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3 | U:<pasted stack trace, 89 tokens> | A:Check the datasource URL.
+16
View File
@@ -0,0 +1,16 @@
# JdbcChatMemoryRepository on PostgreSQL 16
Table created by initialize-schema=always:
conversation_id character varying (36)
content text
type character varying (10)
timestamp timestamp without time zone
sequence_id bigint
After one call, rows for the conversation:
seq=0 USER My name is Priya
seq=1 ASSISTANT Nice to meet you, Priya.
New repository + new memory over the same database, then "What is my name?":
reply: Your name is Priya.
findConversationIds() returns every conversation in the table: [6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10]
@@ -0,0 +1,7 @@
# conversation_id is VARCHAR(36)
conversation ID: "user:[email protected]:thread:2026-09-24" (43 characters)
model calls made for this request: 0 (the user message is saved before the model is called)
thrown: org.springframework.dao.DataIntegrityViolationException
root cause: ERROR: value too long for type character varying(36)
rows stored for it afterwards: 0
@@ -0,0 +1,13 @@
# What JdbcChatMemoryRepository.saveAll keeps
saveAll() given 4 messages:
USER Weather in Mumbai?
ASSISTANT (tool call weather)
TOOL (tool call weather)
ASSISTANT 31C and humid.
findByConversationId() returns 2:
USER Weather in Mumbai?
ASSISTANT 31C and humid.
Logged by the repository: WARN JdbcChatMemoryRepository does not support tool call messages. Some messages were filtered out for conversation: tool-conv
@@ -0,0 +1,8 @@
# Two concurrent add() calls on one conversation
seeded with: u0, a0
two writers each add one message; both read the conversation before either writes it back
first writer to save: adds a 3-message list, saved
second writer to save: adds a different 3-message list, replaces the first
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
the first writer's message is GONE, the second writer's is kept
@@ -0,0 +1,7 @@
# Two saveAll() calls released at the same instant
seeded with u0, a0; two writers add from-A and from-B; both saveAll() calls start together
The outcome differs from run to run, so the exact count is not printed here. Across repeated runs on
PostgreSQL 16 (READ COMMITTED) it is one of two things, and never the 4 messages that were sent:
3 messages -> one writer's message is gone
6 messages -> both transactions inserted, history is duplicated, sequence_id values collide
@@ -0,0 +1,16 @@
# A Spring AI 1.x table under the 2.0 repository
A 1.1.x-shaped table (no sequence_id column) holds 2 rows for "old-conv".
2.0.1 repository, findByConversationId("old-conv"):
ERROR: column "sequence_id" does not exist
Migration: add the column, backfill it from the timestamp order, then make it NOT NULL:
ALTER TABLE SPRING_AI_CHAT_MEMORY ADD COLUMN sequence_id BIGINT
UPDATE SPRING_AI_CHAT_MEMORY m SET sequence_id = s.rn FROM (SELECT ctid AS c, row_number() OVER (PARTITION ...
ALTER TABLE SPRING_AI_CHAT_MEMORY ALTER COLUMN sequence_id SET NOT NULL
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_SEQUENCE_ID_IDX ON SPRING_AI_CHAT_MEMORY(c...
findByConversationId("old-conv") after the migration:
USER My name is Priya
ASSISTANT Nice to meet you, Priya.
@@ -0,0 +1,4 @@
# Same two writers, per-conversation lock
stored afterwards: 4 messages
contains from-A: true, from-B: true
@@ -0,0 +1,16 @@
# RedisChatMemoryRepository on Redis Stack
ChatMemoryRepository bean: RedisChatMemoryRepository
call 1 reply: Nice to meet you, Priya.
Keys under the default prefix "chat-memory:" (3: one JSON document per message, plus a counter):
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
chat-memory:counter:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10
Fields of one document: [content, conversation_id, metadata, timestamp, type]
TTL on each key (spring.ai.chat.memory.repository.redis.time-to-live=24h): between 86390 and 86400 s -> true
Same conversation, second call "What is my name?":
reply: Your name is Priya.
stored: U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name? | A:Your name is Priya.
@@ -0,0 +1,4 @@
# Two concurrent add() calls, Redis repository
seeded with u0, a0; two writers add one message each, both read before either writes back
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
@@ -0,0 +1,11 @@
# Redis starter + your own ChatMemory bean
application has: spring-ai-starter-model-chat-memory-repository-redis, profile "redis", Redis Stack reachable,
and its own @Bean ChatMemory (needed to set a window other than 20)
ChatMemoryRepository bean in the context: InMemoryChatMemoryRepository
Redis keys under "chat-memory:" before / after one chat call: 0 / 0
Condition report for the autoconfigured Redis repository bean:
@ConditionalOnMissingBean (types: org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository,org.springframework.ai.chat.memory.ChatMemory,org.springframework.ai.chat.memory.ChatMemoryRepository; SearchStrategy: all) found beans of type 'org.springframework.ai.chat.memory.ChatMemory' chatMemory
WARN/ERROR log lines mentioning Redis or ChatMemory, captured over the whole test class: 0
@@ -0,0 +1,8 @@
# Redis time-to-live = 2s, max-messages-per-conversation = 4
saved 2 messages; readable now: U:u1 | A:a1
3 seconds later: (gone: Redis expired the keys)
saved 6 messages with max-messages-per-conversation=4, read back 4:
U:u1 | A:a1 | U:u2 | A:a2
keys in Redis for it: 7
@@ -0,0 +1,6 @@
# RedisChatMemoryRepository against plain Redis 7.0 (no modules)
server: redis_version:7.0.15
RedisChatMemoryRepository.builder()...initializeSchema(true).build() threw:
java.lang.IllegalStateException
root cause: JedisDataException: ERR unknown command 'FT._LIST', with args beginning with:
@@ -0,0 +1,7 @@
# app.ownership.enforce=true (default)
alice starts a conversation and says "My name is Priya"
bob: GET /conversations/<alice's id>/messages -> 403
bob: POST /conversations/<alice's id>/messages -> 403
alice: GET /conversations/<her id>/messages -> 200, 2 messages
@@ -0,0 +1,7 @@
# app.ownership.enforce=false
alice starts a conversation and says "My name is Priya"
bob: GET /conversations/<alice's id>/messages -> 200
body: [{"role":"USER","text":"My name is Priya"},{"role":"ASSISTANT","text":"Nice to meet you, Priya."}]
bob: POST "What is my name?" -> 200 {"reply":"Your name is Priya."}
+11
View File
@@ -0,0 +1,11 @@
# Do the chat-memory property keys bind?
spring.ai.chat.memory.repository.jdbc.initialize-schema=always -> initializeSchema = ALWAYS
redis.host / port / time-to-live / max-messages-per-conversation = cache.internal / 6390 / PT24H / 200
Three guesses, each of which looks right and binds nothing:
spring.ai.chat.memory.redis.port=6390 (no "repository" segment)
spring.ai.chat.memory.repository.redis.ttl=24h (the key is time-to-live)
spring.data.redis.port=6390 (Spring Data Redis's key; this repository builds its own Jedis client)
-> port = 6379, time-to-live = null
@@ -0,0 +1,21 @@
# PromptChatMemoryAdvisor: Spring AI 1.1.8 vs 2.0.1
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 2.0.1
PromptAdvisorFrom1x.java:3: error: cannot find symbol
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
^
symbol: class PromptChatMemoryAdvisor
location: package org.springframework.ai.chat.client.advisor
PromptAdvisorFrom1x.java:10: error: cannot find symbol
return PromptChatMemoryAdvisor.builder(chatMemory).build();
^
symbol: variable PromptChatMemoryAdvisor
location: class PromptAdvisorFrom1x
2 errors
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 1.1.8 first
PromptAdvisorFrom1x.java:10: warning: [removal] PromptChatMemoryAdvisor in org.springframework.ai.chat.client.advisor has been deprecated and marked for removal
return PromptChatMemoryAdvisor.builder(chatMemory).build();
^
1 warning
compiled: PromptAdvisorFrom1x.class
@@ -0,0 +1,15 @@
# MessageChatMemoryAdvisor + ToolCallingAdvisor
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + 200
ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + 300
answer: It is 31C in Mumbai.
model calls: 2
What the model was sent on each call:
call 1: U:Weather in Mumbai?
call 2: U:Weather in Mumbai? | A:[tool call currentWeather] | T:[tool result "31C in Mumbai"]
Stored in memory afterwards:
USER Weather in Mumbai?
ASSISTANT It is 31C in Mumbai.