Skip to content

Commit b5d4fb5

Browse files
phernandezclaude
andcommitted
fix: postgres/neon connection settings and search index dedupe
- Reduce db_pool_recycle from 3600s to 180s for Neon scale-to-zero - Add connect_args for Neon serverless (statement cache, timeouts, app name) - Dedupe observation permalinks in search indexing to avoid unique constraint violations - Add tests for duplicate observation permalink handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 8307752 commit b5d4fb5

4 files changed

Lines changed: 205 additions & 4 deletions

File tree

src/basic_memory/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ class BasicMemoryConfig(BaseSettings):
112112
gt=0,
113113
)
114114
db_pool_recycle: int = Field(
115-
default=3600,
116-
description="Recycle connections after N seconds to prevent stale connections (Postgres only)",
115+
default=180,
116+
description="Recycle connections after N seconds to prevent stale connections. Default 180s works well with Neon's ~5 minute scale-to-zero (Postgres only)",
117117
gt=0,
118118
)
119119

src/basic_memory/db.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,13 +201,27 @@ def _create_postgres_engine(db_url: str, config: BasicMemoryConfig) -> AsyncEngi
201201
Configured async engine for Postgres
202202
"""
203203
# Postgres with asyncpg - pool sized for concurrent operations
204+
# connect_args tuned for Neon serverless which scales to zero after ~5 minutes
204205
engine = create_async_engine(
205206
db_url,
206207
echo=False,
207208
pool_pre_ping=True, # Verify connections before using them
208209
pool_size=config.db_pool_size,
209210
max_overflow=config.db_pool_overflow,
210211
pool_recycle=config.db_pool_recycle,
212+
connect_args={
213+
# Disable statement cache to avoid issues with prepared statements on reconnect
214+
"statement_cache_size": 0,
215+
# Allow 10s for commands (Neon cold start can take 2-5s)
216+
"command_timeout": 10,
217+
# Allow 10s for initial connection (Neon wake-up time)
218+
"timeout": 10,
219+
"server_settings": {
220+
"application_name": "basic-memory",
221+
# Statement timeout for queries (10s to allow for cold start)
222+
"statement_timeout": "10s",
223+
},
224+
},
211225
)
212226
logger.debug(
213227
f"Created Postgres engine with pool_size={config.db_pool_size}, "

src/basic_memory/services/search_service.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -284,8 +284,16 @@ async def index_entity_markdown(
284284
)
285285
)
286286

287-
# Add observation rows
287+
# Add observation rows - dedupe by permalink to avoid unique constraint violations
288+
# Two observations with same entity/category/content generate identical permalinks
289+
seen_permalinks: set[str] = {entity.permalink} if entity.permalink else set()
288290
for obs in entity.observations:
291+
obs_permalink = obs.permalink
292+
if obs_permalink in seen_permalinks:
293+
logger.debug(f"Skipping duplicate observation permalink: {obs_permalink}")
294+
continue
295+
seen_permalinks.add(obs_permalink)
296+
289297
# Index with parent entity's file path since that's where it's defined
290298
obs_content_stems = "\n".join(
291299
p for p in self._generate_variants(obs.content) if p and p.strip()
@@ -297,7 +305,7 @@ async def index_entity_markdown(
297305
title=f"{obs.category}: {obs.content[:100]}...",
298306
content_stems=obs_content_stems,
299307
content_snippet=obs.content,
300-
permalink=obs.permalink,
308+
permalink=obs_permalink,
301309
file_path=entity.file_path,
302310
category=obs.category,
303311
entity_id=entity.id,

tests/services/test_search_service.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,3 +755,182 @@ async def test_search_title_via_repository_direct(search_service, session_maker,
755755
# Should find the entity without throwing FTS5 syntax errors
756756
assert len(results) >= 1
757757
assert any(result.title == "Note (with parentheses)" for result in results)
758+
759+
760+
# Tests for duplicate observation permalink deduplication
761+
762+
763+
@pytest.mark.asyncio
764+
async def test_index_entity_with_duplicate_observations(
765+
search_service, session_maker, test_project
766+
):
767+
"""Test that indexing an entity with duplicate observations doesn't cause unique constraint violations.
768+
769+
Two observations with the same category and content generate identical permalinks,
770+
which would violate the unique constraint on the search_index table.
771+
"""
772+
from basic_memory.repository import EntityRepository, ObservationRepository
773+
from unittest.mock import AsyncMock
774+
from datetime import datetime
775+
776+
entity_repo = EntityRepository(session_maker, project_id=test_project.id)
777+
obs_repo = ObservationRepository(session_maker, project_id=test_project.id)
778+
779+
# Create entity
780+
entity_data = {
781+
"title": "Entity With Duplicate Observations",
782+
"entity_type": "note",
783+
"entity_metadata": {},
784+
"content_type": "text/markdown",
785+
"file_path": "test/duplicate-obs.md",
786+
"permalink": "test/duplicate-obs",
787+
"project_id": test_project.id,
788+
"created_at": datetime.now(),
789+
"updated_at": datetime.now(),
790+
}
791+
792+
entity = await entity_repo.create(entity_data)
793+
794+
# Create duplicate observations - same category and content
795+
duplicate_content = "This is a duplicated observation"
796+
await obs_repo.create(
797+
{"entity_id": entity.id, "category": "note", "content": duplicate_content}
798+
)
799+
await obs_repo.create(
800+
{"entity_id": entity.id, "category": "note", "content": duplicate_content}
801+
)
802+
803+
# Reload entity with observations (get_by_permalink eagerly loads observations)
804+
entity = await entity_repo.get_by_permalink("test/duplicate-obs")
805+
806+
# Verify we have duplicate observations
807+
assert len(entity.observations) == 2
808+
assert entity.observations[0].permalink == entity.observations[1].permalink
809+
810+
# Mock file service to avoid file I/O
811+
search_service.file_service.read_entity_content = AsyncMock(return_value="")
812+
813+
# This should not raise a unique constraint violation
814+
await search_service.index_entity(entity)
815+
816+
# Verify entity is searchable
817+
results = await search_service.search(SearchQuery(text="Duplicate Observations"))
818+
assert len(results) >= 1
819+
assert any(r.title == "Entity With Duplicate Observations" for r in results)
820+
821+
822+
@pytest.mark.asyncio
823+
async def test_index_entity_dedupes_observations_by_permalink(
824+
search_service, session_maker, test_project
825+
):
826+
"""Test that only unique observation permalinks are indexed.
827+
828+
When an entity has observations with identical permalinks, only the first one
829+
should be indexed to avoid unique constraint violations.
830+
"""
831+
from basic_memory.repository import EntityRepository, ObservationRepository
832+
from unittest.mock import AsyncMock
833+
from datetime import datetime
834+
835+
entity_repo = EntityRepository(session_maker, project_id=test_project.id)
836+
obs_repo = ObservationRepository(session_maker, project_id=test_project.id)
837+
838+
# Create entity
839+
entity_data = {
840+
"title": "Dedupe Test Entity",
841+
"entity_type": "note",
842+
"entity_metadata": {},
843+
"content_type": "text/markdown",
844+
"file_path": "test/dedupe-test.md",
845+
"permalink": "test/dedupe-test",
846+
"project_id": test_project.id,
847+
"created_at": datetime.now(),
848+
"updated_at": datetime.now(),
849+
}
850+
851+
entity = await entity_repo.create(entity_data)
852+
853+
# Create three observations: two duplicates and one unique
854+
duplicate_content = "Duplicate observation content"
855+
unique_content = "Unique observation content"
856+
857+
await obs_repo.create(
858+
{"entity_id": entity.id, "category": "note", "content": duplicate_content}
859+
)
860+
await obs_repo.create(
861+
{"entity_id": entity.id, "category": "note", "content": duplicate_content}
862+
)
863+
await obs_repo.create({"entity_id": entity.id, "category": "note", "content": unique_content})
864+
865+
# Reload entity with observations (get_by_permalink eagerly loads observations)
866+
entity = await entity_repo.get_by_permalink("test/dedupe-test")
867+
assert len(entity.observations) == 3
868+
869+
# Mock file service to avoid file I/O
870+
search_service.file_service.read_entity_content = AsyncMock(return_value="")
871+
872+
# Index the entity
873+
await search_service.index_entity(entity)
874+
875+
# Search for the unique observation - should find it
876+
results = await search_service.search(SearchQuery(text="Unique observation"))
877+
assert len(results) >= 1
878+
879+
# Search for duplicate observation - should find it (only one indexed)
880+
results = await search_service.search(SearchQuery(text="Duplicate observation"))
881+
assert len(results) >= 1
882+
883+
884+
@pytest.mark.asyncio
885+
async def test_index_entity_multiple_categories_same_content(
886+
search_service, session_maker, test_project
887+
):
888+
"""Test that observations with same content but different categories are not deduped.
889+
890+
The permalink includes the category, so observations with different categories
891+
but same content should have different permalinks and both be indexed.
892+
"""
893+
from basic_memory.repository import EntityRepository, ObservationRepository
894+
from unittest.mock import AsyncMock
895+
from datetime import datetime
896+
897+
entity_repo = EntityRepository(session_maker, project_id=test_project.id)
898+
obs_repo = ObservationRepository(session_maker, project_id=test_project.id)
899+
900+
# Create entity
901+
entity_data = {
902+
"title": "Multi Category Entity",
903+
"entity_type": "note",
904+
"entity_metadata": {},
905+
"content_type": "text/markdown",
906+
"file_path": "test/multi-category.md",
907+
"permalink": "test/multi-category",
908+
"project_id": test_project.id,
909+
"created_at": datetime.now(),
910+
"updated_at": datetime.now(),
911+
}
912+
913+
entity = await entity_repo.create(entity_data)
914+
915+
# Create observations with same content but different categories
916+
shared_content = "Shared content across categories"
917+
await obs_repo.create({"entity_id": entity.id, "category": "tech", "content": shared_content})
918+
await obs_repo.create({"entity_id": entity.id, "category": "design", "content": shared_content})
919+
920+
# Reload entity with observations (get_by_permalink eagerly loads observations)
921+
entity = await entity_repo.get_by_permalink("test/multi-category")
922+
assert len(entity.observations) == 2
923+
924+
# Verify permalinks are different due to different categories
925+
permalinks = {obs.permalink for obs in entity.observations}
926+
assert len(permalinks) == 2 # Should be 2 unique permalinks
927+
928+
# Mock file service to avoid file I/O
929+
search_service.file_service.read_entity_content = AsyncMock(return_value="")
930+
931+
# Index the entity - both should be indexed since permalinks differ
932+
await search_service.index_entity(entity)
933+
934+
# Search for the shared content - should find both observations
935+
results = await search_service.search(SearchQuery(text="Shared content"))
936+
assert len(results) >= 2

0 commit comments

Comments
 (0)