Skip to content

Commit 6f99d2e

Browse files
phernandezclaude
andcommitted
perf: lightweight permalink resolution to avoid eager loading
Add optimized repository methods for resolve_permalink() that skip eager loading of observations and relations: - permalink_exists(): Check existence without loading entity - get_file_path_for_permalink(): Get only file_path column - get_permalink_for_file_path(): Get only permalink column - get_all_permalinks(): Get all permalinks as strings - get_permalink_to_file_path_map(): Bulk lookup mapping - get_file_path_to_permalink_map(): Reverse mapping Updated entity_service.resolve_permalink() to use these lightweight methods instead of loading full entities with all relationships. Also added logfire instrumentation to markdown utils. 🤖 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 73d940e commit 6f99d2e

7 files changed

Lines changed: 317 additions & 7 deletions

File tree

src/basic_memory/markdown/entity_parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
Relation,
2323
)
2424
from basic_memory.utils import parse_tags
25+
import logfire
2526

2627
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
2728

2829

30+
@logfire.instrument()
2931
def normalize_frontmatter_value(value: Any) -> Any:
3032
"""Normalize frontmatter values to safe types for processing.
3133
@@ -87,6 +89,7 @@ def normalize_frontmatter_value(value: Any) -> Any:
8789
return value
8890

8991

92+
@logfire.instrument()
9093
def normalize_frontmatter_metadata(metadata: dict) -> dict:
9194
"""Normalize all values in frontmatter metadata dict.
9295
@@ -109,6 +112,7 @@ class EntityContent:
109112
relations: list[Relation] = field(default_factory=list)
110113

111114

115+
@logfire.instrument()
112116
def parse(content: str) -> EntityContent:
113117
"""Parse markdown content into EntityMarkdown."""
114118

@@ -167,6 +171,7 @@ def parse_date(self, value: Any) -> Optional[datetime]:
167171
return parsed
168172
return None
169173

174+
@logfire.instrument()
170175
async def parse_file(self, path: Path | str) -> EntityMarkdown:
171176
"""Parse markdown file into EntityMarkdown."""
172177

@@ -188,6 +193,7 @@ def get_file_path(self, path):
188193
"""Get absolute path for a file using the base path for the project."""
189194
return self.base_path / path
190195

196+
@logfire.instrument()
191197
async def parse_file_content(self, absolute_path, file_content):
192198
"""Parse markdown content from file stats.
193199
@@ -205,6 +211,7 @@ async def parse_file_content(self, absolute_path, file_content):
205211
ctime=file_stats.st_ctime,
206212
)
207213

214+
@logfire.instrument()
208215
async def parse_markdown_content(
209216
self,
210217
file_path: Path,

src/basic_memory/markdown/markdown_processor.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from frontmatter import Post
66
from loguru import logger
7+
import logfire
78

89
from basic_memory import file_utils
910
from basic_memory.file_utils import dump_frontmatter
@@ -39,6 +40,7 @@ def __init__(self, entity_parser: EntityParser):
3940
"""Initialize processor with base path and parser."""
4041
self.entity_parser = entity_parser
4142

43+
@logfire.instrument()
4244
async def read_file(self, path: Path) -> EntityMarkdown:
4345
"""Read and parse file into EntityMarkdown schema.
4446
@@ -47,6 +49,7 @@ async def read_file(self, path: Path) -> EntityMarkdown:
4749
"""
4850
return await self.entity_parser.parse_file(path)
4951

52+
@logfire.instrument()
5053
async def write_file(
5154
self,
5255
path: Path,
@@ -124,6 +127,7 @@ async def write_file(
124127
await file_utils.write_file_atomic(path, final_content)
125128
return await file_utils.compute_checksum(final_content)
126129

130+
@logfire.instrument()
127131
def format_observations(self, observations: list[Observation]) -> str:
128132
"""Format observations section in standard way.
129133
@@ -132,6 +136,7 @@ def format_observations(self, observations: list[Observation]) -> str:
132136
lines = [f"{obs}" for obs in observations]
133137
return "\n".join(lines) + "\n"
134138

139+
@logfire.instrument()
135140
def format_relations(self, relations: list[Relation]) -> str:
136141
"""Format relations section in standard way.
137142

src/basic_memory/markdown/plugins.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def is_observation(token: Token) -> bool:
3232
match = re.match(r"^\[([^\[\]()]+)\]\s+(.+)", content)
3333
# Check for standalone hashtags (words starting with #)
3434
# This excludes # in HTML attributes like color="#4285F4"
35-
has_tags = any(part.startswith('#') for part in content.split())
35+
has_tags = any(part.startswith("#") for part in content.split())
3636
return bool(match) or has_tags
3737

3838

src/basic_memory/markdown/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from pathlib import Path
44
from typing import Any, Optional
5+
import logfire
56

67
from frontmatter import Post
78

@@ -11,6 +12,7 @@
1112
from basic_memory.models import Observation as ObservationModel
1213

1314

15+
@logfire.instrument()
1416
def entity_model_from_markdown(
1517
file_path: Path, markdown: EntityMarkdown, entity: Optional[Entity] = None
1618
) -> Entity:
@@ -64,6 +66,7 @@ def entity_model_from_markdown(
6466
return model
6567

6668

69+
@logfire.instrument()
6770
async def schema_to_markdown(schema: Any) -> Post:
6871
"""
6972
Convert schema to markdown Post object.

src/basic_memory/repository/entity_repository.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,105 @@ async def get_by_file_path(self, file_path: Union[Path, str]) -> Optional[Entity
8181
)
8282
return await self.find_one(query)
8383

84+
# -------------------------------------------------------------------------
85+
# Lightweight methods for permalink resolution (no eager loading)
86+
# -------------------------------------------------------------------------
87+
88+
@logfire.instrument()
89+
async def permalink_exists(self, permalink: str) -> bool:
90+
"""Check if a permalink exists without loading the full entity.
91+
92+
This is much faster than get_by_permalink() as it skips eager loading
93+
of observations and relations. Use for existence checks in bulk operations.
94+
95+
Args:
96+
permalink: Permalink to check
97+
98+
Returns:
99+
True if permalink exists, False otherwise
100+
"""
101+
query = select(Entity.id).where(Entity.permalink == permalink).limit(1)
102+
query = self._add_project_filter(query)
103+
result = await self.execute_query(query, use_query_options=False)
104+
return result.scalar_one_or_none() is not None
105+
106+
@logfire.instrument()
107+
async def get_file_path_for_permalink(self, permalink: str) -> Optional[str]:
108+
"""Get the file_path for a permalink without loading the full entity.
109+
110+
Use when you only need the file_path, not the full entity with relations.
111+
112+
Args:
113+
permalink: Permalink to look up
114+
115+
Returns:
116+
file_path string if found, None otherwise
117+
"""
118+
query = select(Entity.file_path).where(Entity.permalink == permalink)
119+
query = self._add_project_filter(query)
120+
result = await self.execute_query(query, use_query_options=False)
121+
return result.scalar_one_or_none()
122+
123+
@logfire.instrument()
124+
async def get_permalink_for_file_path(self, file_path: Union[Path, str]) -> Optional[str]:
125+
"""Get the permalink for a file_path without loading the full entity.
126+
127+
Use when you only need the permalink, not the full entity with relations.
128+
129+
Args:
130+
file_path: File path to look up
131+
132+
Returns:
133+
permalink string if found, None otherwise
134+
"""
135+
query = select(Entity.permalink).where(Entity.file_path == Path(file_path).as_posix())
136+
query = self._add_project_filter(query)
137+
result = await self.execute_query(query, use_query_options=False)
138+
return result.scalar_one_or_none()
139+
140+
@logfire.instrument()
141+
async def get_all_permalinks(self) -> List[str]:
142+
"""Get all permalinks for this project.
143+
144+
Optimized for bulk operations - returns only permalink strings
145+
without loading entities or relationships.
146+
147+
Returns:
148+
List of all permalinks in the project
149+
"""
150+
query = select(Entity.permalink)
151+
query = self._add_project_filter(query)
152+
result = await self.execute_query(query, use_query_options=False)
153+
return list(result.scalars().all())
154+
155+
@logfire.instrument()
156+
async def get_permalink_to_file_path_map(self) -> dict[str, str]:
157+
"""Get a mapping of permalink -> file_path for all entities.
158+
159+
Optimized for bulk permalink resolution - loads minimal data in one query.
160+
161+
Returns:
162+
Dict mapping permalink to file_path
163+
"""
164+
query = select(Entity.permalink, Entity.file_path)
165+
query = self._add_project_filter(query)
166+
result = await self.execute_query(query, use_query_options=False)
167+
return {row.permalink: row.file_path for row in result.all()}
168+
169+
@logfire.instrument()
170+
async def get_file_path_to_permalink_map(self) -> dict[str, str]:
171+
"""Get a mapping of file_path -> permalink for all entities.
172+
173+
Optimized for bulk permalink resolution - loads minimal data in one query.
174+
175+
Returns:
176+
Dict mapping file_path to permalink
177+
"""
178+
query = select(Entity.file_path, Entity.permalink)
179+
query = self._add_project_filter(query)
180+
result = await self.execute_query(query, use_query_options=False)
181+
return {row.file_path: row.permalink for row in result.all()}
182+
84183
@logfire.instrument()
85184
async def get_by_file_paths(
86185
self, session: AsyncSession, file_paths: Sequence[Union[Path, str]]

src/basic_memory/services/entity_service.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ async def resolve_permalink(
109109
4. Generate new unique permalink from file path
110110
111111
Enhanced to detect and handle character-related conflicts.
112+
113+
Note: Uses lightweight repository methods that skip eager loading of
114+
observations and relations for better performance during bulk operations.
112115
"""
113116
file_path_str = Path(file_path).as_posix()
114117

@@ -125,16 +128,20 @@ async def resolve_permalink(
125128
# If markdown has explicit permalink, try to validate it
126129
if markdown and markdown.frontmatter.permalink:
127130
desired_permalink = markdown.frontmatter.permalink
128-
existing = await self.repository.get_by_permalink(desired_permalink)
131+
# Use lightweight method - we only need to check file_path
132+
existing_file_path = await self.repository.get_file_path_for_permalink(
133+
desired_permalink
134+
)
129135

130136
# If no conflict or it's our own file, use as is
131-
if not existing or existing.file_path == file_path_str:
137+
if not existing_file_path or existing_file_path == file_path_str:
132138
return desired_permalink
133139

134140
# For existing files, try to find current permalink
135-
existing = await self.repository.get_by_file_path(file_path_str)
136-
if existing:
137-
return existing.permalink
141+
# Use lightweight method - we only need the permalink
142+
existing_permalink = await self.repository.get_permalink_for_file_path(file_path_str)
143+
if existing_permalink:
144+
return existing_permalink
138145

139146
# New file - generate permalink
140147
if markdown and markdown.frontmatter.permalink:
@@ -143,9 +150,10 @@ async def resolve_permalink(
143150
desired_permalink = generate_permalink(file_path_str)
144151

145152
# Make unique if needed - enhanced to handle character conflicts
153+
# Use lightweight existence check instead of loading full entity
146154
permalink = desired_permalink
147155
suffix = 1
148-
while await self.repository.get_by_permalink(permalink):
156+
while await self.repository.permalink_exists(permalink):
149157
permalink = f"{desired_permalink}-{suffix}"
150158
suffix += 1
151159
logger.debug(f"creating unique permalink: {permalink}")

0 commit comments

Comments
 (0)