Remove old docs
This commit is contained in:
@@ -1,199 +0,0 @@
|
||||
# Search API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
MusicFS provides two search interfaces:
|
||||
1. **FUSE Virtual Directory** - `/.search/query/` for file manager integration
|
||||
2. **gRPC API** - `Search` and `SearchStream` RPCs for programmatic access (planned)
|
||||
|
||||
---
|
||||
|
||||
## FUSE Search Interface
|
||||
|
||||
### Endpoint: `/.search/{query}/`
|
||||
|
||||
Browse search results as symlinks in a virtual directory.
|
||||
|
||||
### Happy Path
|
||||
|
||||
1. User navigates to `/.search/metallica/`
|
||||
2. FUSE returns directory listing of symlinks
|
||||
3. Each symlink points to absolute path: `/mnt/music/Metallica/Album/Track.flac`
|
||||
4. User can open symlink directly in media player
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
$ ls -la /mnt/musicfs/.search/metallica/
|
||||
001. Metallica - Enter Sandman.flac -> /mnt/musicfs/Metallica/Black Album/Enter Sandman.flac
|
||||
002. Metallica - Battery.flac -> /mnt/musicfs/Metallica/Master of Puppets/Battery.flac
|
||||
```
|
||||
|
||||
### Error Cases
|
||||
|
||||
| Scenario | Behavior | FUSE Error |
|
||||
|----------|----------|------------|
|
||||
| Empty query | Empty directory | (none) |
|
||||
| No results | Empty directory | (none) |
|
||||
| Query too long (>256 chars) | Truncated | (none) |
|
||||
| Invalid UTF-8 in query | EINVAL | `libc::EINVAL` |
|
||||
| Index corrupted | ENOENT | `libc::ENOENT` |
|
||||
| Index writer shutdown | EIO | `libc::EIO` |
|
||||
|
||||
### Cache Behavior
|
||||
|
||||
- Results cached for 5 minutes (TTL)
|
||||
- Maximum 1000 cached queries (LRU eviction)
|
||||
- Cache miss triggers tantivy query
|
||||
|
||||
---
|
||||
|
||||
## gRPC Search API
|
||||
|
||||
> **Note:** gRPC API is planned for implementation. See architecture docs for design.
|
||||
|
||||
### `Search(SearchRequest) -> SearchResponse`
|
||||
|
||||
Single request/response search.
|
||||
|
||||
#### Request Schema
|
||||
|
||||
```protobuf
|
||||
message SearchRequest {
|
||||
string query = 1; // Required: tantivy query string
|
||||
optional uint32 limit = 2; // Default: 100, max: 10000
|
||||
optional uint32 offset = 3; // Default: 0, for pagination
|
||||
optional string origin_id = 4; // Filter by origin (optional)
|
||||
}
|
||||
```
|
||||
|
||||
#### Response Schema
|
||||
|
||||
```protobuf
|
||||
message SearchResponse {
|
||||
repeated SearchResult results = 1;
|
||||
uint64 total_matches = 2; // Approximate total
|
||||
uint32 query_time_ms = 3; // Query execution time
|
||||
}
|
||||
|
||||
message SearchResult {
|
||||
int64 file_id = 1;
|
||||
string virtual_path = 2;
|
||||
optional string artist = 3;
|
||||
optional string album = 4;
|
||||
optional string title = 5;
|
||||
float score = 6; // Relevance score
|
||||
map<string, string> highlights = 7; // Matched fragments
|
||||
}
|
||||
```
|
||||
|
||||
### Error Cases
|
||||
|
||||
| Scenario | gRPC Status | Details |
|
||||
|----------|-------------|---------|
|
||||
| Empty query | `INVALID_ARGUMENT` | "Query cannot be empty" |
|
||||
| Malformed query syntax | `INVALID_ARGUMENT` | tantivy parse error message |
|
||||
| limit > 10000 | `INVALID_ARGUMENT` | "Limit exceeds maximum (10000)" |
|
||||
| Index unavailable | `UNAVAILABLE` | "Search index not ready" |
|
||||
| Index corrupted | `INTERNAL` | "Search index corrupted" |
|
||||
| Timeout (>5s) | `DEADLINE_EXCEEDED` | Client-specified deadline |
|
||||
|
||||
---
|
||||
|
||||
## Query Syntax
|
||||
|
||||
MusicFS uses tantivy query syntax with custom fuzzy support.
|
||||
|
||||
### Supported Operators
|
||||
|
||||
| Operator | Example | Description |
|
||||
|----------|---------|-------------|
|
||||
| Term | `metallica` | Match in any default field |
|
||||
| Field | `artist:metallica` | Match specific field |
|
||||
| Phrase | `"enter sandman"` | Exact phrase match |
|
||||
| Fuzzy | `metalica~1` | 1-character edit distance |
|
||||
| Boolean | `metallica AND 1991` | Combine conditions |
|
||||
| Range | `year:[1980 TO 1989]` | Numeric range |
|
||||
|
||||
### Searchable Fields
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `artist` | TEXT | Full-text searchable, default field |
|
||||
| `album` | TEXT | Full-text searchable, default field |
|
||||
| `album_artist` | TEXT | Full-text searchable, default field |
|
||||
| `title` | TEXT | Full-text searchable, default field |
|
||||
| `genre` | TEXT | Full-text searchable, default field |
|
||||
| `composer` | TEXT | Full-text searchable, default field |
|
||||
| `year` | u64 | Range queries only |
|
||||
|
||||
### Fuzzy Query Implementation
|
||||
|
||||
Fuzzy queries use the `term~N` syntax where N is the maximum edit distance (0-2).
|
||||
|
||||
When a fuzzy query is detected:
|
||||
1. Query is parsed to extract term and distance
|
||||
2. `FuzzyTermQuery` is created for each default field
|
||||
3. Results are combined with `BooleanQuery` (OR semantics)
|
||||
|
||||
Example: `metalica~1` matches "Metallica" (edit distance 1).
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Target | Notes |
|
||||
|--------|--------|-------|
|
||||
| Query latency (1M tracks) | <500ms | tantivy optimized |
|
||||
| Index throughput | >1000 files/sec | Batch commits recommended |
|
||||
| Memory per 1M tracks | <500MB | mmap-based index |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Index Schema
|
||||
|
||||
```rust
|
||||
pub struct SearchSchema {
|
||||
file_id: Field, // INDEXED | STORED - for deletion
|
||||
virtual_path: Field, // STORED - symlink target
|
||||
artist: Field, // TEXT | STORED
|
||||
album: Field, // TEXT | STORED
|
||||
album_artist: Field, // TEXT | STORED
|
||||
title: Field, // TEXT | STORED
|
||||
genre: Field, // TEXT | STORED
|
||||
composer: Field, // TEXT | STORED
|
||||
year: Field, // INDEXED | STORED
|
||||
duration_ms: Field, // STORED
|
||||
bitrate: Field, // STORED
|
||||
sample_rate: Field, // STORED
|
||||
}
|
||||
```
|
||||
|
||||
### Writer Pattern
|
||||
|
||||
Uses `Arc<RwLock<IndexWriter>>` per tantivy best practices:
|
||||
- `add_document()` and `delete_term()` require READ lock
|
||||
- `commit()` requires WRITE lock
|
||||
- Single writer, multiple concurrent indexers
|
||||
|
||||
### Event Integration
|
||||
|
||||
The `Indexer` subscribes to `EventBus` for:
|
||||
- `FileAdded` - Index new file via `MetadataLookup`
|
||||
- `FileRemoved` - Remove from index by file_id
|
||||
- `FileModified` - Update index entry
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_search_basic` | Unit | Basic search returns results |
|
||||
| `test_search_fuzzy` | Unit | Typo tolerance (FR-14.3) |
|
||||
| `test_search_genre` | Unit | Field-specific search |
|
||||
| `test_index_persistence` | Unit | Index survives restart |
|
||||
| `test_remove_file` | Unit | Deletion works correctly |
|
||||
| `test_index_batch` | Unit | Batch indexing via Indexer |
|
||||
| `test_search_ops_*` | Unit | FUSE SearchOps integration |
|
||||
@@ -1,315 +0,0 @@
|
||||
# Smart Features API Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
MusicFS Week 9 introduces three intelligent features:
|
||||
1. **Smart Collections** - Dynamic playlists based on queries, time ranges, and listening patterns
|
||||
2. **Artwork Extraction & Caching** - Extract and serve album art in multiple sizes
|
||||
3. **Predictive Prefetching** - Learn listening patterns to preload likely-next tracks
|
||||
|
||||
---
|
||||
|
||||
## Smart Collections
|
||||
|
||||
### CollectionStore
|
||||
|
||||
Manages persistent smart collections using SQLite.
|
||||
|
||||
```rust
|
||||
pub struct CollectionStore {
|
||||
db: rusqlite::Connection,
|
||||
}
|
||||
|
||||
pub struct Collection {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub query: CollectionQuery,
|
||||
pub created_at: SystemTime,
|
||||
pub updated_at: SystemTime,
|
||||
}
|
||||
```
|
||||
|
||||
### CollectionQuery Types
|
||||
|
||||
| Query Type | Description | Example |
|
||||
|------------|-------------|---------|
|
||||
| `Match(String)` | tantivy search query | `"artist:Metallica"` |
|
||||
| `DateRange { start, end }` | Files added within range | Last 30 days |
|
||||
| `RecentlyAdded(days)` | Files added in last N days | `RecentlyAdded(7)` |
|
||||
| `RecentlyPlayed(days)` | Files played in last N days | `RecentlyPlayed(30)` |
|
||||
| `MostPlayed(limit)` | Top N most played tracks | `MostPlayed(100)` |
|
||||
| `Genre(String)` | All tracks matching genre | `"Progressive Rock"` |
|
||||
| `Compound(Vec)` | AND combination of queries | Multiple conditions |
|
||||
|
||||
### API
|
||||
|
||||
```rust
|
||||
impl CollectionStore {
|
||||
fn create(&self, name: &str, query: CollectionQuery) -> Result<i64, CollectionError>;
|
||||
fn get(&self, id: i64) -> Result<Option<Collection>, CollectionError>;
|
||||
fn list(&self) -> Result<Vec<Collection>, CollectionError>;
|
||||
fn update(&self, id: i64, name: &str, query: CollectionQuery) -> Result<(), CollectionError>;
|
||||
fn delete(&self, id: i64) -> Result<(), CollectionError>;
|
||||
fn evaluate(&self, id: i64, index: &SearchIndex, patterns: &PatternStore) -> Result<Vec<FileId>, CollectionError>;
|
||||
}
|
||||
```
|
||||
|
||||
### FUSE Integration (Planned)
|
||||
|
||||
Collections will appear as virtual directories under `/.collections/`:
|
||||
|
||||
```bash
|
||||
$ ls /mnt/musicfs/.collections/
|
||||
Recent Additions/
|
||||
Most Played/
|
||||
80s Metal/
|
||||
|
||||
$ ls /mnt/musicfs/.collections/Most\ Played/
|
||||
001. Track1.flac -> /mnt/musicfs/Artist/Album/Track1.flac
|
||||
002. Track2.flac -> /mnt/musicfs/Artist/Album/Track2.flac
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Artwork Extraction & Caching
|
||||
|
||||
### ArtworkExtractor
|
||||
|
||||
Extracts embedded artwork from audio files.
|
||||
|
||||
```rust
|
||||
pub struct Artwork {
|
||||
pub data: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub art_type: ArtType,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
pub enum ArtType {
|
||||
Front,
|
||||
Back,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub enum ArtSize {
|
||||
Thumbnail, // 150x150 max
|
||||
Medium, // 300x300 max
|
||||
Full, // Original size
|
||||
}
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
```rust
|
||||
impl ArtworkExtractor {
|
||||
fn extract(&self, path: &Path) -> Result<Vec<Artwork>, ArtworkError>;
|
||||
fn extract_first(&self, path: &Path) -> Result<Option<Artwork>, ArtworkError>;
|
||||
fn resize(data: &[u8], size: ArtSize) -> Result<Vec<u8>, ArtworkError>;
|
||||
}
|
||||
```
|
||||
|
||||
### ArtworkCache
|
||||
|
||||
Caches artwork in CAS (Content-Addressable Storage).
|
||||
|
||||
```rust
|
||||
impl ArtworkCache {
|
||||
async fn store(&self, file_id: i64, artwork: &Artwork) -> Result<ChunkHash, ArtworkError>;
|
||||
async fn get(&self, file_id: i64, art_type: &str, size: ArtSize) -> Result<Option<Vec<u8>>, ArtworkError>;
|
||||
async fn has(&self, file_id: i64, art_type: &str) -> Result<bool, ArtworkError>;
|
||||
}
|
||||
```
|
||||
|
||||
### Size Specifications
|
||||
|
||||
| Size | Max Dimension | Use Case |
|
||||
|------|---------------|----------|
|
||||
| Thumbnail | 150px | List views, grids |
|
||||
| Medium | 300px | Detail panels |
|
||||
| Full | Original | High-res display |
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
1. Original artwork stored in CAS with content hash
|
||||
2. SQLite maps `(file_id, art_type)` → `chunk_hash`
|
||||
3. Resizing performed on-demand, not cached (saves storage)
|
||||
4. Max input size: 10MB (reject larger images)
|
||||
|
||||
---
|
||||
|
||||
## Predictive Prefetching
|
||||
|
||||
### Access Patterns (PatternStore)
|
||||
|
||||
Tracks file access history to predict next tracks.
|
||||
|
||||
```rust
|
||||
pub struct AccessPattern {
|
||||
pub file_id: FileId,
|
||||
pub timestamp: SystemTime,
|
||||
pub context: AccessContext,
|
||||
pub hour_of_day: u8,
|
||||
}
|
||||
|
||||
pub struct AccessContext {
|
||||
pub album_id: Option<i64>,
|
||||
pub track_number: Option<u32>,
|
||||
pub artist: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern Learning
|
||||
|
||||
| Pattern Type | Description | Use Case |
|
||||
|--------------|-------------|----------|
|
||||
| Sequential | A → B → C transitions | Album playback |
|
||||
| Time-based | Hour-of-day preferences | Morning playlist |
|
||||
| Frequency | Most played tracks | Popular content |
|
||||
|
||||
### API
|
||||
|
||||
```rust
|
||||
impl PatternStore {
|
||||
fn record(&self, file_id: FileId, context: AccessContext) -> Result<(), PatternError>;
|
||||
fn predict_next(&self, current: FileId, limit: usize) -> Vec<FileId>;
|
||||
fn predict_for_time(&self, hour: u8, limit: usize) -> Vec<FileId>;
|
||||
fn recently_played(&self, days: u32) -> Result<Vec<FileId>, PatternError>;
|
||||
fn most_played(&self, limit: u32) -> Result<Vec<FileId>, PatternError>;
|
||||
}
|
||||
```
|
||||
|
||||
### PrefetchEngine
|
||||
|
||||
Background engine that listens for file access events and prefetches predicted content.
|
||||
|
||||
```rust
|
||||
pub struct PrefetchConfig {
|
||||
pub lookahead: usize, // How many tracks to prefetch (default: 3)
|
||||
pub max_concurrent: usize, // Concurrent prefetch limit (default: 2)
|
||||
pub cooldown: Duration, // Delay between prefetch bursts (default: 100ms)
|
||||
pub enabled: bool, // Master switch
|
||||
}
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ EventBus │────▶│ PrefetchEngine │────▶│ ContentFetcher │
|
||||
│ (FileAccessed) │ │ (predictions) │ │ (CAS storage) │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ PatternStore │
|
||||
│ (SQLite DB) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
### FUSE Interface
|
||||
|
||||
Virtual directory `/.prefetch/` exposes prefetch status and hints:
|
||||
|
||||
```bash
|
||||
$ cat /mnt/musicfs/.prefetch/status
|
||||
MusicFS Prefetch Status
|
||||
=======================
|
||||
running: true
|
||||
in_flight: 2
|
||||
most_played: [42, 57, 103, 89, 12]
|
||||
|
||||
$ ls /mnt/musicfs/.prefetch/
|
||||
status
|
||||
hint_0042
|
||||
hint_0057
|
||||
hint_0103
|
||||
|
||||
$ cat /mnt/musicfs/.prefetch/hint_0042
|
||||
57
|
||||
103
|
||||
89
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Feature | Metric | Target |
|
||||
|---------|--------|--------|
|
||||
| Collection evaluation | Latency | <100ms for 100k files |
|
||||
| Artwork extraction | Throughput | >10 files/sec |
|
||||
| Artwork resize | Latency | <50ms per image |
|
||||
| Pattern prediction | Latency | <10ms |
|
||||
| Prefetch hit rate | Accuracy | >70% for sequential play |
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### CollectionError
|
||||
|
||||
| Error | Description |
|
||||
|-------|-------------|
|
||||
| `Database(rusqlite::Error)` | SQLite operation failed |
|
||||
| `NotFound` | Collection ID doesn't exist |
|
||||
| `InvalidQuery` | Query failed to serialize |
|
||||
| `Search(SearchError)` | tantivy query failed |
|
||||
| `Pattern(PatternError)` | Pattern lookup failed |
|
||||
|
||||
### ArtworkError
|
||||
|
||||
| Error | Description |
|
||||
|-------|-------------|
|
||||
| `Database(rusqlite::Error)` | Cache DB operation failed |
|
||||
| `Cas(CasError)` | CAS storage operation failed |
|
||||
| `InvalidHash` | Stored hash is malformed |
|
||||
| `NotFound` | Artwork not in cache |
|
||||
| `ImageTooLarge(usize)` | Input exceeds 10MB limit |
|
||||
| `InvalidImage` | Cannot decode image data |
|
||||
| `ResizeFailed` | Image resize operation failed |
|
||||
|
||||
### PatternError
|
||||
|
||||
| Error | Description |
|
||||
|-------|-------------|
|
||||
| `Database(rusqlite::Error)` | SQLite operation failed |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Default Settings
|
||||
|
||||
```toml
|
||||
[prefetch]
|
||||
enabled = true
|
||||
lookahead = 3
|
||||
max_concurrent = 2
|
||||
cooldown_ms = 100
|
||||
|
||||
[artwork]
|
||||
max_input_size_mb = 10
|
||||
thumbnail_size = 150
|
||||
medium_size = 300
|
||||
|
||||
[patterns]
|
||||
max_history_days = 30
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_collection_crud` | Unit | Create, read, update, delete |
|
||||
| `test_collection_evaluate_match` | Unit | Match query evaluation |
|
||||
| `test_collection_persistence` | Unit | Collections survive restart |
|
||||
| `test_artwork_extract_flac` | Unit | FLAC artwork extraction |
|
||||
| `test_artwork_cache_store_get` | Unit | Cache round-trip |
|
||||
| `test_artwork_resize` | Unit | Resize produces valid output |
|
||||
| `test_pattern_prediction` | Unit | Sequential pattern learning |
|
||||
| `test_pattern_persistence` | Unit | Patterns survive restart |
|
||||
| `test_prefetch_config_defaults` | Unit | Default config values |
|
||||
| `test_prefetch_ops_*` | Unit | FUSE PrefetchOps integration |
|
||||
Vendored
-96
@@ -1,96 +0,0 @@
|
||||
# [Project Name]: Design Doc
|
||||
|
||||
**Authors:** [Author Name(s)]
|
||||
**Status:** [Draft / In-Review / Approved / Obsolete]
|
||||
**Last Updated:** YYYY-MM-DD
|
||||
**Reviewers:** [List of reviewers, usually @usernames]
|
||||
**Approvers:** [List of final decision makers]
|
||||
**Document Link:** [Link to this file or rendered version]
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
A high-level summary (1–3 paragraphs) of what the project is, what problem it solves, and the proposed solution. This should be readable by a non-expert.
|
||||
|
||||
## 2. Background
|
||||
Context for why this project exists.
|
||||
- What is the current state?
|
||||
- What are the pain points?
|
||||
- Are there existing systems that this will replace or interact with?
|
||||
- Include links to relevant PRDs (Product Requirement Documents) or previous design docs.
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
Clarity on scope is critical to prevent scope creep.
|
||||
|
||||
### 3.1. Goals
|
||||
* **Primary Goal:** The most important outcome.
|
||||
* Metric-driven goals (e.g., "Reduce latency by 20%").
|
||||
* Functional requirements (e.g., "Allow users to edit comments").
|
||||
|
||||
### 3.2. Non-Goals
|
||||
* Features that might seem related but are explicitly out of scope.
|
||||
* Future improvements that are deferred.
|
||||
|
||||
## 4. Proposed Design
|
||||
The "meat" of the document. Start with the high-level architecture and zoom in.
|
||||
|
||||
### 4.1. High-Level Architecture
|
||||
Provide a high-level diagram or description of how the system fits together.
|
||||
> *Tip: Use Mermaid.js or link to an embedded image.*
|
||||
|
||||
### 4.2. Detailed Design
|
||||
Go into specific components, APIs, and data models.
|
||||
* **API Definitions:** Describe new endpoints, Protobuf definitions, or CLI commands.
|
||||
* **Data Schema:** Database tables, key-value structures, or file formats.
|
||||
* **Workflows:** Step-by-step logic for complex operations (e.g., auth flow).
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
Google design docs place heavy emphasis on these "standard" reviews.
|
||||
|
||||
### 5.1. Security & Privacy
|
||||
- How is data encrypted?
|
||||
- What are the access control lists (ACLs)?
|
||||
- Does this handle PII (Personally Identifiable Information)?
|
||||
|
||||
### 5.2. Observability (Monitoring & Logging)
|
||||
- What metrics will be exported (e.g., RPC error rates, latency)?
|
||||
- What logging is required for debugging?
|
||||
- What are the "Golden Signals" for the dashboard?
|
||||
|
||||
### 5.3. Scalability & Performance
|
||||
- What are the expected QPS (Queries Per Second)?
|
||||
- How does the system scale (Horizontal vs. Vertical)?
|
||||
- What are the resource requirements (CPU, RAM, Storage)?
|
||||
|
||||
### 5.4. Testing Plan
|
||||
- Unit tests, integration tests, and end-to-end tests.
|
||||
- Strategy for load testing or "chaos" testing.
|
||||
|
||||
## 6. Alternatives Considered
|
||||
*A BlueDoc is not just about the chosen path, but why others were rejected.*
|
||||
* **Alternative A:** Briefly describe it and why it was rejected (e.g., "Too complex," "High latency").
|
||||
* **Alternative B:** Why "Doing Nothing" is not an option.
|
||||
|
||||
## 7. Implementation Plan
|
||||
- **Phase 1:** Minimum Viable Product (MVP).
|
||||
- **Phase 2:** Feature parity or migrations.
|
||||
- **Rollout/Rollback:** How will the feature be toggled? (e.g., feature flags).
|
||||
|
||||
## 8. Glossary / References
|
||||
- Links to external libraries.
|
||||
- Definitions for project-specific acronyms.
|
||||
|
||||
---
|
||||
|
||||
### Markdown Style Tips (Google Conventions):
|
||||
1. **Line Length:** Google's internal style guide suggests a soft limit of **80 characters** per line for source Markdown to make it easier to review in code-diff tools.
|
||||
2. **Headings:** Use `#` for title, `##` for sections, and `###` for subsections.
|
||||
3. **TOC:** If your environment supports it, use `[TOC]` at the top to generate a Table of Contents.
|
||||
4. **Diagrams:** Use **Mermaid** blocks if using GitHub/GitLab, otherwise link to a stable SVG/PNG.
|
||||
```mermaid
|
||||
graph TD;
|
||||
A-->B;
|
||||
A-->C;
|
||||
B-->D;
|
||||
C-->D;
|
||||
```
|
||||
Vendored
-56
@@ -1,56 +0,0 @@
|
||||
# [Project Name]: Design One-Pager (GreenDoc)
|
||||
|
||||
**Author(s):** [Name]
|
||||
**Status:** [Draft / Approved / Shipped]
|
||||
**Last Updated:** YYYY-MM-DD
|
||||
**Estimated Effort:** [e.g., 2 weeks, 1 sprint]
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
A 2–3 sentence overview of the change. What are you doing and why?
|
||||
|
||||
## 2. Problem Statement
|
||||
Describe the specific pain point or "broken" state this project addresses.
|
||||
* *Example: Currently, users cannot filter their search history by date, leading to high latency in manual lookup.*
|
||||
|
||||
## 3. Proposed Solution
|
||||
Explain the high-level logic of the fix or feature.
|
||||
* What is the specific code change or configuration update?
|
||||
* How does it interact with existing systems?
|
||||
* *Note: Use a single simple diagram if the logic is non-trivial.*
|
||||
|
||||
## 4. Risks & Trade-offs
|
||||
Even small changes have risks. Address them upfront.
|
||||
* **Performance:** Will this increase memory usage?
|
||||
* **Complexity:** Does this add a new dependency?
|
||||
* **Backwards Compatibility:** Will this break existing clients?
|
||||
* **Alternatives:** Why did you choose this over a "quicker" or "better" fix?
|
||||
|
||||
## 5. Success Criteria (Metrics)
|
||||
How will you know this worked?
|
||||
* [ ] Primary metric (e.g., "Feature usage > 5%")
|
||||
* [ ] Guardrail metric (e.g., "Latency does not increase by > 10ms")
|
||||
|
||||
## 6. Implementation & Rollout
|
||||
A brief bulleted list of the steps to ship.
|
||||
1. Feature flag implementation.
|
||||
2. Canary to 1% of users.
|
||||
3. Full rollout.
|
||||
|
||||
---
|
||||
|
||||
### Comparison: BlueDoc vs. GreenDoc
|
||||
|
||||
| Feature | BlueDoc (Standard) | GreenDoc (One-Pager) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Scope** | Major systems, new services. | Small features, optimizations, bug fixes. |
|
||||
| **Length** | 5–20+ pages. | 1–2 pages. |
|
||||
| **Review** | Cross-functional committees (SRE, Security). | Peer-level or Team Lead review. |
|
||||
| **Focus** | Long-term scalability and architecture. | Immediate impact and implementation. |
|
||||
|
||||
### Markdown Tips for GreenDocs:
|
||||
* **Be Brutally Concise:** If a section requires more than three paragraphs, consider upgrading to a **BlueDoc**.
|
||||
* **Checklists:** Use `[ ]` to show remaining work or requirements.
|
||||
* **Inline Links:** Link directly to the relevant code files or bug tracker (Jira/Buganizer) to keep the doc self-contained.
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# beetfs - Reverse Engineered Documentation
|
||||
|
||||
> **Status**: Archived project (2010-2013), Python 2, fuse-python API
|
||||
> **Fork**: git@github.com:LichHunter/beetfs.git
|
||||
> **Original**: https://github.com/jbaiter/beetfs
|
||||
|
||||
## Overview
|
||||
|
||||
beetfs is a FUSE filesystem that presents audio files with **metadata from a database** while **passing through audio data unchanged** from original files. This enables transparent metadata modification without touching the underlying files.
|
||||
|
||||
### The Core Concept
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION (VLC, Jellyfin, etc.) │
|
||||
│ │
|
||||
│ read("/mount/Artist/Album/track.flac") │
|
||||
└─────────────────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ beetfs (FUSE Layer) │
|
||||
│ ┌────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ FileHandler │ │
|
||||
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ if offset < header_boundary: │ │ │
|
||||
│ │ │ return MODIFIED_HEADER (from beets database) │ │ │
|
||||
│ │ │ else: │ │ │
|
||||
│ │ │ return ORIGINAL_AUDIO (from real file on disk) │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
┌───────────┘ └───────────┐
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ Beets Database │ │ Original File │
|
||||
│ (SQLite - tags) │ │ (untouched) │
|
||||
│ │ │ │
|
||||
│ title: "Fixed" │ │ [FLAC header] │
|
||||
│ artist: "Corr" │ │ [Audio frames] │
|
||||
│ album: "Right" │ │ │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Metadata Overlay** | Returns tags from database, not from file |
|
||||
| **Audio Passthrough** | Original audio data served unchanged |
|
||||
| **Write Interception** | Tag edits saved to database, not to file |
|
||||
| **Virtual Organization** | Presents files in template-based directory structure |
|
||||
| **Format Support** | FLAC (full), MP3 (partial - read-only) |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
beetfs/
|
||||
├── beetsplug/
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ └── beetFs.py # ALL code (~1144 lines)
|
||||
├── README.rst # Original readme
|
||||
└── COPYING # GPLv3 license
|
||||
```
|
||||
|
||||
## Quick Architecture Summary
|
||||
|
||||
| Component | Lines | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `beetFs` (plugin) | 188-191 | Beets plugin hook |
|
||||
| `mount()` | 119-183 | CLI entry point, builds virtual tree |
|
||||
| `FSNode` | 390-436 | Virtual directory tree node |
|
||||
| `FileHandler` | 439-565 | **CORE**: Metadata interpolation |
|
||||
| `InterpolatedFLAC` | 274-388 | FLAC header generation |
|
||||
| `InterpolatedID3` | 200-271 | ID3 tag generation (incomplete) |
|
||||
| `beetFileSystem` | 622-1144 | FUSE operations implementation |
|
||||
| `Stat` | 568-619 | File stat structure |
|
||||
|
||||
## Documentation Index
|
||||
|
||||
1. **[Architecture Overview](./architecture.md)** - System design and component interaction
|
||||
2. **[Components Deep Dive](./components.md)** - Detailed component analysis
|
||||
3. **[Data Flow](./data-flow.md)** - Read/write operation flows
|
||||
4. **[Performance Analysis](./analysis.md)** - Latency, memory footprint, I/O patterns
|
||||
5. **[Drawbacks & Limitations](./drawbacks.md)** - Known issues and missing features
|
||||
6. **[Modernization Guide](./modernization.md)** - Notes for updating to Python 3
|
||||
|
||||
## Critical Issues Summary
|
||||
|
||||
| Issue | Severity | Impact |
|
||||
|-------|----------|--------|
|
||||
| Full file loaded into RAM | 🔴 Critical | OOM on large libraries |
|
||||
| MP3 support disabled | 🔴 Critical | Only FLAC works |
|
||||
| Python 2 only | 🔴 Critical | EOL, security risk |
|
||||
| Single-threaded | 🟡 Major | Poor concurrency |
|
||||
| 4 of 17 metadata fields | 🟡 Major | Limited functionality |
|
||||
|
||||
See [drawbacks.md](./drawbacks.md) for complete list (27 identified issues).
|
||||
|
||||
## Dependencies (Original)
|
||||
|
||||
```
|
||||
beets >= 1.0
|
||||
fuse-python (Python 2 FUSE bindings)
|
||||
mutagen (audio metadata library)
|
||||
```
|
||||
|
||||
## Usage (Original)
|
||||
|
||||
```bash
|
||||
# As beets plugin
|
||||
beet mount /path/to/mountpoint
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPLv3 - See COPYING file
|
||||
@@ -1,263 +0,0 @@
|
||||
# beetfs Performance Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
beetfs has significant performance limitations due to its 2010-era design assumptions. The primary issues are **full file loading into RAM** and **blocking I/O on file open**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Latency Analysis
|
||||
|
||||
### Operation Latencies
|
||||
|
||||
| Operation | Time Complexity | Typical Latency | Notes |
|
||||
|-----------|-----------------|-----------------|-------|
|
||||
| **File Open** | O(file_size) | 50ms - 1s+ | Reads entire file into memory |
|
||||
| **File Read** | O(1) | <1ms | Pure memory slice |
|
||||
| **File Write** | O(file_size) | 100ms - 2s+ | Reconstructs + DB write |
|
||||
| **Directory List** | O(n) | <10ms | In-memory tree traversal |
|
||||
| **getattr** | O(depth) | <1ms | Tree navigation + stat |
|
||||
|
||||
### File Open Breakdown
|
||||
|
||||
The file open operation is the critical bottleneck:
|
||||
|
||||
```
|
||||
Time breakdown for opening 50MB FLAC file:
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 1. open() syscall │ ~1ms │
|
||||
│ 2. file_object.read() - load entire file │ ~100-200ms │
|
||||
│ 3. InterpolatedFLAC() - parse FLAC │ ~20-50ms │
|
||||
│ 4. Inject DB metadata │ ~1ms │
|
||||
│ 5. get_header() - generate new header │ ~10-20ms │
|
||||
│ 6. Seek to audio offset │ ~1ms │
|
||||
│ 7. Read audio into music_data │ ~100-200ms │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ TOTAL │ ~230-470ms │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Code Evidence** (lines 461-483):
|
||||
```python
|
||||
# Step 2-5: Load and parse entire file
|
||||
self.inf = InterpolatedFLAC(self.file_object.read()) # FULL FILE READ
|
||||
self.inf["title"] = self.item.title
|
||||
# ...
|
||||
self.header = self.inf.get_header(self.real_path)
|
||||
|
||||
# Step 6-7: Cache all audio data
|
||||
self.file_object.seek(self.music_offset)
|
||||
self.music_data = self.file_object.read() # ANOTHER FULL READ
|
||||
```
|
||||
|
||||
### Read Operation (Post-Open)
|
||||
|
||||
After file is opened, reads are fast:
|
||||
|
||||
```python
|
||||
def read(self, size, offset):
|
||||
if offset < self.bound:
|
||||
return self.header[offset:offset+size] # Memory slice: O(1)
|
||||
else:
|
||||
return self.music_data[offset - len(self.header):...] # Memory slice: O(1)
|
||||
```
|
||||
|
||||
### Write Operation
|
||||
|
||||
Writes to header area trigger expensive reconstruction:
|
||||
|
||||
```
|
||||
Time breakdown for tag write:
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 1. Reconstruct filedata in memory │ ~10-50ms │
|
||||
│ 2. Parse as InterpolatedFLAC │ ~20-50ms │
|
||||
│ 3. Extract tag values │ ~1ms │
|
||||
│ 4. lib.store() + lib.save() (SQLite) │ ~10-50ms │
|
||||
│ 5. Regenerate header │ ~10-20ms │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ TOTAL │ ~50-170ms │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Memory Footprint
|
||||
|
||||
### Per-File Memory Usage
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ FileHandler Memory Layout │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.music_data (bytes) │ │
|
||||
│ │ Size: file_size - original_header_size │ │
|
||||
│ │ Typical: 95-99% of file size │ │
|
||||
│ │ Example: 48.5 MB for 50 MB file │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.header (bytes) │ │
|
||||
│ │ Size: Generated FLAC header with DB metadata │ │
|
||||
│ │ Typical: 4 KB - 64 KB (depends on metadata + padding) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.inf (InterpolatedFLAC) │ │
|
||||
│ │ Size: Parsed metadata blocks + internal state │ │
|
||||
│ │ Typical: 10 KB - 100 KB │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Other attributes │ │
|
||||
│ │ path, real_path, item reference, format, etc. │ │
|
||||
│ │ Typical: ~1 KB │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ TOTAL per file: ~1.0x - 1.1x original file size │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Memory Scaling
|
||||
|
||||
| Scenario | Files Open | Avg File Size | RAM Usage |
|
||||
|----------|------------|---------------|-----------|
|
||||
| Single track playback | 1 | 30 MB | ~32 MB |
|
||||
| Album playback (gapless) | 2-3 | 30 MB | ~65-100 MB |
|
||||
| Album fully opened | 10 | 30 MB | ~320 MB |
|
||||
| Jellyfin library scan | 50-100 | 30 MB | **1.6 - 3.2 GB** |
|
||||
| Full library scan | 1000 | 30 MB | **32 GB** (OOM) |
|
||||
|
||||
### Global Memory
|
||||
|
||||
```python
|
||||
# Directory tree structure
|
||||
directory_structure = FSNode({}, {})
|
||||
# Memory: O(number_of_items)
|
||||
# Typical: 1-10 MB for libraries with 10,000-100,000 tracks
|
||||
|
||||
# Open file handles
|
||||
self.files = {} # Dict[str, FileHandler]
|
||||
# Memory: Sum of all FileHandler instances
|
||||
# Unbounded - grows with concurrent opens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. I/O Patterns
|
||||
|
||||
### Current (Inefficient)
|
||||
|
||||
```
|
||||
File Open:
|
||||
Disk → [Read ALL] → RAM (music_data)
|
||||
→ RAM (inf object)
|
||||
→ RAM (header)
|
||||
|
||||
File Read:
|
||||
RAM (header or music_data) → Application
|
||||
|
||||
Total I/O: 1x-2x file size on open, 0 on read
|
||||
```
|
||||
|
||||
### Optimal (Not Implemented)
|
||||
|
||||
```
|
||||
File Open:
|
||||
Disk → [Read header only] → RAM (small)
|
||||
|
||||
File Read:
|
||||
If header region:
|
||||
RAM (header) → Application
|
||||
If audio region:
|
||||
Disk → [Seek + Read chunk] → Application
|
||||
|
||||
Total I/O: ~64KB on open, on-demand reads
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Concurrency
|
||||
|
||||
### Current Model
|
||||
|
||||
```python
|
||||
server.multithreaded = 0 # Single-threaded
|
||||
```
|
||||
|
||||
**Implications:**
|
||||
- All FUSE operations serialized
|
||||
- One slow file open blocks everything
|
||||
- No benefit from multi-core CPUs
|
||||
|
||||
### Impact on Use Cases
|
||||
|
||||
| Use Case | Impact |
|
||||
|----------|--------|
|
||||
| Single player (VLC) | Acceptable - one file at a time |
|
||||
| Media server scan | Severe - sequential processing |
|
||||
| Multiple clients | Severe - requests queue up |
|
||||
| Concurrent reads | Moderate - reads are fast once open |
|
||||
|
||||
---
|
||||
|
||||
## 5. Benchmarks (Theoretical)
|
||||
|
||||
Based on code analysis, not actual measurements:
|
||||
|
||||
### File Open Time vs Size
|
||||
|
||||
```
|
||||
File Size Open Time (HDD) Open Time (SSD)
|
||||
────────────────────────────────────────────────
|
||||
10 MB 50-100 ms 20-50 ms
|
||||
30 MB 150-300 ms 50-100 ms
|
||||
50 MB 250-500 ms 100-200 ms
|
||||
100 MB 500-1000 ms 200-400 ms
|
||||
200 MB 1000-2000 ms 400-800 ms
|
||||
```
|
||||
|
||||
### Memory vs Concurrent Opens
|
||||
|
||||
```
|
||||
Open Files RAM Usage (30MB avg)
|
||||
─────────────────────────────────────
|
||||
1 ~32 MB
|
||||
5 ~160 MB
|
||||
10 ~320 MB
|
||||
25 ~800 MB
|
||||
50 ~1.6 GB
|
||||
100 ~3.2 GB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Comparison with Alternatives
|
||||
|
||||
| Metric | beetfs | Direct File | NFS | FUSE passthrough |
|
||||
|--------|--------|-------------|-----|------------------|
|
||||
| Open latency | 200-500ms | <10ms | 10-50ms | <10ms |
|
||||
| Read latency | <1ms | <1ms | 1-10ms | <1ms |
|
||||
| Memory/file | ~1x size | ~0 | ~0 | ~0 |
|
||||
| Metadata source | Database | File | File | File |
|
||||
| Modify original | No | Yes | Yes | Yes |
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendations
|
||||
|
||||
### For Current Usage
|
||||
|
||||
1. **Limit concurrent opens** - Don't scan full library
|
||||
2. **Use SSDs** - Reduces open latency by 2-3x
|
||||
3. **Increase RAM** - Expect 1x file size per open
|
||||
4. **Avoid large files** - 24-bit/192kHz FLACs are problematic
|
||||
|
||||
### For Modernization
|
||||
|
||||
1. **Implement lazy loading** - Read audio on demand
|
||||
2. **Add file handle caching** - Keep headers, release audio
|
||||
3. **Enable multi-threading** - Parallelize opens
|
||||
4. **Add memory limits** - Evict old FileHandlers
|
||||
@@ -1,403 +0,0 @@
|
||||
# beetfs Benchmark Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Benchmark suite to measure beetfs FUSE filesystem performance across mount time, metadata operations, file I/O, and memory usage. Focus on realistic music library workloads.
|
||||
|
||||
## Critical Performance Findings (Pre-Benchmark)
|
||||
|
||||
### Architecture Bottlenecks Identified
|
||||
|
||||
| Bottleneck | Location | Impact |
|
||||
|------------|----------|--------|
|
||||
| **Full file load into RAM** | `FileHandler.__init__` line 481 | 50-100MB per open FLAC |
|
||||
| **Mount-time bulk load** | `mount()` line 143 | O(N) for N library items |
|
||||
| **GIL serialization** | Python 2.7 | Single-core limit for metadata ops |
|
||||
| **Per-file DB lookup** | `getattr()`, `access()` | SQLite query per stat call |
|
||||
|
||||
### Expected Performance Characteristics
|
||||
|
||||
| Operation | Expected Performance | Bottleneck |
|
||||
|-----------|---------------------|------------|
|
||||
| Mount (10K items) | 5-30 seconds | `lib.items()` + FSNode construction |
|
||||
| readdir | Fast (in-memory dict) | None |
|
||||
| getattr (file) | Slow (~1ms) | DB lookup + real file stat |
|
||||
| open (first) | Very slow | Full file read into RAM |
|
||||
| read | Fast | Memory-to-memory copy |
|
||||
| Memory (10 open files) | 500MB-1GB | FileHandler caches entire files |
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Tools
|
||||
|
||||
### Primary Tools
|
||||
|
||||
| Tool | Purpose | Install |
|
||||
|------|---------|---------|
|
||||
| **fio** | I/O throughput, IOPS, latency | `nix-shell -p fio` |
|
||||
| **mdtest** | Metadata operations (stat, readdir) | `nix-shell -p ior` |
|
||||
| **hyperfine** | Mount time, command timing | `nix-shell -p hyperfine` |
|
||||
| **time** | Basic timing | builtin |
|
||||
| **/usr/bin/time -v** | Memory usage (maxrss) | builtin |
|
||||
|
||||
### Measurement Scripts
|
||||
|
||||
All benchmarks use synthetic FLAC files (5-10MB) to avoid I/O variance from real storage.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Categories
|
||||
|
||||
### 1. Mount Time Scaling
|
||||
|
||||
**Goal**: Measure how mount time scales with library size.
|
||||
|
||||
**Method**:
|
||||
```bash
|
||||
# Create libraries with N items: 100, 1K, 10K, 50K, 100K
|
||||
hyperfine --warmup 1 --runs 5 \
|
||||
'beet mount /mnt/beetfs && sleep 1 && fusermount -u /mnt/beetfs'
|
||||
```
|
||||
|
||||
**Metrics**:
|
||||
- Time to mount (seconds)
|
||||
- Memory usage at mount completion (RSS)
|
||||
|
||||
**Expected scaling**: O(N) - linear with library size
|
||||
|
||||
**Test matrix**:
|
||||
| Library Size | Expected Mount Time | Expected Memory |
|
||||
|--------------|--------------------:|----------------:|
|
||||
| 100 items | <1s | ~50MB |
|
||||
| 1,000 items | 1-3s | ~60MB |
|
||||
| 10,000 items | 5-15s | ~100MB |
|
||||
| 50,000 items | 30-60s | ~300MB |
|
||||
| 100,000 items | 60-120s | ~500MB |
|
||||
|
||||
---
|
||||
|
||||
### 2. Metadata Operations (stat/readdir)
|
||||
|
||||
**Goal**: Measure getattr and readdir performance - critical for music players that scan libraries.
|
||||
|
||||
#### 2a. Single stat latency
|
||||
|
||||
```bash
|
||||
# Measure single stat call latency
|
||||
hyperfine --warmup 10 --runs 100 \
|
||||
'stat /mnt/beetfs/Artist/Album/01-Track.flac'
|
||||
```
|
||||
|
||||
**Target**: <5ms average, <20ms p99
|
||||
|
||||
#### 2b. Bulk stat (library scan simulation)
|
||||
|
||||
```bash
|
||||
# Stat all files in library
|
||||
hyperfine --warmup 1 --runs 5 \
|
||||
'find /mnt/beetfs -type f -exec stat {} + > /dev/null'
|
||||
```
|
||||
|
||||
**Metrics**:
|
||||
- Total time for N files
|
||||
- stat operations per second
|
||||
- p50, p95, p99 latency
|
||||
|
||||
**Target**: >500 stat/s (Python FUSE baseline)
|
||||
|
||||
#### 2c. Directory listing
|
||||
|
||||
```bash
|
||||
# List directory with N entries
|
||||
hyperfine --warmup 3 --runs 10 \
|
||||
'ls /mnt/beetfs/Artist/Album/'
|
||||
```
|
||||
|
||||
**Test matrix**:
|
||||
| Directory entries | Target time |
|
||||
|------------------:|------------:|
|
||||
| 10 | <50ms |
|
||||
| 100 | <100ms |
|
||||
| 1,000 | <500ms |
|
||||
|
||||
---
|
||||
|
||||
### 3. File Open Performance
|
||||
|
||||
**Goal**: Measure file open latency - the critical bottleneck due to full file load.
|
||||
|
||||
#### 3a. First open (cold)
|
||||
|
||||
```bash
|
||||
# Clear any caches, then open file
|
||||
echo 3 > /proc/sys/vm/drop_caches
|
||||
hyperfine --warmup 0 --runs 10 \
|
||||
'head -c 1 /mnt/beetfs/Artist/Album/01-Track.flac > /dev/null'
|
||||
```
|
||||
|
||||
**Test matrix**:
|
||||
| File size | Expected open time |
|
||||
|----------:|-------------------:|
|
||||
| 5MB | 50-200ms |
|
||||
| 20MB | 200-500ms |
|
||||
| 50MB | 500ms-1s |
|
||||
| 100MB | 1-2s |
|
||||
|
||||
#### 3b. Cached open (warm)
|
||||
|
||||
```bash
|
||||
# File already opened once
|
||||
hyperfine --warmup 5 --runs 50 \
|
||||
'head -c 1 /mnt/beetfs/Artist/Album/01-Track.flac > /dev/null'
|
||||
```
|
||||
|
||||
**Target**: <10ms (should hit FileHandler cache)
|
||||
|
||||
---
|
||||
|
||||
### 4. Read Throughput
|
||||
|
||||
**Goal**: Measure sequential and random read performance.
|
||||
|
||||
#### 4a. Sequential read
|
||||
|
||||
```bash
|
||||
fio --name=seq_read \
|
||||
--filename=/mnt/beetfs/Artist/Album/01-Track.flac \
|
||||
--rw=read --bs=1M --direct=0 \
|
||||
--ioengine=sync --numjobs=1 \
|
||||
--runtime=30 --time_based
|
||||
```
|
||||
|
||||
**Metrics**: MB/s throughput
|
||||
|
||||
**Target**: >100 MB/s (memory-backed after first read)
|
||||
|
||||
#### 4b. Random read (simulates seeking in audio player)
|
||||
|
||||
```bash
|
||||
fio --name=rand_read \
|
||||
--filename=/mnt/beetfs/Artist/Album/01-Track.flac \
|
||||
--rw=randread --bs=64k --direct=0 \
|
||||
--ioengine=sync --numjobs=1 \
|
||||
--runtime=30 --time_based
|
||||
```
|
||||
|
||||
**Metrics**: IOPS, latency histogram
|
||||
|
||||
---
|
||||
|
||||
### 5. Memory Usage
|
||||
|
||||
**Goal**: Measure memory consumption under load.
|
||||
|
||||
#### 5a. Idle memory (mounted, no activity)
|
||||
|
||||
```bash
|
||||
# Mount and measure RSS
|
||||
beet mount /mnt/beetfs &
|
||||
sleep 5
|
||||
ps -o rss= -p $(pgrep -f beetfs)
|
||||
```
|
||||
|
||||
#### 5b. Memory per open file
|
||||
|
||||
```bash
|
||||
# Open N files, measure memory growth
|
||||
for i in 1 5 10 20; do
|
||||
# Open $i files simultaneously
|
||||
cat /mnt/beetfs/Artist/Album/0{1..$i}*.flac > /dev/null &
|
||||
ps -o rss= -p $(pgrep -f beetfs)
|
||||
done
|
||||
```
|
||||
|
||||
**Expected**: ~file_size × open_files (FileHandler caches entire file)
|
||||
|
||||
#### 5c. Memory leak detection
|
||||
|
||||
```bash
|
||||
# Repeatedly open/close files, check for memory growth
|
||||
for i in {1..100}; do
|
||||
cat /mnt/beetfs/Artist/Album/01-Track.flac > /dev/null
|
||||
done
|
||||
# Compare RSS before and after
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Concurrent Access
|
||||
|
||||
**Goal**: Measure performance under parallel access (multiple processes).
|
||||
|
||||
```bash
|
||||
# Parallel stat operations
|
||||
hyperfine --warmup 1 --runs 5 \
|
||||
'seq 1 100 | xargs -P 4 -I {} stat /mnt/beetfs/Artist/Album/0{}-Track.flac'
|
||||
```
|
||||
|
||||
**Metrics**:
|
||||
- Throughput scaling with parallelism (1, 2, 4, 8 workers)
|
||||
- Latency degradation
|
||||
|
||||
**Expected**: Limited scaling due to Python GIL
|
||||
|
||||
---
|
||||
|
||||
### 7. Realistic Workloads
|
||||
|
||||
#### 7a. Music player library scan
|
||||
|
||||
Simulates: Rhythmbox/Clementine scanning library at startup
|
||||
|
||||
```bash
|
||||
# Recursive stat + readdir
|
||||
time find /mnt/beetfs -type f -name "*.flac" -exec stat {} + | wc -l
|
||||
```
|
||||
|
||||
#### 7b. Album playback
|
||||
|
||||
Simulates: Playing 12-track album sequentially
|
||||
|
||||
```bash
|
||||
# Open each file, read 1MB (simulate buffering), close
|
||||
for f in /mnt/beetfs/Artist/Album/*.flac; do
|
||||
dd if="$f" of=/dev/null bs=1M count=1 2>/dev/null
|
||||
done
|
||||
```
|
||||
|
||||
#### 7c. Metadata edit
|
||||
|
||||
Simulates: Editing tags in Picard/Kid3
|
||||
|
||||
```bash
|
||||
# Open file, write to header region, close
|
||||
# (Requires write support to be functional)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Baseline Comparisons
|
||||
|
||||
### Reference Filesystems
|
||||
|
||||
| Filesystem | Purpose |
|
||||
|------------|---------|
|
||||
| **ext4 (local)** | Best-case baseline |
|
||||
| **fuse-passthrough** | FUSE overhead baseline |
|
||||
| **sshfs** | Network FUSE comparison |
|
||||
|
||||
### Comparison Method
|
||||
|
||||
Run identical benchmarks on:
|
||||
1. Real music files on ext4
|
||||
2. Same files via FUSE passthrough
|
||||
3. Same files via beetfs
|
||||
|
||||
Calculate overhead: `(beetfs_time - ext4_time) / ext4_time × 100%`
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
### Hardware Requirements
|
||||
|
||||
- CPU: 4+ cores (to test GIL impact)
|
||||
- RAM: 8+ GB (for large library tests)
|
||||
- Storage: SSD recommended (reduces I/O variance)
|
||||
|
||||
### Software Requirements
|
||||
|
||||
```nix
|
||||
# Add to flake.nix devShell
|
||||
buildInputs = [
|
||||
fio
|
||||
hyperfine
|
||||
# ior # includes mdtest
|
||||
];
|
||||
```
|
||||
|
||||
### Cache Control
|
||||
|
||||
```bash
|
||||
# Clear all caches before cold benchmarks
|
||||
sync
|
||||
echo 3 > /proc/sys/vm/drop_caches
|
||||
|
||||
# Disable kernel FUSE caching for accurate measurements
|
||||
mount -o entry_timeout=0,attr_timeout=0,negative_timeout=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Minimum Viable Performance
|
||||
|
||||
| Metric | Minimum | Target | Excellent |
|
||||
|--------|--------:|-------:|----------:|
|
||||
| Mount time (10K items) | <60s | <15s | <5s |
|
||||
| stat latency (avg) | <20ms | <5ms | <1ms |
|
||||
| stat throughput | >100/s | >500/s | >2000/s |
|
||||
| File open (50MB, cold) | <5s | <1s | <200ms |
|
||||
| Read throughput | >50 MB/s | >200 MB/s | >500 MB/s |
|
||||
| Memory (idle, 10K items) | <500MB | <100MB | <50MB |
|
||||
| Memory per open file | <2× file size | <1.5× | <1.1× |
|
||||
|
||||
### Regression Detection
|
||||
|
||||
Any benchmark result >20% worse than baseline triggers investigation.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Test Data Generation
|
||||
|
||||
Use existing test infrastructure from `tests/conftest.py`:
|
||||
- `create_synthetic_flac()` - generates valid FLAC files
|
||||
- `BeetFSTestCase` - creates isolated beets library
|
||||
|
||||
### Benchmark Script Structure
|
||||
|
||||
```
|
||||
beetfs/
|
||||
├── benchmarks/
|
||||
│ ├── run_all.sh # Master script
|
||||
│ ├── bench_mount.sh # Mount time tests
|
||||
│ ├── bench_metadata.sh # stat/readdir tests
|
||||
│ ├── bench_io.sh # Read/write throughput
|
||||
│ ├── bench_memory.sh # Memory profiling
|
||||
│ └── results/ # Output directory
|
||||
│ ├── mount_scaling.csv
|
||||
│ ├── stat_latency.csv
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
### Output Format
|
||||
|
||||
```csv
|
||||
# Example: mount_scaling.csv
|
||||
library_size,mount_time_ms,memory_rss_kb,timestamp
|
||||
100,450,52000,2024-01-15T10:30:00
|
||||
1000,2100,61000,2024-01-15T10:31:00
|
||||
10000,12500,98000,2024-01-15T10:33:00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. **Python 2.7 GIL**: Cannot achieve true parallelism - expect flat scaling beyond 1 core
|
||||
2. **FileHandler memory**: Each open file = full file in RAM - will OOM with many large files
|
||||
3. **No lazy loading**: All library items loaded at mount - slow for large libraries
|
||||
4. **SQLite single-writer**: Concurrent writes will serialize
|
||||
|
||||
## Optimization Opportunities (Post-Benchmark)
|
||||
|
||||
Based on benchmark results, consider:
|
||||
|
||||
1. **Lazy FSNode construction** - Build tree on first access, not mount
|
||||
2. **Memory-mapped file access** - mmap instead of full read
|
||||
3. **LRU cache for FileHandler** - Evict old files instead of holding all
|
||||
4. **Metadata caching** - Cache getattr results, invalidate on DB change
|
||||
5. **Batch DB queries** - Prefetch metadata for directory listings
|
||||
@@ -1,101 +0,0 @@
|
||||
# beetfs Benchmark Results
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Status**: ❌ ALL BENCHMARKS BLOCKED BY BUGS
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Benchmarks cannot complete due to critical bugs in beetfs. The implementation is non-functional for any library with content.
|
||||
|
||||
## Results
|
||||
|
||||
| Benchmark | Status | Mean | Error |
|
||||
|-----------|--------|------|-------|
|
||||
| mount_time | ❌ FAIL | N/A | Directory tree building bug |
|
||||
| readdir | ❌ FAIL | N/A | Directory tree building bug |
|
||||
| stat_latency | ❌ FAIL | N/A | Directory tree building bug |
|
||||
| enoent_lookup | ❌ FAIL | N/A | Directory tree building bug |
|
||||
| file_open | ❌ FAIL | N/A | Directory tree building bug |
|
||||
| read_throughput | ❌ FAIL | N/A | Directory tree building bug |
|
||||
| memory_usage | ❌ FAIL | N/A | Directory tree building bug |
|
||||
|
||||
## Blocking Bugs
|
||||
|
||||
### Bug #1: Nested Methods (Lines 758-1144)
|
||||
|
||||
All FUSE operations (`readdir`, `open`, `read`, `write`, etc.) are indented inside the `access()` method, making them local functions instead of class methods.
|
||||
|
||||
**Impact**: Even if mount succeeds, all file operations return `ENOSYS (Function not implemented)`.
|
||||
|
||||
**Fix Required**: Dedent lines 758-1144 by 8 spaces.
|
||||
|
||||
### Bug #2: Directory Tree Building (Lines 403-414)
|
||||
|
||||
`FSNode.adddir()` calls `getnode()` which assumes parent directories already exist. When building the tree for a new library, parent directories haven't been created yet.
|
||||
|
||||
**Error**:
|
||||
```
|
||||
KeyError: u'Bench Artist'
|
||||
File "beetFs.py", line 403, in getnode
|
||||
return self.getnode(elements, root=root.dirs[topdir])
|
||||
```
|
||||
|
||||
**Impact**: Mount crashes when library contains any tracks.
|
||||
|
||||
**Fix Required**: `adddir()` must create parent directories recursively before adding child.
|
||||
|
||||
### Bug #3: Empty Library Only
|
||||
|
||||
The only working configuration is mounting with an empty beets library:
|
||||
- `test_mount_empty_library`: ✅ PASS
|
||||
- Any library with tracks: ❌ CRASH
|
||||
|
||||
## Test Environment
|
||||
|
||||
- **Python**: 2.7.15
|
||||
- **OS**: Linux (NixOS)
|
||||
- **Test data**: 10 synthetic FLAC files (5 MB each)
|
||||
- **Beets**: 1.4.9
|
||||
|
||||
## Benchmark Configuration
|
||||
|
||||
```python
|
||||
num_tracks = 10
|
||||
track_size_mb = 5
|
||||
mount_runs = 3
|
||||
stat_runs = 20
|
||||
readdir_runs = 10
|
||||
```
|
||||
|
||||
## Raw Results
|
||||
|
||||
See `benchmarks/results/benchmark_results.json` for full JSON output.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Fix Bug #2** (directory tree building) - allows mount with content
|
||||
2. **Fix Bug #1** (nested methods) - allows FUSE operations to work
|
||||
3. **Re-run benchmarks** - get actual performance numbers
|
||||
|
||||
## Conclusion
|
||||
|
||||
**beetfs is currently non-functional** for real-world use. Both bugs must be fixed before performance can be measured. The test infrastructure and benchmark suite are ready; only the implementation needs repair.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: E2E Test Results (For Reference)
|
||||
|
||||
From the e2e test suite (74 tests):
|
||||
|
||||
| Category | Passed | Failed | Errors |
|
||||
|----------|--------|--------|--------|
|
||||
| Smoke tests | 4 | 3 | 0 |
|
||||
| Nested bug detection | 3 (confirmed bug) | 10 | 0 |
|
||||
| Readdir | 0 | 10 | 0 |
|
||||
| Stat | 0 | 8 | 0 |
|
||||
| Read | 0 | 11 | 0 |
|
||||
| Write | 0 | 7 | 0 |
|
||||
| Error handling | 0 | 7 | 3 |
|
||||
| **Total** | **12** | **56** | **3** |
|
||||
|
||||
The 12 passing tests are infrastructure tests and tests that verify the bugs exist.
|
||||
@@ -1,550 +0,0 @@
|
||||
# beetfs Components Deep Dive
|
||||
|
||||
## Component Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFs.py │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ PLUGIN LAYER ││
|
||||
│ │ beetFs (BeetsPlugin) beetFs_command (Subcommand) ││
|
||||
│ │ mount() template_mapping() ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ VIRTUAL FILESYSTEM ││
|
||||
│ │ FSNode beetFileSystem (fuse.Fuse) ││
|
||||
│ │ Stat ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ METADATA INTERPOLATION ││
|
||||
│ │ FileHandler InterpolatedFLAC ││
|
||||
│ │ InterpolatedID3 ││
|
||||
│ └─────────────────────────────────────────────────────────────────────┘│
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Plugin Layer
|
||||
|
||||
### 1.1 beetFs (BeetsPlugin)
|
||||
|
||||
**Location**: Lines 188-191
|
||||
|
||||
```python
|
||||
class beetFs(BeetsPlugin):
|
||||
""" The beets plugin hook."""
|
||||
def commands(self):
|
||||
return [beetFs_command]
|
||||
```
|
||||
|
||||
**Purpose**: Registers beetfs as a beets plugin, exposing the `mount` subcommand.
|
||||
|
||||
### 1.2 beetFs_command
|
||||
|
||||
**Location**: Lines 47, 185
|
||||
|
||||
```python
|
||||
beetFs_command = Subcommand('mount', help='Mount a beets filesystem')
|
||||
beetFs_command.func = mount
|
||||
```
|
||||
|
||||
**Purpose**: CLI subcommand definition for `beet mount`.
|
||||
|
||||
### 1.3 mount() Function
|
||||
|
||||
**Location**: Lines 119-183
|
||||
|
||||
```python
|
||||
def mount(lib, config, opts, args):
|
||||
# 1. Validate arguments
|
||||
if not args:
|
||||
raise beets.ui.UserError('no mountpoint specified')
|
||||
|
||||
# 2. Parse path template
|
||||
global structure_split
|
||||
structure_split = PATH_FORMAT.split("/")
|
||||
global structure_depth
|
||||
structure_depth = len(structure_split)
|
||||
|
||||
# 3. Store library reference
|
||||
global library
|
||||
library = lib
|
||||
|
||||
# 4. Build virtual directory tree
|
||||
global directory_structure
|
||||
directory_structure = FSNode({}, {})
|
||||
|
||||
# 5. Iterate all library items
|
||||
for item in lib.items():
|
||||
mapping = template_mapping(lib, item)
|
||||
# ... build tree ...
|
||||
directory_structure.addfile(sub_elements, filename, item.id)
|
||||
|
||||
# 6. Create and run FUSE server
|
||||
server = beetFileSystem(...)
|
||||
server.main()
|
||||
```
|
||||
|
||||
**Key Variables Set**:
|
||||
| Variable | Type | Purpose |
|
||||
|----------|------|---------|
|
||||
| `structure_split` | `List[str]` | Path template components |
|
||||
| `structure_depth` | `int` | Number of path levels |
|
||||
| `library` | `Library` | Beets library reference |
|
||||
| `directory_structure` | `FSNode` | Root of virtual tree |
|
||||
|
||||
### 1.4 template_mapping() Function
|
||||
|
||||
**Location**: Lines 82-116
|
||||
|
||||
```python
|
||||
def template_mapping(lib, item):
|
||||
"""Builds a template substitution map from beets item."""
|
||||
mapping = {}
|
||||
for key in METADATA_KEYS:
|
||||
value = getattr(item, key)
|
||||
# Sanitize value for filesystem paths
|
||||
if isinstance(value, basestring):
|
||||
value = re.sub(r'[\\/:]|^\.', '_', value)
|
||||
elif key in ('track', 'tracktotal', 'disc', 'disctotal'):
|
||||
value = '%02i' % value # Zero-pad numbers
|
||||
mapping[key] = value
|
||||
|
||||
# Add format info
|
||||
format_ = os.path.splitext(item.path)[1][1:]
|
||||
mapping['format'] = format_
|
||||
mapping['format_upper'] = format_.upper()
|
||||
|
||||
# Default values for missing fields
|
||||
if mapping['artist'] == '':
|
||||
mapping['artist'] = 'Unknown Artist'
|
||||
# ... etc
|
||||
|
||||
return mapping
|
||||
```
|
||||
|
||||
**Template Variables Available**:
|
||||
| Variable | Source | Example |
|
||||
|----------|--------|---------|
|
||||
| `$artist` | `item.artist` | "Pink Floyd" |
|
||||
| `$album` | `item.album` | "The Wall" |
|
||||
| `$title` | `item.title` | "Comfortably Numb" |
|
||||
| `$year` | `item.year` | "1979" |
|
||||
| `$track` | `item.track` | "06" |
|
||||
| `$format` | file extension | "flac" |
|
||||
| `$format_upper` | file extension | "FLAC" |
|
||||
|
||||
---
|
||||
|
||||
## 2. Virtual Filesystem Layer
|
||||
|
||||
### 2.1 FSNode Class
|
||||
|
||||
**Location**: Lines 390-436
|
||||
|
||||
```python
|
||||
class FSNode(object):
|
||||
"""A directory node in the virtual filesystem tree."""
|
||||
|
||||
def __init__(self, dirs, files):
|
||||
self.dirs = dirs # Dict[str, FSNode] - subdirectories
|
||||
self.files = files # Dict[str, int] - filename → beets item ID
|
||||
```
|
||||
|
||||
**Methods**:
|
||||
|
||||
| Method | Purpose | Signature |
|
||||
|--------|---------|-----------|
|
||||
| `getnode()` | Navigate to nested node | `getnode(elements, root=None) → FSNode` |
|
||||
| `adddir()` | Add a directory | `adddir(elements, directory, root=None)` |
|
||||
| `addfile()` | Add a file entry | `addfile(elements, filename, id, root=None)` |
|
||||
| `listdir()` | List contents | `listdir(elements, directories, root=None) → List[str]` |
|
||||
|
||||
**Example Tree Navigation**:
|
||||
```python
|
||||
# Path: /Artist/Album/track.flac
|
||||
# structure_split = ["$artist", "$album ($year) [$format_upper]", "$track - $artist - $title.$format"]
|
||||
|
||||
elements = ["Artist", "Album (2020) [FLAC]"]
|
||||
node = directory_structure.getnode(elements)
|
||||
# node.files = {"01 - Artist - Track.flac": 42, ...}
|
||||
|
||||
item_id = node.files["01 - Artist - Track.flac"]
|
||||
# item_id = 42
|
||||
```
|
||||
|
||||
### 2.2 Stat Class
|
||||
|
||||
**Location**: Lines 568-619
|
||||
|
||||
```python
|
||||
class Stat(fuse.Stat):
|
||||
DIRSIZE = 4096
|
||||
|
||||
def __init__(self, st_mode, st_size, st_nlink=1, st_uid=None, st_gid=None,
|
||||
dt_atime=None, dt_mtime=None, dt_ctime=None):
|
||||
self.st_mode = st_mode
|
||||
self.st_ino = 0
|
||||
self.st_dev = 0
|
||||
self.st_nlink = st_nlink
|
||||
self.st_uid = st_uid or os.getuid()
|
||||
self.st_gid = st_gid or os.getgid()
|
||||
self.st_size = st_size
|
||||
# ... timestamps ...
|
||||
```
|
||||
|
||||
**Purpose**: Represents file/directory metadata for FUSE stat operations.
|
||||
|
||||
### 2.3 beetFileSystem Class
|
||||
|
||||
**Location**: Lines 622-1144
|
||||
|
||||
```python
|
||||
class beetFileSystem(fuse.Fuse):
|
||||
"""Main FUSE filesystem implementation."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
logging.basicConfig(filename="LOG", level=logging.INFO)
|
||||
super(beetFileSystem, self).__init__(*args, **kwargs)
|
||||
|
||||
def fsinit(self):
|
||||
"""Called after filesystem is mounted."""
|
||||
self.lib = library
|
||||
self.files = {} # Dict[path, FileHandler]
|
||||
```
|
||||
|
||||
**FUSE Operations Implemented**:
|
||||
|
||||
| Operation | Lines | Purpose |
|
||||
|-----------|-------|---------|
|
||||
| `fsinit()` | 630-636 | Post-mount initialization |
|
||||
| `fsdestroy()` | 638-639 | Pre-unmount cleanup |
|
||||
| `statfs()` | 641-646 | Filesystem statistics |
|
||||
| `getattr()` | 648-707 | Get file/dir attributes |
|
||||
| `access()` | 723-756 | Check permissions |
|
||||
| `readdir()` | 931-975 | List directory contents |
|
||||
| `open()` | 988-1021 | Open file |
|
||||
| `read()` | 1077-1106 | Read file data |
|
||||
| `write()` | 1108-1135 | Write file data |
|
||||
| `release()` | 1049-1059 | Close file |
|
||||
|
||||
**Not Implemented (return EOPNOTSUPP)**:
|
||||
- `mknod()`, `mkdir()`, `unlink()`, `rmdir()`
|
||||
- `symlink()`, `link()`, `rename()`
|
||||
- `chmod()`, `chown()`, `truncate()`
|
||||
|
||||
---
|
||||
|
||||
## 3. Metadata Interpolation Layer
|
||||
|
||||
### 3.1 FileHandler Class
|
||||
|
||||
**Location**: Lines 439-565
|
||||
|
||||
This is the **core component** that implements metadata overlay.
|
||||
|
||||
```python
|
||||
class FileHandler(object):
|
||||
def __init__(self, path, lib):
|
||||
self.path = path # Virtual path
|
||||
self.lib = lib # Beets library
|
||||
|
||||
# Resolve virtual path to real file
|
||||
pathsplit = path[1:].split('/')
|
||||
self.item = self.lib.get_item(id=directory_structure
|
||||
.getnode(pathsplit[0:structure_depth-1])
|
||||
.files[pathsplit[structure_depth-1]])
|
||||
self.real_path = self.item.path
|
||||
|
||||
# Open real file
|
||||
self.file_object = open(self.real_path, 'r+')
|
||||
self.instance_count = 1
|
||||
|
||||
# Determine format
|
||||
self.format = os.path.splitext(path)[1][1:].lower()
|
||||
|
||||
if self.format == "flac":
|
||||
# Load file into interpolated FLAC object
|
||||
self.inf = InterpolatedFLAC(self.file_object.read())
|
||||
|
||||
# INJECT DATABASE METADATA
|
||||
self.inf["title"] = self.item.title
|
||||
self.inf["album"] = self.item.album
|
||||
self.inf["artist"] = self.item.artist
|
||||
self.inf["genre"] = self.item.genre
|
||||
|
||||
# Generate new header with DB metadata
|
||||
self.header = self.inf.get_header(self.real_path)
|
||||
self.bound = len(self.header)
|
||||
self.music_offset = self.inf.offset()
|
||||
|
||||
elif self.format == "mp3":
|
||||
self.bound = 0 # MP3 interpolation disabled
|
||||
self.music_offset = 0
|
||||
|
||||
# Cache audio data
|
||||
self.file_object.seek(self.music_offset)
|
||||
self.music_data = self.file_object.read()
|
||||
self.file_object.close()
|
||||
```
|
||||
|
||||
**Key Attributes**:
|
||||
|
||||
| Attribute | Type | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `path` | `str` | Virtual path (e.g., `/Artist/Album/track.flac`) |
|
||||
| `real_path` | `str` | Actual file path on disk |
|
||||
| `item` | `Item` | Beets library item (has DB metadata) |
|
||||
| `format` | `str` | File format ("flac", "mp3") |
|
||||
| `inf` | `InterpolatedFLAC` | Mutagen object with injected metadata |
|
||||
| `header` | `bytes` | Generated header with DB tags |
|
||||
| `bound` | `int` | Byte offset where header ends |
|
||||
| `music_offset` | `int` | Byte offset in original file where audio starts |
|
||||
| `music_data` | `bytes` | Cached audio data |
|
||||
| `instance_count` | `int` | Reference count for file handles |
|
||||
|
||||
### 3.2 FileHandler.read() Method
|
||||
|
||||
**Location**: Lines 497-517
|
||||
|
||||
```python
|
||||
def read(self, size, offset):
|
||||
# Case 1: Reading within header boundary
|
||||
if offset < self.bound:
|
||||
if offset + size < len(self.header):
|
||||
# Entire read is within header
|
||||
return self.header[offset:offset+size]
|
||||
else:
|
||||
# Read spans header and audio
|
||||
ret = self.header[offset:len(self.header)]
|
||||
ret = ret + self.music_data[0:size - (len(self.header) - offset)]
|
||||
return ret
|
||||
|
||||
# Case 2: Reading audio data only
|
||||
return self.music_data[offset - len(self.header):offset - len(self.header) + size]
|
||||
```
|
||||
|
||||
**Read Logic Diagram**:
|
||||
|
||||
```
|
||||
Virtual File Layout:
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ 0 bound EOF │
|
||||
│ ├─────────┼────────────────────────────────────────────────┤ │
|
||||
│ │ HEADER │ AUDIO DATA │ │
|
||||
│ │ (from │ (from self.music_data) │ │
|
||||
│ │ self. │ │ │
|
||||
│ │ header) │ │ │
|
||||
│ └─────────┴────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Read scenarios:
|
||||
1. offset=0, size=100, bound=500 → Return header[0:100]
|
||||
2. offset=400, size=200, bound=500 → Return header[400:500] + music[0:100]
|
||||
3. offset=600, size=100, bound=500 → Return music[100:200]
|
||||
```
|
||||
|
||||
### 3.3 FileHandler.write() Method
|
||||
|
||||
**Location**: Lines 519-565
|
||||
|
||||
```python
|
||||
def write(self, offset, buf):
|
||||
# Only handle writes to header area
|
||||
if offset < self.bound:
|
||||
# Reconstruct full file in memory
|
||||
filedata = self.header + self.music_data
|
||||
|
||||
# Patch in new data
|
||||
filedata = filedata[0:offset] + buf + filedata[offset + len(buf):]
|
||||
|
||||
if self.format == "flac":
|
||||
# Parse the patched data
|
||||
self.inf = InterpolatedFLAC(filedata)
|
||||
|
||||
# EXTRACT new tag values and save to DB
|
||||
self.item.title = str(self.inf["title"][0]).encode('utf-8')
|
||||
self.item.album = str(self.inf["album"][0]).encode('utf-8')
|
||||
self.item.artist = str(self.inf["artist"][0]).encode('utf-8')
|
||||
self.item.genre = str(self.inf["genre"][0]).encode('utf-8')
|
||||
|
||||
# Persist to beets database
|
||||
self.lib.store(self.item)
|
||||
self.lib.save()
|
||||
|
||||
# Regenerate header with updated values
|
||||
self.inf["title"] = self.item.title
|
||||
self.inf["album"] = self.item.album
|
||||
self.inf["artist"] = self.item.artist
|
||||
self.inf["genre"] = self.item.genre
|
||||
|
||||
self.header = self.inf.get_header(self.real_path)
|
||||
self.bound = len(self.header)
|
||||
|
||||
return len(buf)
|
||||
```
|
||||
|
||||
**Write Flow**:
|
||||
```
|
||||
1. App writes new tag data to header region
|
||||
│
|
||||
▼
|
||||
2. Patch header + music_data with new bytes
|
||||
│
|
||||
▼
|
||||
3. Parse patched data as FLAC
|
||||
│
|
||||
▼
|
||||
4. Extract tag values from parsed FLAC
|
||||
│
|
||||
▼
|
||||
5. Update beets Item with new values
|
||||
│
|
||||
▼
|
||||
6. lib.store(item) + lib.save() → SQLite
|
||||
│
|
||||
▼
|
||||
7. Regenerate header for subsequent reads
|
||||
```
|
||||
|
||||
### 3.4 InterpolatedFLAC Class
|
||||
|
||||
**Location**: Lines 274-388
|
||||
|
||||
```python
|
||||
class InterpolatedFLAC(FLAC):
|
||||
"""Custom FLAC handler that can load from bytes and generate headers."""
|
||||
|
||||
def load(self, filedata):
|
||||
"""Load FLAC from byte string instead of file."""
|
||||
self.metadata_blocks = []
|
||||
self.tags = None
|
||||
self.filedata = filedata
|
||||
self.fileobj = BytesIO(filedata)
|
||||
self.__check_header(self.fileobj)
|
||||
|
||||
while self.__read_metadata_block(self.fileobj):
|
||||
pass
|
||||
|
||||
# Verify audio frame starts correctly
|
||||
if self.fileobj.read(2) not in ["\xff\xf8", "\xff\xf9"]:
|
||||
raise FLACNoHeaderError("End of metadata did not start audio")
|
||||
|
||||
def get_header(self, filename=None):
|
||||
"""Generate FLAC header with current metadata."""
|
||||
# Add padding block
|
||||
self.metadata_blocks.append(Padding('\x00' * 1020))
|
||||
MetadataBlock.group_padding(self.metadata_blocks)
|
||||
|
||||
# Calculate available space
|
||||
header = self.__check_header(self.fileobj)
|
||||
available = self.__find_audio_offset(self.fileobj) - header
|
||||
data = MetadataBlock.writeblocks(self.metadata_blocks)
|
||||
|
||||
# Adjust padding to match available space
|
||||
if len(data) > available:
|
||||
# Reduce padding
|
||||
padding = self.metadata_blocks[-1]
|
||||
padding.length -= (len(data) - available)
|
||||
data = MetadataBlock.writeblocks(self.metadata_blocks)
|
||||
elif len(data) < available:
|
||||
# Increase padding
|
||||
self.metadata_blocks[-1].length += (available - len(data))
|
||||
data = MetadataBlock.writeblocks(self.metadata_blocks)
|
||||
|
||||
self.__offset = len("fLaC" + data)
|
||||
return "fLaC" + data
|
||||
|
||||
def offset(self):
|
||||
"""Return byte offset where audio data starts."""
|
||||
return self.__offset
|
||||
```
|
||||
|
||||
**FLAC Structure**:
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ "fLaC" │ STREAMINFO │ VORBIS_COMMENT │ ... │ PADDING │ AUDIO... │
|
||||
│ (4B) │ block │ block │ │ block │ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│◄──────── metadata_blocks ─────────►│
|
||||
│ │
|
||||
└──── get_header() returns this ─────┘
|
||||
```
|
||||
|
||||
### 3.5 InterpolatedID3 Class
|
||||
|
||||
**Location**: Lines 200-271
|
||||
|
||||
```python
|
||||
class InterpolatedID3(ID3):
|
||||
"""Custom ID3 handler for MP3 files."""
|
||||
|
||||
def save(self, filename=None, v1=0):
|
||||
"""Save ID3 tags to file."""
|
||||
# Sort frames by importance
|
||||
order = ["TIT2", "TPE1", "TRCK", "TALB", "TPOS", "TDRC", "TCON"]
|
||||
# ... write header ...
|
||||
```
|
||||
|
||||
**Note**: MP3 support is **incomplete** in the current implementation. The `FileHandler.__init__` sets `self.bound = 0` for MP3, effectively disabling interpolation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Supported Metadata Fields
|
||||
|
||||
**Location**: Lines 55-77
|
||||
|
||||
```python
|
||||
METADATA_RW_FIELDS = [
|
||||
('title', 'text'),
|
||||
('artist', 'text'),
|
||||
('album', 'text'),
|
||||
('genre', 'text'),
|
||||
('composer', 'text'),
|
||||
('grouping', 'text'),
|
||||
('year', 'int'),
|
||||
('month', 'int'),
|
||||
('day', 'int'),
|
||||
('track', 'int'),
|
||||
('tracktotal', 'int'),
|
||||
('disc', 'int'),
|
||||
('disctotal', 'int'),
|
||||
('lyrics', 'text'),
|
||||
('comments', 'text'),
|
||||
('bpm', 'int'),
|
||||
('comp', 'bool'),
|
||||
]
|
||||
```
|
||||
|
||||
**Actually Implemented** (in FileHandler):
|
||||
| Field | Read | Write |
|
||||
|-------|------|-------|
|
||||
| `title` | ✅ | ✅ |
|
||||
| `artist` | ✅ | ✅ |
|
||||
| `album` | ✅ | ✅ |
|
||||
| `genre` | ✅ | ✅ |
|
||||
| Others | ❌ | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Error Handling
|
||||
|
||||
**Error Codes Used**:
|
||||
|
||||
| Code | Constant | Usage |
|
||||
|------|----------|-------|
|
||||
| 2 | `ENOENT` | File/directory not found |
|
||||
| 13 | `EACCES` | Permission denied |
|
||||
| 1 | `EPERM` | Operation not permitted |
|
||||
| 95 | `EOPNOTSUPP` | Operation not supported |
|
||||
|
||||
**Exception Handling Pattern**:
|
||||
```python
|
||||
def getattr(self, path):
|
||||
try:
|
||||
# ... logic ...
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return -errno.ENOENT
|
||||
```
|
||||
@@ -1,412 +0,0 @@
|
||||
# beetfs Data Flow
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the complete data flow for read and write operations in beetfs.
|
||||
|
||||
---
|
||||
|
||||
## 1. Initialization Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beet mount /mountpoint │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ mount(lib, config, opts, args) │
|
||||
│ │
|
||||
│ 1. Parse PATH_FORMAT into structure_split │
|
||||
│ PATH_FORMAT = "$artist/$album ($year) [$format_upper]/..." │
|
||||
│ structure_split = ["$artist", "$album ($year) [$format_upper]", ...] │
|
||||
│ structure_depth = 3 │
|
||||
│ │
|
||||
│ 2. Store global library reference │
|
||||
│ library = lib │
|
||||
│ │
|
||||
│ 3. Create empty virtual directory tree │
|
||||
│ directory_structure = FSNode({}, {}) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ for item in lib.items(): │
|
||||
│ │
|
||||
│ For each item in beets library: │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 1. Build template mapping │ │
|
||||
│ │ mapping = { │ │
|
||||
│ │ 'artist': 'Pink Floyd', │ │
|
||||
│ │ 'album': 'The Wall', │ │
|
||||
│ │ 'year': '1979', │ │
|
||||
│ │ 'format_upper': 'FLAC', │ │
|
||||
│ │ 'track': '01', │ │
|
||||
│ │ 'title': 'In The Flesh?', │ │
|
||||
│ │ } │ │
|
||||
│ │ │ │
|
||||
│ │ 2. Substitute template for each level │ │
|
||||
│ │ level_subbed[0] = "Pink Floyd" │ │
|
||||
│ │ level_subbed[1] = "The Wall (1979) [FLAC]" │ │
|
||||
│ │ level_subbed[2] = "01 - Pink Floyd - In The Flesh?.flac" │ │
|
||||
│ │ │ │
|
||||
│ │ 3. Add directories to tree │ │
|
||||
│ │ directory_structure.adddir([], "Pink Floyd") │ │
|
||||
│ │ directory_structure.adddir(["Pink Floyd"], "The Wall (1979)...") │ │
|
||||
│ │ │ │
|
||||
│ │ 4. Add file entry (filename → item.id) │ │
|
||||
│ │ directory_structure.addfile( │ │
|
||||
│ │ ["Pink Floyd", "The Wall (1979) [FLAC]"], │ │
|
||||
│ │ "01 - Pink Floyd - In The Flesh?.flac", │ │
|
||||
│ │ item.id # e.g., 42 │ │
|
||||
│ │ ) │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFileSystem FUSE Server │
|
||||
│ │
|
||||
│ server = beetFileSystem(...) │
|
||||
│ server.multithreaded = 0 │
|
||||
│ server.main() ← Enters FUSE event loop │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. File Open Flow
|
||||
|
||||
```
|
||||
Application: open("/mount/Pink Floyd/The Wall (1979) [FLAC]/01 - Pink Floyd - In The Flesh?.flac")
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFileSystem.open(path, flags) │
|
||||
│ Lines 988-1021 │
|
||||
│ │
|
||||
│ path = "/Pink Floyd/The Wall (1979) [FLAC]/01 - Pink Floyd - In The..." │
|
||||
│ flags = os.O_RDONLY (or O_RDWR) │
|
||||
│ │
|
||||
│ if path in self.files: │
|
||||
│ # File already open - increment reference count │
|
||||
│ self.files[path].open() │
|
||||
│ return self.files[path] │
|
||||
│ else: │
|
||||
│ # Create new FileHandler │
|
||||
│ self.files[path] = FileHandler(path, self.lib) │
|
||||
│ return self.files[path] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FileHandler.__init__(path, lib) │
|
||||
│ Lines 440-483 │
|
||||
│ │
|
||||
│ Step 1: Resolve virtual path to beets item │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ pathsplit = ["Pink Floyd", "The Wall (1979) [FLAC]", │ │
|
||||
│ │ "01 - Pink Floyd - In The Flesh?.flac"] │ │
|
||||
│ │ │ │
|
||||
│ │ # Navigate to parent directory in virtual tree │ │
|
||||
│ │ node = directory_structure.getnode(pathsplit[0:2]) │ │
|
||||
│ │ # node.files = {"01 - Pink Floyd - In The Flesh?.flac": 42, ...} │ │
|
||||
│ │ │ │
|
||||
│ │ # Get beets item by ID │ │
|
||||
│ │ item_id = node.files[pathsplit[2]] # 42 │ │
|
||||
│ │ self.item = lib.get_item(id=42) │ │
|
||||
│ │ self.real_path = self.item.path │ │
|
||||
│ │ # e.g., "/mnt/music/torrents/pink_floyd_wall.flac" │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 2: Open real file and detect format │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.file_object = open(self.real_path, 'r+') │ │
|
||||
│ │ self.format = "flac" # from file extension │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 3: Create InterpolatedFLAC with database metadata │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.inf = InterpolatedFLAC(self.file_object.read()) │ │
|
||||
│ │ │ │
|
||||
│ │ # INJECT DATABASE METADATA (this is the key operation!) │ │
|
||||
│ │ self.inf["title"] = self.item.title # "In The Flesh?" │ │
|
||||
│ │ self.inf["album"] = self.item.album # "The Wall" │ │
|
||||
│ │ self.inf["artist"] = self.item.artist # "Pink Floyd" │ │
|
||||
│ │ self.inf["genre"] = self.item.genre # "Progressive Rock" │ │
|
||||
│ │ │ │
|
||||
│ │ # Generate header with injected metadata │ │
|
||||
│ │ self.header = self.inf.get_header(self.real_path) │ │
|
||||
│ │ self.bound = len(self.header) # e.g., 8192 bytes │ │
|
||||
│ │ self.music_offset = self.inf.offset() │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 4: Cache audio data │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.file_object.seek(self.music_offset) │ │
|
||||
│ │ self.music_data = self.file_object.read() # All audio data │ │
|
||||
│ │ self.file_object.close() │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. File Read Flow
|
||||
|
||||
```
|
||||
Application: read(fd, buffer, 4096) # offset managed by kernel
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFileSystem.read(path, size, offset, fh) │
|
||||
│ Lines 1077-1106 │
|
||||
│ │
|
||||
│ path = "/Pink Floyd/The Wall (1979) [FLAC]/01 - ..." │
|
||||
│ size = 4096 │
|
||||
│ offset = 0 (first read) or previous offset + bytes_read │
|
||||
│ fh = FileHandler instance │
|
||||
│ │
|
||||
│ return self.files[path].read(size, offset) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FileHandler.read(size, offset) │
|
||||
│ Lines 497-517 │
|
||||
│ │
|
||||
│ Variables: │
|
||||
│ self.bound = 8192 (header size) │
|
||||
│ self.header = bytes (generated FLAC header with DB metadata) │
|
||||
│ self.music_data = bytes (original audio frames) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────────────┼───────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ Case 1: Header Only │ │ Case 2: Span Both │ │ Case 3: Audio Only │
|
||||
│ offset < bound │ │ offset < bound │ │ offset >= bound │
|
||||
│ offset+size < bound │ │ offset+size >= bound│ │ │
|
||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||
│ Example: │ │ Example: │ │ Example: │
|
||||
│ offset=0 │ │ offset=8000 │ │ offset=10000 │
|
||||
│ size=4096 │ │ size=4096 │ │ size=4096 │
|
||||
│ bound=8192 │ │ bound=8192 │ │ bound=8192 │
|
||||
├─────────────────────┤ ├─────────────────────┤ ├─────────────────────┤
|
||||
│ Return: │ │ Return: │ │ Return: │
|
||||
│ header[0:4096] │ │ header[8000:8192] │ │ music_data[ │
|
||||
│ │ │ + music_data[0:3904]│ │ 1808:5904] │
|
||||
│ (DB metadata!) │ │ │ │ │
|
||||
│ │ │ (mixed) │ │ (original audio) │
|
||||
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘
|
||||
|
||||
|
||||
Visual representation of virtual file:
|
||||
|
||||
0 bound (8192) EOF
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────────────────┬────────────────────────────────────────────┐
|
||||
│ HEADER │ AUDIO DATA │
|
||||
│ (self.header) │ (self.music_data) │
|
||||
│ │ │
|
||||
│ Contains: │ Contains: │
|
||||
│ - "fLaC" magic │ - Original FLAC frames │
|
||||
│ - STREAMINFO block │ - Unchanged from disk │
|
||||
│ - VORBIS_COMMENT │ │
|
||||
│ with DB values: │ │
|
||||
│ title, artist, │ │
|
||||
│ album, genre │ │
|
||||
│ - PADDING block │ │
|
||||
└───────────────────────┴────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ │
|
||||
From InterpolatedFLAC From original file
|
||||
with injected DB tags (passed through)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. File Write Flow
|
||||
|
||||
```
|
||||
Application: write(fd, "TITLE=New Title\0", 16) # Hypothetical tag edit
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFileSystem.write(path, buf, offset, fh) │
|
||||
│ Lines 1108-1135 │
|
||||
│ │
|
||||
│ return self.files[path].write(offset, buf) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FileHandler.write(offset, buf) │
|
||||
│ Lines 519-565 │
|
||||
│ │
|
||||
│ if offset >= self.bound: │
|
||||
│ # Write is in audio area - DISCARD │
|
||||
│ return # Do nothing, audio is read-only │
|
||||
│ │
|
||||
│ # Write is in header area - process tag update │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 1: Reconstruct full virtual file in memory │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ filedata = self.header + self.music_data │ │
|
||||
│ │ │ │
|
||||
│ │ # Patch in new data │ │
|
||||
│ │ filedata = filedata[0:offset] + buf + filedata[offset + len(buf):] │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 2: Parse patched data as FLAC │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.inf = InterpolatedFLAC(filedata) │ │
|
||||
│ │ # This parses the FLAC structure and extracts Vorbis comments │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 3: Extract tag values from parsed FLAC │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.item.title = str(self.inf["title"][0]).encode('utf-8') │ │
|
||||
│ │ self.item.album = str(self.inf["album"][0]).encode('utf-8') │ │
|
||||
│ │ self.item.artist = str(self.inf["artist"][0]).encode('utf-8') │ │
|
||||
│ │ self.item.genre = str(self.inf["genre"][0]).encode('utf-8') │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 4: Save to beets database │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.lib.store(self.item) # Update item in library │ │
|
||||
│ │ self.lib.save() # Persist to SQLite │ │
|
||||
│ │ │ │
|
||||
│ │ # NOTE: Original file on disk is NEVER touched! │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 5: Regenerate header for subsequent reads │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ self.inf["title"] = self.item.title │ │
|
||||
│ │ self.inf["album"] = self.item.album │ │
|
||||
│ │ self.inf["artist"] = self.item.artist │ │
|
||||
│ │ self.inf["genre"] = self.item.genre │ │
|
||||
│ │ │ │
|
||||
│ │ self.header = self.inf.get_header(self.real_path) │ │
|
||||
│ │ self.bound = len(self.header) │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ return len(buf) # Success │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
Write data flow summary:
|
||||
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Application │ │ beetfs │ │ Beets │ │ Original │
|
||||
│ writes │────▶│ parses │────▶│ database │ │ file │
|
||||
│ new tags │ │ extracts │ │ updated │ │ UNTOUCHED │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. File Release Flow
|
||||
|
||||
```
|
||||
Application: close(fd)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFileSystem.release(path, flags, fh) │
|
||||
│ Lines 1049-1059 │
|
||||
│ │
|
||||
│ if self.files[path].release(): │
|
||||
│ # Reference count reached 0, clean up │
|
||||
│ del self.files[path] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ FileHandler.release() │
|
||||
│ Lines 489-495 │
|
||||
│ │
|
||||
│ self.instance_count -= 1 │
|
||||
│ │
|
||||
│ if self.instance_count == 0: │
|
||||
│ return True # OK to delete │
|
||||
│ else: │
|
||||
│ return False # Still in use │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Directory Listing Flow
|
||||
|
||||
```
|
||||
Application: ls /mount/Pink\ Floyd/
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ beetFileSystem.readdir(path, offset, dh) │
|
||||
│ Lines 931-975 │
|
||||
│ │
|
||||
│ path = "/Pink Floyd" │
|
||||
│ pathsplit = ["Pink Floyd"] │
|
||||
│ │
|
||||
│ yield fuse.Direntry(".") │
|
||||
│ yield fuse.Direntry("..") │
|
||||
│ │
|
||||
│ # len(pathsplit) == 1, structure_depth - 1 == 2 │
|
||||
│ # So we're listing directories (albums), not files │
|
||||
│ │
|
||||
│ for dirname in directory_structure.listdir(pathsplit, True): │
|
||||
│ yield fuse.Direntry(dirname.encode('utf-8')) │
|
||||
│ # "The Wall (1979) [FLAC]" │
|
||||
│ # "Animals (1977) [FLAC]" │
|
||||
│ # etc. │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Complete Request Lifecycle
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ COMPLETE LIFECYCLE │
|
||||
│ │
|
||||
│ 1. User mounts: beet mount /mnt/music │
|
||||
│ ├─ Build virtual tree from beets library │
|
||||
│ └─ Start FUSE event loop │
|
||||
│ │
|
||||
│ 2. Application opens file: open("/mnt/music/Artist/Album/track.flac") │
|
||||
│ ├─ Resolve virtual path to beets item ID │
|
||||
│ ├─ Load original file into memory │
|
||||
│ ├─ Inject database metadata into FLAC structure │
|
||||
│ ├─ Generate new header with DB tags │
|
||||
│ └─ Cache audio data │
|
||||
│ │
|
||||
│ 3. Application reads file: read(fd, buf, 4096) │
|
||||
│ ├─ If reading header region → return header (DB metadata) │
|
||||
│ ├─ If reading audio region → return cached audio (original) │
|
||||
│ └─ If spanning both → return combined data │
|
||||
│ │
|
||||
│ 4. Application writes tags: write(fd, new_tags, offset) │
|
||||
│ ├─ If audio region → discard (read-only) │
|
||||
│ ├─ If header region: │
|
||||
│ │ ├─ Parse new tag values │
|
||||
│ │ ├─ Update beets database │
|
||||
│ │ └─ Regenerate header │
|
||||
│ └─ Original file NEVER modified │
|
||||
│ │
|
||||
│ 5. Application closes file: close(fd) │
|
||||
│ ├─ Decrement reference count │
|
||||
│ └─ Clean up if count == 0 │
|
||||
│ │
|
||||
│ 6. User unmounts: fusermount -u /mnt/music │
|
||||
│ └─ fsdestroy() called, cleanup │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -1,479 +0,0 @@
|
||||
# beetfs Drawbacks & Limitations
|
||||
|
||||
## Overview
|
||||
|
||||
This document catalogs all identified issues, limitations, and missing features in beetfs. Issues are categorized by severity and type.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues (🔴)
|
||||
|
||||
### 1. Full File Loading into Memory
|
||||
|
||||
**Location**: Lines 463, 480-481
|
||||
|
||||
```python
|
||||
self.inf = InterpolatedFLAC(self.file_object.read()) # Entire file
|
||||
# ...
|
||||
self.music_data = self.file_object.read() # Audio portion again
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Memory usage = O(file_size) per open file
|
||||
- 50MB FLAC = ~50MB RAM
|
||||
- Library scan of 100 files = 5GB+ RAM
|
||||
- Out-of-memory crashes on large libraries
|
||||
|
||||
**Fix Required**: Implement lazy loading with seek-based reads.
|
||||
|
||||
---
|
||||
|
||||
### 2. MP3 Support Disabled
|
||||
|
||||
**Location**: Lines 475-477
|
||||
|
||||
```python
|
||||
elif self.format == "mp3":
|
||||
self.bound = 0 # disable interpolation for now
|
||||
self.music_offset = 0 # disable interpolation for now
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- MP3 files return original metadata, not database metadata
|
||||
- Breaks the core promise of metadata overlay
|
||||
- MP3 is still one of the most common formats
|
||||
|
||||
**Fix Required**: Implement `InterpolatedID3` header generation.
|
||||
|
||||
---
|
||||
|
||||
### 3. Python 2 Only
|
||||
|
||||
**Location**: Throughout
|
||||
|
||||
```python
|
||||
except fuse.FuseError, e: # Python 2 syntax
|
||||
if isinstance(value, basestring): # Removed in Python 3
|
||||
return reduce(lambda a, b: (a << 8) + ord(b), string, 0L) # Long literals
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Python 2 EOL was January 2020
|
||||
- Security vulnerabilities unfixed
|
||||
- No modern library support
|
||||
- Cannot run on Python 3 without migration
|
||||
|
||||
**Fix Required**: Full Python 3 migration (see modernization.md).
|
||||
|
||||
---
|
||||
|
||||
### 4. Deprecated FUSE Library
|
||||
|
||||
**Location**: Line 25, 51
|
||||
|
||||
```python
|
||||
import fuse
|
||||
fuse.fuse_python_api = (0, 2)
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- fuse-python is unmaintained
|
||||
- Missing modern FUSE features (FUSE 3.x)
|
||||
- Compatibility issues with recent kernels
|
||||
- No async support
|
||||
|
||||
**Fix Required**: Migrate to pyfuse3 or llfuse.
|
||||
|
||||
---
|
||||
|
||||
### 5. Single-Threaded Execution
|
||||
|
||||
**Location**: Line 178
|
||||
|
||||
```python
|
||||
server.multithreaded = 0
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- All operations serialized
|
||||
- One slow open blocks all other operations
|
||||
- Cannot utilize multiple CPU cores
|
||||
- Poor performance under concurrent access
|
||||
|
||||
**Fix Required**: Enable multithreading with proper locking.
|
||||
|
||||
---
|
||||
|
||||
## Major Issues (🟡)
|
||||
|
||||
### 6. Limited Metadata Fields
|
||||
|
||||
**Location**: Lines 466-469, 540-547
|
||||
|
||||
```python
|
||||
# Only these 4 fields are actually used:
|
||||
self.inf["title"] = self.item.title
|
||||
self.inf["album"] = self.item.album
|
||||
self.inf["artist"] = self.item.artist
|
||||
self.inf["genre"] = self.item.genre
|
||||
```
|
||||
|
||||
**Defined but not implemented** (lines 55-77):
|
||||
- `composer`, `grouping`
|
||||
- `year`, `month`, `day`
|
||||
- `track`, `tracktotal`
|
||||
- `disc`, `disctotal`
|
||||
- `lyrics`, `comments`
|
||||
- `bpm`, `comp`
|
||||
- `albumartist` (not even defined)
|
||||
|
||||
**Impact**:
|
||||
- Track numbers not from database
|
||||
- Album artist not supported
|
||||
- Year/date not interpolated
|
||||
- Cover art not handled
|
||||
|
||||
---
|
||||
|
||||
### 7. No File Handle Caching/Eviction
|
||||
|
||||
**Location**: Lines 1004-1018
|
||||
|
||||
```python
|
||||
if path in self.files:
|
||||
self.files[path].open()
|
||||
else:
|
||||
self.files[path] = FileHandler(path, self.lib)
|
||||
```
|
||||
|
||||
**Missing**:
|
||||
- No maximum cache size
|
||||
- No LRU eviction
|
||||
- No memory pressure handling
|
||||
- Files stay in memory until explicitly closed
|
||||
|
||||
**Impact**:
|
||||
- Memory grows unbounded
|
||||
- No protection against OOM
|
||||
- Applications that open-then-close still leave data cached
|
||||
|
||||
---
|
||||
|
||||
### 8. Blocking Database Operations
|
||||
|
||||
**Location**: Lines 549-550
|
||||
|
||||
```python
|
||||
self.lib.store(self.item)
|
||||
self.lib.save()
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- SQLite operations in FUSE thread
|
||||
- Write operations block all reads
|
||||
- No transaction batching
|
||||
- Potential deadlocks with beets
|
||||
|
||||
---
|
||||
|
||||
### 9. No Library Hot Reload
|
||||
|
||||
**Issue**: Virtual directory tree built once at mount time.
|
||||
|
||||
**Location**: Lines 142-172
|
||||
|
||||
```python
|
||||
for item in lib.items():
|
||||
# Build tree...
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- New files added to beets library not visible
|
||||
- Deleted files still appear (ENOENT on access)
|
||||
- Metadata changes in beets not reflected until remount
|
||||
- Must unmount/remount to see changes
|
||||
|
||||
---
|
||||
|
||||
### 10. Static Path Format
|
||||
|
||||
**Location**: Lines 44-45
|
||||
|
||||
```python
|
||||
PATH_FORMAT = ("$artist/$album ($year) [$format_upper]/"
|
||||
"$track - $artist - $title.$format")
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Cannot customize organization
|
||||
- Hard-coded template
|
||||
- No configuration option
|
||||
- Incompatible with different organizational preferences
|
||||
|
||||
---
|
||||
|
||||
### 11. No Extended Attribute Support
|
||||
|
||||
**Location**: Not implemented
|
||||
|
||||
**Impact**:
|
||||
- Cannot store/retrieve xattrs
|
||||
- Some applications use xattrs for metadata
|
||||
- macOS Finder metadata lost
|
||||
- Linux capabilities not supported
|
||||
|
||||
---
|
||||
|
||||
### 12. No Symlink Support
|
||||
|
||||
**Location**: Lines 758-765
|
||||
|
||||
```python
|
||||
def readlink(self, path):
|
||||
return -errno.EOPNOTSUPP
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Cannot create symlinks in mount
|
||||
- Some applications expect symlink support
|
||||
- Cannot link to external files
|
||||
|
||||
---
|
||||
|
||||
### 13. Silent Error Swallowing
|
||||
|
||||
**Location**: Lines 705-707, 1019-1021, 1103-1104
|
||||
|
||||
```python
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
return -errno.ENOENT # Always returns same error
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- All errors appear as "file not found"
|
||||
- Hard to debug issues
|
||||
- No distinction between permission, I/O, parse errors
|
||||
- Lost stack traces in many cases
|
||||
|
||||
---
|
||||
|
||||
## Minor Issues (🟢)
|
||||
|
||||
### 14. Global State
|
||||
|
||||
**Location**: Lines 125-140
|
||||
|
||||
```python
|
||||
global structure_split
|
||||
global structure_depth
|
||||
global library
|
||||
global directory_structure
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Cannot mount multiple instances
|
||||
- Difficult to unit test
|
||||
- Tight coupling between components
|
||||
- No dependency injection
|
||||
|
||||
---
|
||||
|
||||
### 15. Hard-coded Log File
|
||||
|
||||
**Location**: Lines 624-625
|
||||
|
||||
```python
|
||||
LOG_FILENAME = "LOG"
|
||||
logging.basicConfig(filename=LOG_FILENAME, level=logging.INFO,)
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- Log file created in current directory
|
||||
- No log rotation
|
||||
- No configurable log level
|
||||
- Fills disk on busy systems
|
||||
|
||||
---
|
||||
|
||||
### 16. Reference Count Manual Management
|
||||
|
||||
**Location**: Lines 485-495
|
||||
|
||||
```python
|
||||
def open(self):
|
||||
self.instance_count = self.instance_count + 1
|
||||
|
||||
def release(self):
|
||||
if self.instance_count > 0:
|
||||
self.instance_count = self.instance_count - 1
|
||||
```
|
||||
|
||||
**Issues**:
|
||||
- Race conditions possible if multithreaded
|
||||
- No context manager support
|
||||
- Manual counting error-prone
|
||||
- Off-by-one potential
|
||||
|
||||
---
|
||||
|
||||
### 17. Inefficient Directory Building
|
||||
|
||||
**Location**: Lines 153-172
|
||||
|
||||
```python
|
||||
for level in range(0, structure_depth - 1):
|
||||
if level-1 in level_subbed:
|
||||
sub_elements.append(level_subbed[level-1])
|
||||
directory_structure.adddir(sub_elements, level_subbed[level])
|
||||
```
|
||||
|
||||
**Issues**:
|
||||
- Rebuilds path for every item
|
||||
- O(items × depth) complexity
|
||||
- String allocations in inner loop
|
||||
- Could use trie-based insertion
|
||||
|
||||
---
|
||||
|
||||
### 18. No Cover Art Handling
|
||||
|
||||
**Issue**: Cover art embedded in FLAC not addressed.
|
||||
|
||||
**Impact**:
|
||||
- Cover art from original file used, not database
|
||||
- Cannot replace/add cover art through overlay
|
||||
- PICTURE metadata blocks passed through unchanged
|
||||
|
||||
---
|
||||
|
||||
### 19. No Cue Sheet Support
|
||||
|
||||
**Issue**: Cue sheets not handled specially.
|
||||
|
||||
**Impact**:
|
||||
- `.cue` files point to original file paths
|
||||
- Cannot play cue-referenced tracks correctly
|
||||
- Split-by-cue not supported
|
||||
|
||||
---
|
||||
|
||||
### 20. File Size Mismatch Potential
|
||||
|
||||
**Issue**: Virtual file size differs from physical if header size changes.
|
||||
|
||||
**Location**: Lines 675-688
|
||||
|
||||
```python
|
||||
statinfo = os.stat(item)
|
||||
st = Stat(st_mode=statinfo.st_mode,
|
||||
st_size=statinfo.st_size, # Original size, not virtual!
|
||||
...)
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- `stat()` returns original file size
|
||||
- If generated header is larger/smaller, size is wrong
|
||||
- Some applications may fail on size mismatch
|
||||
- Range requests could break
|
||||
|
||||
---
|
||||
|
||||
## Missing Features
|
||||
|
||||
### Essential
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| MP3 metadata interpolation | ❌ Disabled | Code exists but disabled |
|
||||
| OGG/Opus support | ❌ Missing | No implementation |
|
||||
| AAC/M4A support | ❌ Missing | No implementation |
|
||||
| Lazy file loading | ❌ Missing | Full file loaded |
|
||||
| Memory management | ❌ Missing | No limits or eviction |
|
||||
| Configuration file | ❌ Missing | Hard-coded values |
|
||||
|
||||
### Nice to Have
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Cover art interpolation | ❌ Missing | Would need PICTURE block handling |
|
||||
| ReplayGain from database | ❌ Missing | Tags not interpolated |
|
||||
| Lyrics from database | ❌ Missing | Listed in fields, not implemented |
|
||||
| Watch mode (hot reload) | ❌ Missing | No inotify integration |
|
||||
| Multiple mount points | ❌ Missing | Global state prevents |
|
||||
| Remote database | ❌ Missing | Local beets only |
|
||||
| Read-only mode | ❌ Missing | Always allows writes |
|
||||
| Custom path templates | ❌ Missing | Hard-coded PATH_FORMAT |
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### 1. No Input Validation
|
||||
|
||||
**Location**: Throughout
|
||||
|
||||
```python
|
||||
pathsplit = path[1:].split('/')
|
||||
item_id = node.files[pathsplit[structure_depth-1]] # No bounds check
|
||||
```
|
||||
|
||||
**Risk**: Path traversal, injection attacks unlikely but possible.
|
||||
|
||||
### 2. Database Credentials Exposed
|
||||
|
||||
**Issue**: Uses beets library directly with stored credentials.
|
||||
|
||||
**Risk**: Low - local access only.
|
||||
|
||||
### 3. No Permission Enforcement
|
||||
|
||||
**Location**: Lines 749-756
|
||||
|
||||
```python
|
||||
if flags | os.R_OK:
|
||||
pass # TODO: actually check the file permissions
|
||||
if flags | os.W_OK:
|
||||
pass
|
||||
```
|
||||
|
||||
**Risk**: All users can read/write through mount.
|
||||
|
||||
---
|
||||
|
||||
## Compatibility Issues
|
||||
|
||||
| Component | Issue |
|
||||
|-----------|-------|
|
||||
| **Jellyfin** | May scan entire library, causing OOM |
|
||||
| **Plex** | Same library scan issue |
|
||||
| **Navidrome** | Expects certain tag fields not implemented |
|
||||
| **mpd** | Works for playback, database features limited |
|
||||
| **macOS** | fuse-python macOS support questionable |
|
||||
| **Docker** | FUSE in containers requires privileged mode |
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Category | Critical | Major | Minor |
|
||||
|----------|----------|-------|-------|
|
||||
| Performance | 2 | 4 | 2 |
|
||||
| Functionality | 2 | 5 | 4 |
|
||||
| Code Quality | 2 | 2 | 4 |
|
||||
| **Total** | **6** | **11** | **10** |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Fix List
|
||||
|
||||
1. 🔴 **Memory**: Implement lazy loading (Critical for usability)
|
||||
2. 🔴 **Python 3**: Migrate to Python 3 (Required for any changes)
|
||||
3. 🔴 **FUSE lib**: Switch to pyfuse3/llfuse (Required for Python 3)
|
||||
4. 🔴 **MP3**: Enable MP3 interpolation (Core functionality)
|
||||
5. 🟡 **Metadata**: Implement all fields (Feature completeness)
|
||||
6. 🟡 **Threading**: Enable multithreading (Performance)
|
||||
7. 🟡 **Config**: Add configuration file (Usability)
|
||||
8. 🟡 **Hot reload**: Watch for library changes (Usability)
|
||||
9. 🟢 **Globals**: Remove global state (Code quality)
|
||||
10. 🟢 **Logging**: Configurable logging (Operations)
|
||||
@@ -1,493 +0,0 @@
|
||||
# beetfs E2E Test Plan
|
||||
|
||||
> **Reviewed by Oracle** - Critical bug discovered, plan updated accordingly
|
||||
|
||||
## Test Results (Latest Run)
|
||||
|
||||
```
|
||||
Tests run: 74
|
||||
Passed: 12
|
||||
Failures: 56
|
||||
Errors: 3
|
||||
Skipped: 3
|
||||
Duration: ~103 seconds
|
||||
```
|
||||
|
||||
### Bugs Detected by Tests
|
||||
|
||||
| Bug | Tests Affected | Description |
|
||||
|-----|----------------|-------------|
|
||||
| **Nested Methods** | 56 | Lines 758-1144 indented inside `access()` - FUSE operations unreachable |
|
||||
| **Directory Tree Building** | 3 | `KeyError` in `FSNode.getnode()` when adding files |
|
||||
| **Unmount** | 1 | Filesystem not unmounting cleanly |
|
||||
|
||||
### Passing Tests (12)
|
||||
|
||||
- `test_fuse_available` - FUSE/fusermount detected
|
||||
- `test_library_fixture_created` - SQLite DB and music dir created
|
||||
- `test_temp_directory_created` - Temp dirs set up correctly
|
||||
- `test_mount_empty_library` - **Mount works with empty library!**
|
||||
- `test_list_empty_root` - Empty root returns empty list
|
||||
- `test_list_root_returns_list` - Returns list type
|
||||
- `test_access_empty_path` - Handles empty path
|
||||
- Plus 5 nested bug detection tests (confirming bug exists)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
E2E tests for beetfs FUSE filesystem using real music files from qBittorrent container. No mocks - actual filesystem operations against mounted beetfs.
|
||||
|
||||
### Critical Finding
|
||||
|
||||
**BUG DISCOVERED**: Lines 758-1144 in `beetFs.py` are indented inside `access()` method, making these FUSE operations unreachable as class methods:
|
||||
- `readdir`, `open`, `read`, `write`, `mkdir`, `unlink`, `rmdir`, `symlink`, `link`, `rename`, `chmod`, `chown`, `truncate`, `opendir`, `releasedir`, `fsyncdir`, `create`, `fgetattr`, `release`, `fsync`, `flush`, `ftruncate`
|
||||
|
||||
Tests will expose this immediately - write `test_readdir.py` first.
|
||||
|
||||
---
|
||||
|
||||
## Test Environment
|
||||
|
||||
| Component | Status | Details |
|
||||
|-----------|--------|---------|
|
||||
| Real Music | Available | Metallica "72 Seasons" (12 FLAC, 650MB) at `/home/fujin/.local/share/docker/volumes/containers_downloads/_data/Metallica - 72 Seasons (2023) [FLAC] 88/` |
|
||||
| Synthetic Music | Create | 5-10MB FLACs for most tests (avoid RAM explosion) |
|
||||
| Beets Config | Create | `~/.config/beets/config.yaml` for test isolation |
|
||||
| Beets Library | Empty | Needs import of test files |
|
||||
| Python | 2.7.15 | Via Nix flake (nixpkgs-18.09) |
|
||||
| Test Framework | unittest | stdlib, no external deps for Py2.7 |
|
||||
|
||||
---
|
||||
|
||||
## Test Architecture
|
||||
|
||||
```
|
||||
beetfs/tests/
|
||||
├── __init__.py
|
||||
├── conftest.py # Test fixtures, beets library setup, synthetic FLAC creation
|
||||
├── test_smoke.py # Mount/unmount lifecycle (run FIRST)
|
||||
├── test_nested_bug.py # Verify the indentation bug (run SECOND)
|
||||
├── test_readdir.py # Directory listing operations
|
||||
├── test_read.py # File reading with metadata overlay (CORE FEATURE)
|
||||
├── test_stat.py # getattr, fgetattr, statfs
|
||||
├── test_write.py # Metadata write operations
|
||||
├── test_error_handling.py # ENOENT, EOPNOTSUPP scenarios
|
||||
├── test_edge_cases.py # Unicode, concurrent opens, special chars
|
||||
├── test_integration.py # Real 650MB files (skip by default)
|
||||
└── fixtures/
|
||||
├── synthetic/ # Generated 5-10MB test FLACs
|
||||
└── real -> /home/fujin/.local/share/docker/volumes/containers_downloads/_data/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Tiers
|
||||
|
||||
### Tier 1: Unit-ish (Synthetic FLACs, ~500KB each)
|
||||
- Fast execution
|
||||
- No memory issues (FileHandler loads entire file to RAM)
|
||||
- Run on every commit
|
||||
|
||||
### Tier 2: Integration (Subset of real files, 1-2 tracks)
|
||||
- Uses real Metallica FLACs
|
||||
- Tests real-world metadata
|
||||
- Run before merge
|
||||
|
||||
### Tier 3: E2E (All 12 tracks, 650MB)
|
||||
- Full album processing
|
||||
- Memory stress testing
|
||||
- Run via `E2E=1 python -m unittest discover`
|
||||
- Skip by default
|
||||
|
||||
---
|
||||
|
||||
## Test Isolation Strategy
|
||||
|
||||
| Resource | Strategy | Rationale |
|
||||
|----------|----------|-----------|
|
||||
| Audio Files | **Symlinks** for reads | beetfs NEVER writes to source files, only to beets DB |
|
||||
| Beets DB | **Copy per test** | Writes mutate DB; need isolation |
|
||||
| Mount Point | **Fresh tempdir** | Each test gets clean mount |
|
||||
| Global State | **Fresh subprocess** | `library`, `directory_structure` are module globals |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
> Reordered per Oracle recommendation: smoke → nested-bug → read → write → errors → edge
|
||||
|
||||
### Phase 1: Infrastructure (Day 1 AM)
|
||||
|
||||
1. Create `tests/` directory structure
|
||||
2. Implement `BeetFSTestCase` base class with:
|
||||
- Subprocess timeout via `threading.Timer` (Py2.7 compatible)
|
||||
- Mount wait polling (`os.path.ismount()`)
|
||||
- Proper cleanup (`fusermount -u`)
|
||||
3. Create synthetic FLAC generator using ffmpeg + flac CLI
|
||||
4. Setup isolated beets config and library
|
||||
|
||||
### Phase 2: Bug Detection (Day 1 PM)
|
||||
|
||||
5. `test_smoke.py` - Mount/unmount lifecycle
|
||||
6. `test_nested_bug.py` - Verify `readdir`, `open` are callable (will fail, exposing bug)
|
||||
|
||||
### Phase 3: Core Tests (Day 2)
|
||||
|
||||
7. `test_readdir.py` - Directory listing
|
||||
8. `test_read.py` - **Metadata overlay verification** (critical)
|
||||
9. `test_stat.py` - File/directory attributes
|
||||
|
||||
### Phase 4: Write & Errors (Day 3)
|
||||
|
||||
10. `test_write.py` - Metadata modification, DB persistence
|
||||
11. `test_error_handling.py` - ENOENT, EOPNOTSUPP
|
||||
|
||||
### Phase 5: Edge Cases (Day 3-4)
|
||||
|
||||
12. `test_edge_cases.py` - Unicode, concurrent opens, special chars
|
||||
13. `test_integration.py` - Real 650MB files (optional tier)
|
||||
|
||||
---
|
||||
|
||||
## Test Categories
|
||||
|
||||
### 1. Smoke Tests (`test_smoke.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_mount_success` | Mount beetfs | `os.path.ismount()` returns True |
|
||||
| `test_unmount_clean` | Unmount | Process exits 0, dir accessible |
|
||||
| `test_mount_empty_library` | Mount with 0 items | Mounts successfully, root empty |
|
||||
| `test_mount_invalid_path` | Mount to non-existent | Fails gracefully |
|
||||
| `test_fsinit_called` | Check initialization | No crash on mount |
|
||||
|
||||
### 2. Nested Methods Bug (`test_nested_bug.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_readdir_exists` | `hasattr(beetFileSystem, 'readdir')` | True (currently False!) |
|
||||
| `test_open_exists` | `hasattr(beetFileSystem, 'open')` | True (currently False!) |
|
||||
| `test_read_exists` | `hasattr(beetFileSystem, 'read')` | True (currently False!) |
|
||||
| `test_readdir_callable` | `os.listdir(mount)` | Returns list (currently fails!) |
|
||||
|
||||
### 3. Directory Operations (`test_readdir.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_list_root` | `os.listdir(mount)` | Returns artist directories |
|
||||
| `test_list_artist` | `os.listdir(mount/artist)` | Returns album directories |
|
||||
| `test_list_album` | `os.listdir(mount/artist/album)` | Returns track files |
|
||||
| `test_path_format` | Check structure | Matches `$artist/$album ($year) [$format_upper]/$track - $artist - $title.$format` |
|
||||
| `test_unicode_paths` | Non-ASCII chars | Handles "Lux Aeterna" correctly |
|
||||
|
||||
### 4. Read Operations (`test_read.py`) - CORE FEATURE
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_read_header_overlay` | Read + parse with mutagen | Tags match DB, not file |
|
||||
| `test_read_audio_passthrough` | Compare audio bytes | Identical to original after header |
|
||||
| `test_read_full_file` | Read entire file | Header from DB + audio from file |
|
||||
| `test_metadata_artist` | Check artist tag | DB value, not file value |
|
||||
| `test_metadata_title` | Check title tag | DB value, not file value |
|
||||
| `test_metadata_album` | Check album tag | DB value, not file value |
|
||||
| `test_metadata_genre` | Check genre tag | DB value, not file value |
|
||||
| `test_original_unchanged` | Read original file | Original metadata intact |
|
||||
|
||||
#### Metadata Overlay Verification Pattern
|
||||
|
||||
```python
|
||||
import mutagen.flac
|
||||
from io import BytesIO
|
||||
|
||||
def test_read_header_overlay(self):
|
||||
# Setup: Import file, modify DB metadata
|
||||
# beet import /path/to/file
|
||||
# beet modify artist="DB Artist" # File has "Original Artist"
|
||||
|
||||
# Read mounted file as bytes
|
||||
with open(os.path.join(self.mount_dir, 'DB Artist/...'), 'rb') as f:
|
||||
mounted_data = f.read()
|
||||
|
||||
# Parse with mutagen
|
||||
flac = mutagen.flac.FLAC(BytesIO(mounted_data))
|
||||
|
||||
# Verify overlay worked
|
||||
self.assertEqual(flac['artist'][0], 'DB Artist') # From DB
|
||||
self.assertNotEqual(flac['artist'][0], 'Original Artist') # Not from file
|
||||
```
|
||||
|
||||
### 5. Stat Operations (`test_stat.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_stat_file` | `os.stat(file)` | Valid stat with size, mtime |
|
||||
| `test_stat_directory` | `os.stat(dir)` | Directory mode (S_IFDIR) |
|
||||
| `test_statfs` | `os.statvfs(mount)` | Valid filesystem stats |
|
||||
| `test_access_read` | `os.access(file, R_OK)` | True |
|
||||
| `test_access_write` | `os.access(file, W_OK)` | True (header writable) |
|
||||
|
||||
### 6. Write Operations (`test_write.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_write_title` | Modify title in header | DB updated, file unchanged |
|
||||
| `test_write_artist` | Modify artist | DB updated |
|
||||
| `test_write_album` | Modify album | DB updated |
|
||||
| `test_write_genre` | Modify genre | DB updated |
|
||||
| `test_write_audio_discarded` | Write at offset > bound | Silently discarded |
|
||||
| `test_write_persistence` | Write -> unmount -> remount | Changes persisted in DB |
|
||||
| `test_write_mp3_noop` | Write to MP3 header | No error, but no effect (bound=0) |
|
||||
|
||||
### 7. Error Handling (`test_error_handling.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_enoent_file` | Read non-existent | `OSError(ENOENT)` |
|
||||
| `test_enoent_dir` | List non-existent | `OSError(ENOENT)` |
|
||||
| `test_eopnotsupp_mkdir` | `os.mkdir()` | `OSError(EOPNOTSUPP)` |
|
||||
| `test_eopnotsupp_unlink` | `os.unlink()` | `OSError(EOPNOTSUPP)` |
|
||||
| `test_eopnotsupp_rename` | `os.rename()` | `OSError(EOPNOTSUPP)` |
|
||||
| `test_eopnotsupp_symlink` | `os.symlink()` | `OSError(EOPNOTSUPP)` |
|
||||
|
||||
### 8. Edge Cases (`test_edge_cases.py`)
|
||||
|
||||
| Test | Operation | Expected |
|
||||
|------|-----------|----------|
|
||||
| `test_special_chars_sanitized` | Path with `?/` | Sanitized via `sanitize()` |
|
||||
| `test_concurrent_opens` | Open same file twice | `instance_count` increments |
|
||||
| `test_concurrent_release` | Release after double open | File stays cached until count=0 |
|
||||
| `test_unicode_metadata` | Non-ASCII in artist/title | Handled correctly |
|
||||
| `test_empty_metadata` | None/empty fields | Doesn't crash |
|
||||
| `test_mp3_no_interpolation` | Read MP3 | Returns original file (no overlay) |
|
||||
|
||||
### 9. Integration (`test_integration.py`)
|
||||
|
||||
| Test | Env Var | Expected |
|
||||
|------|---------|----------|
|
||||
| `test_real_album_listing` | `E2E=1` | Lists all 12 Metallica tracks |
|
||||
| `test_real_file_read` | `E2E=1` | Reads 67MB file successfully |
|
||||
| `test_memory_usage` | `E2E=1` | Documents but doesn't fail on high RAM |
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure Code
|
||||
|
||||
### Base Test Class (Python 2.7 Compatible)
|
||||
|
||||
```python
|
||||
# tests/conftest.py
|
||||
import unittest
|
||||
import subprocess
|
||||
import tempfile
|
||||
import shutil
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
|
||||
class BeetFSTestCase(unittest.TestCase):
|
||||
"""Base class for beetfs e2e tests - Python 2.7 compatible"""
|
||||
|
||||
MOUNT_TIMEOUT = 30 # seconds
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Check FUSE availability"""
|
||||
try:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.check_call(['which', 'fusermount'],
|
||||
stdout=devnull, stderr=devnull)
|
||||
except subprocess.CalledProcessError:
|
||||
raise unittest.SkipTest("fusermount not available")
|
||||
|
||||
def setUp(self):
|
||||
self.mount_dir = tempfile.mkdtemp(prefix='beetfs_test_')
|
||||
self.fs_process = None
|
||||
|
||||
def mount_beetfs(self, library_path=None):
|
||||
"""Mount beetfs in background with timeout"""
|
||||
cmd = ['python', '-c',
|
||||
'from beetsplug.beetFs import mount; mount()']
|
||||
# Add mount point and other args as needed
|
||||
|
||||
self.fs_process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=open(os.devnull, 'w'),
|
||||
stderr=subprocess.STDOUT
|
||||
)
|
||||
|
||||
# Python 2.7 timeout workaround
|
||||
timer = threading.Timer(self.MOUNT_TIMEOUT, self._timeout_kill)
|
||||
timer.start()
|
||||
|
||||
try:
|
||||
self._wait_for_mount()
|
||||
finally:
|
||||
timer.cancel()
|
||||
|
||||
def _timeout_kill(self):
|
||||
if self.fs_process and self.fs_process.poll() is None:
|
||||
self.fs_process.kill()
|
||||
|
||||
def _wait_for_mount(self):
|
||||
"""Wait for filesystem to be mounted"""
|
||||
start = time.time()
|
||||
while time.time() - start < self.MOUNT_TIMEOUT:
|
||||
if os.path.ismount(self.mount_dir):
|
||||
return
|
||||
if self.fs_process.poll() is not None:
|
||||
self.fail("Filesystem process terminated prematurely")
|
||||
time.sleep(0.1)
|
||||
self.fail("Mount timeout after {} seconds".format(self.MOUNT_TIMEOUT))
|
||||
|
||||
def tearDown(self):
|
||||
"""Cleanup: unmount and kill process"""
|
||||
if self.fs_process:
|
||||
with open(os.devnull, 'w') as devnull:
|
||||
subprocess.call(['fusermount', '-z', '-u', self.mount_dir],
|
||||
stdout=devnull, stderr=devnull)
|
||||
|
||||
self.fs_process.terminate()
|
||||
|
||||
# Wait for termination (Py2.7 compatible)
|
||||
start = time.time()
|
||||
while time.time() - start < 5:
|
||||
if self.fs_process.poll() is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
self.fs_process.kill()
|
||||
|
||||
shutil.rmtree(self.mount_dir, ignore_errors=True)
|
||||
```
|
||||
|
||||
### Synthetic FLAC Generator
|
||||
|
||||
```python
|
||||
# tests/conftest.py (continued)
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
def create_synthetic_flac(duration_sec=5, artist="Test Artist",
|
||||
title="Test Track", album="Test Album"):
|
||||
"""Create minimal FLAC with known metadata (~500KB for 5s silence)"""
|
||||
wav_fd, wav_path = tempfile.mkstemp(suffix='.wav')
|
||||
os.close(wav_fd)
|
||||
flac_path = wav_path.replace('.wav', '.flac')
|
||||
|
||||
try:
|
||||
# Generate silence WAV
|
||||
subprocess.check_call([
|
||||
'ffmpeg', '-f', 'lavfi', '-i',
|
||||
'anullsrc=r=44100:cl=stereo', '-t', str(duration_sec),
|
||||
'-y', wav_path
|
||||
], stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||
|
||||
# Convert to FLAC with metadata
|
||||
subprocess.check_call([
|
||||
'flac', '--best',
|
||||
'-T', 'ARTIST={}'.format(artist),
|
||||
'-T', 'TITLE={}'.format(title),
|
||||
'-T', 'ALBUM={}'.format(album),
|
||||
'-o', flac_path, wav_path
|
||||
], stdout=open(os.devnull, 'w'), stderr=subprocess.STDOUT)
|
||||
|
||||
return flac_path
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add to flake.nix
|
||||
|
||||
```nix
|
||||
# In devShell buildInputs, add:
|
||||
pkgs.ffmpeg # For synthetic FLAC generation
|
||||
pkgs.flac # For FLAC encoding
|
||||
|
||||
# pythonEnv already has mutagen for verification
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Memory explosion | High | Use 5-10MB synthetic FLACs, skip 650MB tests by default |
|
||||
| Nested methods bug | Critical | Tests will expose; fix required before other tests pass |
|
||||
| Python 2.7 EOL | Medium | Nix provides isolated environment |
|
||||
| Global state pollution | Medium | Fresh subprocess per test |
|
||||
| FUSE permissions | Low | Run as regular user, skip privileged tests |
|
||||
| Concurrent access | Low | Single-threaded mode, sequential tests |
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **All smoke tests pass** - beetfs mounts and unmounts cleanly
|
||||
2. **Nested bug exposed and fixed** - All FUSE methods callable
|
||||
3. **Metadata overlay verified** - Reads return DB metadata, not file metadata
|
||||
4. **Writes update DB** - Metadata changes persist
|
||||
5. **Errors handled gracefully** - Correct errno for unsupported ops
|
||||
6. **No crashes on edge cases** - Unicode, special chars, concurrent access
|
||||
|
||||
---
|
||||
|
||||
## Findings from Test Execution
|
||||
|
||||
### Bug #1: Nested Methods (CRITICAL)
|
||||
|
||||
**Location**: `beetFs.py` lines 758-1144
|
||||
|
||||
**Problem**: All FUSE operation methods are indented inside the `access()` method, making them local functions instead of class methods.
|
||||
|
||||
**Evidence**:
|
||||
```python
|
||||
def access(self, path, flags): # Line 723 - correct class method
|
||||
...
|
||||
return 0
|
||||
|
||||
def readdir(self, path, ...): # Line 931 - WRONG! Nested inside access()
|
||||
...
|
||||
def open(self, path, flags): # Line 988 - Also nested
|
||||
...
|
||||
def read(self, path, ...): # Line 1077 - Also nested
|
||||
...
|
||||
```
|
||||
|
||||
**Symptom**: `os.listdir()` returns `OSError: [Errno 38] Function not implemented`
|
||||
|
||||
**Fix Required**: Dedent lines 758-1144 by 8 spaces to make them class methods.
|
||||
|
||||
### Bug #2: Directory Tree Building
|
||||
|
||||
**Location**: `beetFs.py` lines 403-414 (`FSNode.getnode()` and `FSNode.adddir()`)
|
||||
|
||||
**Problem**: When adding files to the directory structure, the code assumes parent directories already exist.
|
||||
|
||||
**Evidence**:
|
||||
```
|
||||
KeyError: u'Test Artist'
|
||||
File "beetFs.py", line 403, in getnode
|
||||
return self.getnode(elements, root=root.dirs[topdir])
|
||||
```
|
||||
|
||||
**Symptom**: Mount fails when library contains tracks.
|
||||
|
||||
### Bug #3: Unmount Not Clean
|
||||
|
||||
**Problem**: After unmounting, `os.path.ismount()` still returns `True`.
|
||||
|
||||
**Likely Cause**: FUSE process not terminating properly, or lazy unmount not completing.
|
||||
|
||||
---
|
||||
|
||||
## Notes from Oracle Review
|
||||
|
||||
1. **MP3 is not "readonly"** - metadata overlay is disabled (`bound=0`), but reads still work
|
||||
2. **Write returns None for MP3** - no explicit return in MP3 path (falls through)
|
||||
3. **Path format is hardcoded** - tests must match `$artist/$album ($year) [$format_upper]/$track - $artist - $title.$format`
|
||||
4. **basestring vs str** - use `isinstance(x, basestring)` for Py2.7 string checks
|
||||
5. **Global variables** - `library`, `directory_structure` must be reset between tests (use subprocesses)
|
||||
@@ -1,249 +0,0 @@
|
||||
# beetfs Feature Set
|
||||
|
||||
## Overview
|
||||
|
||||
beetfs is a FUSE filesystem plugin for [beets](https://beets.io/) that presents your music library as a virtual filesystem organized by metadata. Files appear with paths derived from their database metadata, and reading file headers returns metadata from the beets database rather than the actual file tags.
|
||||
|
||||
**Author**: Martin Eve (2010)
|
||||
**License**: GPLv3
|
||||
**Python**: 2.7 (uses fuse-python)
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Virtual Metadata-Based Directory Structure
|
||||
|
||||
Files are presented in a configurable path format based on beets database fields:
|
||||
|
||||
```
|
||||
$artist/$album ($year) [$format_upper]/$track - $artist - $title.$format
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```
|
||||
/mnt/beetfs/
|
||||
├── Metallica/
|
||||
│ └── 72 Seasons (2023) [FLAC]/
|
||||
│ ├── 01 - Metallica - 72 Seasons.flac
|
||||
│ ├── 02 - Metallica - Shadows Follow.flac
|
||||
│ └── ...
|
||||
├── Pink Floyd/
|
||||
│ └── The Dark Side of the Moon (1973) [FLAC]/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
**Available template variables**:
|
||||
- `$artist`, `$album`, `$title`, `$genre`, `$composer`, `$grouping`
|
||||
- `$year`, `$month`, `$day`
|
||||
- `$track`, `$tracktotal`, `$disc`, `$disctotal`
|
||||
- `$format`, `$format_upper` (file extension)
|
||||
- `$lyrics`, `$comments`, `$bpm`, `$comp`
|
||||
|
||||
### 2. Metadata Overlay (Read)
|
||||
|
||||
When you read a file through beetfs, the **metadata header is synthesized from the beets database**, not read from the actual file on disk.
|
||||
|
||||
**How it works**:
|
||||
1. Open file → beetfs reads the real file from disk
|
||||
2. Parse the audio format header (FLAC/MP3)
|
||||
3. Replace metadata fields with values from beets database
|
||||
4. Return synthesized header + original audio data
|
||||
|
||||
**Supported fields for overlay**:
|
||||
- `title`, `artist`, `album`, `genre` (FLAC only currently)
|
||||
|
||||
**Use case**: Your files may have inconsistent or wrong tags, but beetfs presents them with the corrected metadata from your beets library.
|
||||
|
||||
### 3. Metadata Passthrough (Write)
|
||||
|
||||
When you write to file headers through beetfs, the **changes are saved to the beets database**, not to the actual file.
|
||||
|
||||
**How it works**:
|
||||
1. Application writes new metadata to file header region
|
||||
2. beetfs intercepts the write
|
||||
3. Parses the new metadata values
|
||||
4. Updates the beets database (`lib.store()`, `lib.save()`)
|
||||
5. Regenerates the synthesized header
|
||||
|
||||
**Result**: Tag editors (Picard, Kid3, etc.) can edit metadata through beetfs, and changes persist in the beets database without modifying the original files.
|
||||
|
||||
### 4. Format Support
|
||||
|
||||
| Format | Read | Metadata Overlay | Write to DB |
|
||||
|--------|------|------------------|-------------|
|
||||
| FLAC | ✅ | ✅ Full | ✅ |
|
||||
| MP3 | ✅ | ❌ Disabled | ❌ |
|
||||
| Other | ❌ | ❌ | ❌ |
|
||||
|
||||
**FLAC Implementation**:
|
||||
- Uses `InterpolatedFLAC` class extending mutagen
|
||||
- Reconstructs Vorbis comment block with DB values
|
||||
- Preserves audio data and other metadata blocks
|
||||
|
||||
**MP3 Implementation**:
|
||||
- Passthrough only (no interpolation)
|
||||
- `self.bound = 0` disables header replacement
|
||||
|
||||
### 5. File Caching
|
||||
|
||||
Open files are cached in `FileHandler` objects:
|
||||
|
||||
- First open: Load entire file into memory, parse headers
|
||||
- Subsequent opens: Reuse cached `FileHandler`
|
||||
- Reference counting for multiple opens
|
||||
- Release when reference count reaches zero
|
||||
|
||||
**Memory impact**: Each open file consumes ~filesize RAM.
|
||||
|
||||
## FUSE Operations
|
||||
|
||||
### Implemented (Functional)
|
||||
|
||||
| Operation | Description |
|
||||
|-----------|-------------|
|
||||
| `getattr` | File/directory stat (size, mode, timestamps) |
|
||||
| `access` | Permission checking |
|
||||
| `opendir` | Open directory for listing |
|
||||
| `readdir` | List directory contents |
|
||||
| `releasedir` | Close directory |
|
||||
| `open` | Open file for reading/writing |
|
||||
| `read` | Read file contents |
|
||||
| `write` | Write to file (header region only) |
|
||||
| `release` | Close file |
|
||||
| `fgetattr` | Stat with file handle |
|
||||
| `statfs` | Filesystem statistics |
|
||||
|
||||
### Not Implemented (Return EOPNOTSUPP)
|
||||
|
||||
| Operation | Reason |
|
||||
|-----------|--------|
|
||||
| `create` | Read-only structure |
|
||||
| `mknod` | Read-only structure |
|
||||
| `mkdir` | Read-only structure |
|
||||
| `unlink` | Read-only structure |
|
||||
| `rmdir` | Read-only structure |
|
||||
| `symlink` | Not needed |
|
||||
| `link` | Not needed |
|
||||
| `rename` | Would break DB consistency |
|
||||
| `chmod` | Metadata-only FS |
|
||||
| `chown` | Metadata-only FS |
|
||||
| `truncate` | Would corrupt audio |
|
||||
| `utime` | Metadata-only FS |
|
||||
|
||||
## Usage
|
||||
|
||||
### Mount
|
||||
|
||||
```bash
|
||||
beet mount /mnt/beetfs
|
||||
```
|
||||
|
||||
### Unmount
|
||||
|
||||
```bash
|
||||
fusermount -u /mnt/beetfs
|
||||
```
|
||||
|
||||
### Example Session
|
||||
|
||||
```bash
|
||||
# Mount the filesystem
|
||||
beet mount /mnt/music
|
||||
|
||||
# Browse by artist
|
||||
ls /mnt/music/
|
||||
# Metallica/ Pink Floyd/ The Beatles/ ...
|
||||
|
||||
# List an album
|
||||
ls "/mnt/music/Metallica/72 Seasons (2023) [FLAC]/"
|
||||
# 01 - Metallica - 72 Seasons.flac
|
||||
# 02 - Metallica - Shadows Follow.flac
|
||||
# ...
|
||||
|
||||
# Play through any music player
|
||||
mpv "/mnt/music/Metallica/72 Seasons (2023) [FLAC]/01 - Metallica - 72 Seasons.flac"
|
||||
|
||||
# Edit tags (changes go to beets DB)
|
||||
kid3 "/mnt/music/Metallica/72 Seasons (2023) [FLAC]/"
|
||||
|
||||
# Unmount
|
||||
fusermount -u /mnt/music
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ User Applications │
|
||||
│ (mpv, Rhythmbox, Kid3, etc.) │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│ POSIX calls (open, read, write)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Linux Kernel │
|
||||
│ FUSE module │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
│ /dev/fuse
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ beetfs │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
||||
│ │ FSNode Tree │ │ FileHandler │ │ InterpolatedFLAC │ │
|
||||
│ │ (in-memory) │ │ (cache) │ │ (header synth) │ │
|
||||
│ └─────────────┘ └──────────────┘ └───────────────────┘ │
|
||||
└────────┬────────────────┬───────────────────┬───────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Beets DB │ │ Real Files │ │ Mutagen │
|
||||
│ (SQLite) │ │ (on disk) │ │ (parsing) │
|
||||
└─────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
### Current Bugs (Non-Functional)
|
||||
|
||||
1. **Nested Methods Bug**: Lines 758-1144 are indented inside `access()`, making FUSE operations unreachable
|
||||
2. **Directory Tree Bug**: `FSNode.adddir()` crashes when building tree for non-empty library
|
||||
|
||||
### Design Limitations
|
||||
|
||||
1. **Memory Usage**: Entire file loaded into RAM on open
|
||||
2. **Mount Time**: O(N) - loads all library items at mount
|
||||
3. **No Lazy Loading**: Full directory tree built upfront
|
||||
4. **Single Format**: Only FLAC has full metadata overlay
|
||||
5. **No Real File Modification**: Writes only update DB, not actual files
|
||||
6. **Python 2.7 GIL**: Single-threaded performance
|
||||
|
||||
### Not Supported
|
||||
|
||||
- Creating/deleting files or directories
|
||||
- Moving/renaming files
|
||||
- Modifying audio content
|
||||
- Album art / embedded images
|
||||
- Multi-value tags
|
||||
- Non-ASCII in some edge cases
|
||||
|
||||
## Configuration
|
||||
|
||||
Currently hardcoded. Potential configuration points:
|
||||
|
||||
| Setting | Current Value | Description |
|
||||
|---------|---------------|-------------|
|
||||
| `PATH_FORMAT` | `$artist/$album ($year)...` | Directory structure template |
|
||||
| `METADATA_RW_FIELDS` | 17 fields | Fields available for read/write |
|
||||
| Caching | Always on | FileHandler caching behavior |
|
||||
| Threading | Disabled | `multithreaded = 0` |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 2.7
|
||||
- fuse-python
|
||||
- beets 1.4.x
|
||||
- mutagen (FLAC/MP3 parsing)
|
||||
|
||||
## See Also
|
||||
|
||||
- [e2e-test-plan.md](e2e-test-plan.md) - Test strategy and bug documentation
|
||||
- [benchmark-plan.md](benchmark-plan.md) - Performance measurement methodology
|
||||
- [benchmark-results.md](benchmark-results.md) - Current benchmark status
|
||||
@@ -1,459 +0,0 @@
|
||||
# beetfs Modernization Guide
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Technical Debt
|
||||
|
||||
| Issue | Severity | Location |
|
||||
|-------|----------|----------|
|
||||
| Python 2 syntax | 🔴 Critical | Throughout |
|
||||
| fuse-python (deprecated) | 🔴 Critical | Lines 25, 51 |
|
||||
| `basestring` usage | 🔴 Critical | Line 89 |
|
||||
| `reduce` without import | 🟡 Medium | Line 197 |
|
||||
| `0755` octal syntax | 🟡 Medium | Lines 654, 700 |
|
||||
| `print` as statement | 🟡 Medium | N/A (not used) |
|
||||
| `except Exception, e` | 🔴 Critical | Line 181 |
|
||||
| Long integers (`0L`) | 🟡 Medium | Line 197 |
|
||||
| Global state | 🟡 Medium | Lines 125-140 |
|
||||
| Memory-heavy design | 🟡 Medium | Line 481 |
|
||||
|
||||
### Dependencies to Update
|
||||
|
||||
| Original | Replacement | Notes |
|
||||
|----------|-------------|-------|
|
||||
| `fuse-python` | `pyfuse3` or `llfuse` | Modern FUSE bindings |
|
||||
| `beets` (old API) | `beets >= 1.6` | Check API compatibility |
|
||||
| `mutagen` | `mutagen >= 1.45` | Mostly compatible |
|
||||
| Python 2.7 | Python 3.9+ | Full migration needed |
|
||||
|
||||
---
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Phase 1: Python 3 Compatibility
|
||||
|
||||
#### 1.1 Fix Syntax Issues
|
||||
|
||||
```python
|
||||
# BEFORE (Python 2)
|
||||
except fuse.FuseError, e:
|
||||
log.error(str(e))
|
||||
|
||||
# AFTER (Python 3)
|
||||
except fuse.FuseError as e:
|
||||
log.error(str(e))
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
if isinstance(value, basestring):
|
||||
|
||||
# AFTER
|
||||
if isinstance(value, str):
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
return reduce(lambda a, b: (a << 8) + ord(b), string, 0L)
|
||||
|
||||
# AFTER
|
||||
from functools import reduce
|
||||
return reduce(lambda a, b: (a << 8) + b, string, 0)
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
mode = stat.S_IFDIR | 0755
|
||||
|
||||
# AFTER
|
||||
mode = stat.S_IFDIR | 0o755
|
||||
```
|
||||
|
||||
#### 1.2 Fix String/Bytes Handling
|
||||
|
||||
```python
|
||||
# BEFORE - implicit string/bytes mixing
|
||||
self.header = self.inf.get_header(self.real_path)
|
||||
return self.header[offset:offset+size]
|
||||
|
||||
# AFTER - explicit bytes handling
|
||||
self.header: bytes = self.inf.get_header(self.real_path)
|
||||
return self.header[offset:offset+size]
|
||||
```
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
self.item.title = str(self.inf["title"][0]).encode('utf-8')
|
||||
|
||||
# AFTER
|
||||
self.item.title = self.inf["title"][0] # Already str in Python 3
|
||||
```
|
||||
|
||||
#### 1.3 Fix Dictionary Methods
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
return node.dirs.keys()
|
||||
|
||||
# AFTER
|
||||
return list(node.dirs.keys()) # If list is needed
|
||||
# or just
|
||||
return node.dirs.keys() # If iteration is sufficient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: FUSE Library Migration
|
||||
|
||||
#### Option A: pyfuse3 (Recommended)
|
||||
|
||||
Modern, async-capable FUSE bindings.
|
||||
|
||||
```python
|
||||
# BEFORE (fuse-python)
|
||||
import fuse
|
||||
fuse.fuse_python_api = (0, 2)
|
||||
|
||||
class beetFileSystem(fuse.Fuse):
|
||||
def read(self, path, size, offset):
|
||||
return data
|
||||
|
||||
# AFTER (pyfuse3)
|
||||
import pyfuse3
|
||||
import trio
|
||||
|
||||
class BeetFS(pyfuse3.Operations):
|
||||
async def read(self, fh, offset, size):
|
||||
return data
|
||||
|
||||
async def main():
|
||||
fs = BeetFS()
|
||||
fuse_options = set(pyfuse3.default_options)
|
||||
fuse_options.add('fsname=beetfs')
|
||||
pyfuse3.init(fs, mountpoint, fuse_options)
|
||||
try:
|
||||
await pyfuse3.main()
|
||||
finally:
|
||||
pyfuse3.close()
|
||||
|
||||
trio.run(main)
|
||||
```
|
||||
|
||||
**Key Differences**:
|
||||
| fuse-python | pyfuse3 |
|
||||
|-------------|---------|
|
||||
| `read(path, size, offset)` | `read(fh, offset, size)` |
|
||||
| Synchronous | Async (trio) |
|
||||
| Return data directly | Return bytes |
|
||||
| Path-based | File handle based |
|
||||
|
||||
#### Option B: llfuse (Alternative)
|
||||
|
||||
Lower-level, synchronous.
|
||||
|
||||
```python
|
||||
import llfuse
|
||||
|
||||
class BeetFS(llfuse.Operations):
|
||||
def read(self, fh, offset, size):
|
||||
return data
|
||||
|
||||
def main():
|
||||
fs = BeetFS()
|
||||
llfuse.init(fs, mountpoint, options)
|
||||
try:
|
||||
llfuse.main()
|
||||
finally:
|
||||
llfuse.close()
|
||||
```
|
||||
|
||||
#### Option C: fusepy (Simple)
|
||||
|
||||
Simple wrapper, but less maintained.
|
||||
|
||||
```python
|
||||
from fuse import FUSE, Operations
|
||||
|
||||
class BeetFS(Operations):
|
||||
def read(self, path, size, offset, fh):
|
||||
return data
|
||||
|
||||
FUSE(BeetFS(), mountpoint, foreground=True)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Architecture Improvements
|
||||
|
||||
#### 3.1 Remove Global State
|
||||
|
||||
```python
|
||||
# BEFORE - Global variables
|
||||
global structure_split
|
||||
global structure_depth
|
||||
global library
|
||||
global directory_structure
|
||||
|
||||
# AFTER - Instance variables
|
||||
class BeetFS:
|
||||
def __init__(self, lib: Library, path_format: str):
|
||||
self.lib = lib
|
||||
self.path_format = path_format
|
||||
self.structure_split = path_format.split("/")
|
||||
self.structure_depth = len(self.structure_split)
|
||||
self.directory_structure = FSNode({}, {})
|
||||
self._build_tree()
|
||||
```
|
||||
|
||||
#### 3.2 Reduce Memory Usage
|
||||
|
||||
```python
|
||||
# BEFORE - Load entire audio into memory
|
||||
self.music_data = self.file_object.read() # Could be 100MB+
|
||||
|
||||
# AFTER - Lazy loading with mmap or seek
|
||||
class FileHandler:
|
||||
def __init__(self, path, lib):
|
||||
self.real_path = self._resolve_path(path)
|
||||
self.file_object = open(self.real_path, 'rb')
|
||||
self._header = None # Lazy load
|
||||
self._music_offset = None
|
||||
|
||||
@property
|
||||
def header(self) -> bytes:
|
||||
if self._header is None:
|
||||
self._header = self._generate_header()
|
||||
return self._header
|
||||
|
||||
def read(self, size: int, offset: int) -> bytes:
|
||||
if offset < len(self.header):
|
||||
# Header region - return from generated header
|
||||
if offset + size <= len(self.header):
|
||||
return self.header[offset:offset+size]
|
||||
else:
|
||||
# Span header and audio
|
||||
header_part = self.header[offset:]
|
||||
audio_offset = 0
|
||||
audio_size = size - len(header_part)
|
||||
audio_part = self._read_audio(audio_offset, audio_size)
|
||||
return header_part + audio_part
|
||||
else:
|
||||
# Audio region - read directly from file
|
||||
audio_offset = offset - len(self.header)
|
||||
return self._read_audio(audio_offset, size)
|
||||
|
||||
def _read_audio(self, offset: int, size: int) -> bytes:
|
||||
self.file_object.seek(self._music_offset + offset)
|
||||
return self.file_object.read(size)
|
||||
```
|
||||
|
||||
#### 3.3 Add Type Hints
|
||||
|
||||
```python
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
class FSNode:
|
||||
def __init__(self, dirs: Dict[str, 'FSNode'], files: Dict[str, int]):
|
||||
self.dirs: Dict[str, FSNode] = dirs
|
||||
self.files: Dict[str, int] = files
|
||||
|
||||
def getnode(self, elements: List[str], root: Optional['FSNode'] = None) -> 'FSNode':
|
||||
...
|
||||
|
||||
def addfile(self, elements: List[str], filename: str, item_id: int) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
#### 3.4 Add MP3 Support
|
||||
|
||||
```python
|
||||
class FileHandler:
|
||||
def __init__(self, path: str, lib: Library):
|
||||
self.format = Path(path).suffix[1:].lower()
|
||||
|
||||
if self.format == "flac":
|
||||
self._handler = FLACHandler(self.real_path, self.item)
|
||||
elif self.format == "mp3":
|
||||
self._handler = MP3Handler(self.real_path, self.item)
|
||||
elif self.format in ("ogg", "opus"):
|
||||
self._handler = OggHandler(self.real_path, self.item)
|
||||
else:
|
||||
raise UnsupportedFormatError(f"Format {self.format} not supported")
|
||||
|
||||
class FLACHandler:
|
||||
def generate_header(self, item: Item) -> bytes:
|
||||
inf = InterpolatedFLAC(self.file_data)
|
||||
inf["title"] = item.title
|
||||
inf["album"] = item.album
|
||||
inf["artist"] = item.artist
|
||||
inf["genre"] = item.genre
|
||||
return inf.get_header()
|
||||
|
||||
class MP3Handler:
|
||||
def generate_header(self, item: Item) -> bytes:
|
||||
# Implement ID3v2 header generation
|
||||
id3 = InterpolatedID3()
|
||||
id3.add(TIT2(encoding=3, text=item.title))
|
||||
id3.add(TPE1(encoding=3, text=item.artist))
|
||||
id3.add(TALB(encoding=3, text=item.album))
|
||||
id3.add(TCON(encoding=3, text=item.genre))
|
||||
|
||||
# Calculate padding to match original header size
|
||||
...
|
||||
return id3.render()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Testing
|
||||
|
||||
#### 4.1 Unit Tests
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from beetfs import FSNode, FileHandler
|
||||
|
||||
class TestFSNode:
|
||||
def test_adddir(self):
|
||||
root = FSNode({}, {})
|
||||
root.adddir([], "Artist")
|
||||
assert "Artist" in root.dirs
|
||||
|
||||
def test_addfile(self):
|
||||
root = FSNode({}, {})
|
||||
root.adddir([], "Artist")
|
||||
root.addfile(["Artist"], "track.flac", 42)
|
||||
assert root.dirs["Artist"].files["track.flac"] == 42
|
||||
|
||||
def test_getnode(self):
|
||||
root = FSNode({}, {})
|
||||
root.adddir([], "Artist")
|
||||
root.adddir(["Artist"], "Album")
|
||||
node = root.getnode(["Artist", "Album"])
|
||||
assert node is not None
|
||||
|
||||
class TestFileHandler:
|
||||
def test_read_header(self, mock_flac_file, mock_beets_item):
|
||||
handler = FileHandler("/Artist/Album/track.flac", mock_lib)
|
||||
data = handler.read(100, 0)
|
||||
assert data.startswith(b"fLaC")
|
||||
|
||||
def test_read_audio(self, mock_flac_file, mock_beets_item):
|
||||
handler = FileHandler("/Artist/Album/track.flac", mock_lib)
|
||||
data = handler.read(100, handler.bound + 100)
|
||||
# Should be audio data from original file
|
||||
assert data == mock_flac_file.audio_data[100:200]
|
||||
```
|
||||
|
||||
#### 4.2 Integration Tests
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
class TestFUSEMount:
|
||||
def test_mount_unmount(self, beets_library):
|
||||
with tempfile.TemporaryDirectory() as mountpoint:
|
||||
# Mount
|
||||
proc = subprocess.Popen(
|
||||
["beet", "mount", mountpoint],
|
||||
stdout=subprocess.PIPE
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Verify mount
|
||||
assert os.path.ismount(mountpoint)
|
||||
|
||||
# List files
|
||||
files = os.listdir(mountpoint)
|
||||
assert len(files) > 0
|
||||
|
||||
# Unmount
|
||||
subprocess.run(["fusermount", "-u", mountpoint])
|
||||
proc.wait()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Standalone Mode (Optional)
|
||||
|
||||
Remove beets dependency for use as standalone metadata overlay.
|
||||
|
||||
```python
|
||||
class StandaloneFS:
|
||||
"""Metadata overlay without beets dependency."""
|
||||
|
||||
def __init__(self,
|
||||
source_dir: Path,
|
||||
metadata_db: Path,
|
||||
path_format: str):
|
||||
self.source_dir = source_dir
|
||||
self.db = sqlite3.connect(metadata_db)
|
||||
self.path_format = path_format
|
||||
self._build_tree()
|
||||
|
||||
def _build_tree(self):
|
||||
"""Build virtual tree from source directory and metadata DB."""
|
||||
for audio_file in self.source_dir.rglob("*.flac"):
|
||||
# Get metadata from DB or scan file
|
||||
metadata = self._get_metadata(audio_file)
|
||||
# Build virtual path from template
|
||||
virtual_path = self._format_path(metadata)
|
||||
# Add to tree
|
||||
self.directory_structure.addfile(
|
||||
virtual_path.parent.parts,
|
||||
virtual_path.name,
|
||||
str(audio_file) # Store actual path instead of ID
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommended Migration Order
|
||||
|
||||
```
|
||||
1. [ ] Fork and set up development environment
|
||||
2. [ ] Add type hints throughout (helps catch issues)
|
||||
3. [ ] Fix Python 3 syntax issues
|
||||
4. [ ] Replace fuse-python with pyfuse3/llfuse
|
||||
5. [ ] Add unit tests for FSNode and FileHandler
|
||||
6. [ ] Refactor global state to instance variables
|
||||
7. [ ] Implement lazy loading for audio data
|
||||
8. [ ] Add MP3 support
|
||||
9. [ ] Add integration tests
|
||||
10. [ ] Optional: Create standalone mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Effort | Risk |
|
||||
|-------|--------|------|
|
||||
| Phase 1 (Python 3) | 2-3 days | Low |
|
||||
| Phase 2 (FUSE migration) | 3-5 days | Medium |
|
||||
| Phase 3 (Architecture) | 3-5 days | Medium |
|
||||
| Phase 4 (Testing) | 2-3 days | Low |
|
||||
| Phase 5 (Standalone) | 3-5 days | Medium |
|
||||
| **Total** | **13-21 days** | |
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Rewrite from Scratch
|
||||
|
||||
Given the age of the codebase, a rewrite might be more efficient:
|
||||
|
||||
**Pros of Rewrite**:
|
||||
- Clean architecture from start
|
||||
- Modern async design
|
||||
- Better memory management
|
||||
- Easier to test
|
||||
|
||||
**Cons of Rewrite**:
|
||||
- More initial effort
|
||||
- Risk of missing edge cases
|
||||
- Need to re-discover FLAC/ID3 intricacies
|
||||
|
||||
**Recommended Approach**: Start with Phase 1-2 to understand the code deeply, then decide whether to continue refactoring or rewrite.
|
||||
@@ -1,451 +0,0 @@
|
||||
# Rust Migration Analysis for beetfs
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Migrating beetfs from Python to Rust is **strongly recommended** based on research findings. Expected improvements:
|
||||
|
||||
| Metric | Python (Current) | Rust (Expected) | Improvement |
|
||||
|--------|------------------|-----------------|-------------|
|
||||
| **Memory per file** | ~280 bytes overhead | ~60 bytes | **4-5x reduction** |
|
||||
| **File open latency** | 200-500ms | 20-50ms | **10x faster** |
|
||||
| **Read latency** | 5-10ms | 0.5-2ms | **5-10x faster** |
|
||||
| **Concurrent opens** | ~1,000 (threading) | ~100,000+ (Tokio) | **100x more** |
|
||||
| **GC pauses** | 50-2200ms | 0ms | **Eliminated** |
|
||||
|
||||
---
|
||||
|
||||
## 1. Rust FUSE Ecosystem
|
||||
|
||||
### Recommended: **fuser**
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| **Downloads** | 3.2M+ |
|
||||
| **Maturity** | Production-ready |
|
||||
| **Platforms** | Linux, macOS, FreeBSD |
|
||||
| **Async** | Experimental (stable sync API) |
|
||||
| **Used by** | AWS Mountpoint for S3 |
|
||||
|
||||
**API Example:**
|
||||
```rust
|
||||
use fuser::{Filesystem, Request, ReplyData};
|
||||
|
||||
impl Filesystem for BeetFS {
|
||||
fn read(&self, _req: &Request, ino: u64, _fh: u64,
|
||||
offset: i64, size: u32, _flags: i32,
|
||||
_lock: Option<u64>, reply: ReplyData) {
|
||||
|
||||
let file = self.get_file(ino);
|
||||
|
||||
if offset < file.header_len {
|
||||
// Return metadata from database (interpolated)
|
||||
reply.data(&file.header[offset as usize..]);
|
||||
} else {
|
||||
// Return audio from original file (zero-copy via mmap)
|
||||
let audio_offset = offset - file.header_len;
|
||||
reply.data(&file.mmap[audio_offset as usize..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Alternatives
|
||||
|
||||
| Library | Async | Maturity | Best For |
|
||||
|---------|-------|----------|----------|
|
||||
| **fuser** | Experimental | ⭐⭐⭐⭐⭐ | General purpose |
|
||||
| **fuse3** | Native | ⭐⭐⭐⭐ | Async-heavy, Linux-only |
|
||||
| **polyfuse** | Native | ⭐⭐⭐ | Custom control flow |
|
||||
|
||||
---
|
||||
|
||||
## 2. Rust Audio Metadata: **lofty**
|
||||
|
||||
Full feature parity with Python's mutagen:
|
||||
|
||||
| Feature | mutagen (Python) | lofty (Rust) |
|
||||
|---------|------------------|--------------|
|
||||
| FLAC Vorbis Comments | ✅ | ✅ |
|
||||
| MP3 ID3v2 (all versions) | ✅ | ✅ |
|
||||
| OGG Vorbis Comments | ✅ | ✅ |
|
||||
| Opus metadata | ✅ | ✅ |
|
||||
| In-memory manipulation | ✅ | ✅ |
|
||||
| Header generation | ✅ | ✅ `dump_to()` |
|
||||
| Picture/artwork | ✅ | ✅ |
|
||||
|
||||
**API Comparison:**
|
||||
```python
|
||||
# Python mutagen
|
||||
audio = mutagen.File("song.flac")
|
||||
audio['artist'] = 'New Artist'
|
||||
audio['title'] = 'New Title'
|
||||
audio.save()
|
||||
```
|
||||
|
||||
```rust
|
||||
// Rust lofty
|
||||
let mut file = lofty::read_from_path("song.flac")?;
|
||||
let tag = file.primary_tag_mut().unwrap();
|
||||
tag.set_artist("New Artist".to_string());
|
||||
tag.set_title("New Title".to_string());
|
||||
tag.save_to_path("song.flac", WriteOptions::default())?;
|
||||
```
|
||||
|
||||
**Header Generation (Critical for beetfs):**
|
||||
```rust
|
||||
// Generate FLAC header with modified tags WITHOUT writing to file
|
||||
let mut buffer = Vec::new();
|
||||
tag.dump_to(&mut buffer, WriteOptions::default())?;
|
||||
// `buffer` contains serialized metadata header
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Memory Benefits
|
||||
|
||||
### Python Object Overhead
|
||||
|
||||
| Python Type | Size | Notes |
|
||||
|-------------|------|-------|
|
||||
| Empty dict | 232 bytes | Base overhead |
|
||||
| Dict entry | +184 bytes | Per key-value |
|
||||
| Empty string | 49 bytes | Base overhead |
|
||||
| Empty list | 56 bytes | Base overhead |
|
||||
| Small int | 28 bytes | Even for `0` |
|
||||
|
||||
**Current beetfs FileHandler (Python):**
|
||||
```
|
||||
self.path → str → 49 + len(path) bytes
|
||||
self.real_path → str → 49 + len(path) bytes
|
||||
self.item → dict → 232 + entries
|
||||
self.header → bytes → 33 + len(header)
|
||||
self.music_data → bytes → 33 + len(audio) ← CRITICAL: full file!
|
||||
self.inf → object → 100+ bytes
|
||||
─────────────────────────────────────────
|
||||
TOTAL: ~500 bytes + entire file in RAM
|
||||
```
|
||||
|
||||
### Rust Struct Efficiency
|
||||
|
||||
```rust
|
||||
struct FileHandler {
|
||||
path: PathBuf, // 24 bytes (ptr+len+cap)
|
||||
real_path: PathBuf, // 24 bytes
|
||||
item_id: u64, // 8 bytes
|
||||
header: Vec<u8>, // 24 bytes (ptr+len+cap) + header data
|
||||
mmap: Mmap, // 24 bytes (NO file data in RAM!)
|
||||
header_len: u64, // 8 bytes
|
||||
audio_offset: u64, // 8 bytes
|
||||
}
|
||||
// TOTAL: ~120 bytes + header only (audio via mmap)
|
||||
```
|
||||
|
||||
### Memory Comparison
|
||||
|
||||
| Scenario | Python | Rust | Savings |
|
||||
|----------|--------|------|---------|
|
||||
| 1 file (50MB) | ~50 MB | ~64 KB | **780x** |
|
||||
| 10 files (50MB each) | ~500 MB | ~640 KB | **780x** |
|
||||
| 100 files (50MB each) | ~5 GB | ~6.4 MB | **780x** |
|
||||
| Library scan (1000 files) | **OOM** | ~64 MB | ∞ |
|
||||
|
||||
**Key insight**: Rust can use memory-mapped files (`mmap`) to serve audio data with zero copies, eliminating the need to load files into RAM.
|
||||
|
||||
---
|
||||
|
||||
## 4. Latency Benefits
|
||||
|
||||
### Python FUSE Bottlenecks
|
||||
|
||||
1. **Dict-to-struct conversion**: Every FUSE callback requires converting Python dicts to C structs
|
||||
2. **GIL contention**: Single-threaded execution despite multi-core CPUs
|
||||
3. **GC pauses**: Stop-the-world pauses of 50-2200ms under load
|
||||
4. **Object allocation**: Creating Python objects for every I/O operation
|
||||
|
||||
### Rust FUSE Advantages
|
||||
|
||||
1. **Zero-cost abstractions**: No runtime overhead for type conversions
|
||||
2. **No GIL**: True parallelism across all cores
|
||||
3. **No GC**: Deterministic memory management, no pauses
|
||||
4. **Stack allocation**: Small objects allocated on stack, not heap
|
||||
|
||||
### Benchmark Data
|
||||
|
||||
| Operation | Python FUSE | Rust FUSE | Improvement |
|
||||
|-----------|-------------|-----------|-------------|
|
||||
| File stat | 5-10ms | 0.5-1ms | **10x** |
|
||||
| Small read | 5-10ms | 0.5-2ms | **5-10x** |
|
||||
| Large read | 115 MB/s | 260+ MB/s | **2-3x** |
|
||||
| Metadata lookup | 10ms | <1ms | **10x** |
|
||||
|
||||
### GC Pause Elimination
|
||||
|
||||
```
|
||||
Python GC Pauses (measured):
|
||||
├── P50: ~10ms
|
||||
├── P95: ~50ms
|
||||
├── P99: ~320ms
|
||||
└── Max: ~2200ms (!)
|
||||
|
||||
Rust (no GC):
|
||||
├── P50: ~0.5ms
|
||||
├── P95: ~1ms
|
||||
├── P99: ~2ms
|
||||
└── Max: ~5ms (deterministic)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Concurrency Benefits
|
||||
|
||||
### Python Threading Limitations
|
||||
|
||||
```python
|
||||
# Python (current beetfs)
|
||||
server.multithreaded = 0 # Single-threaded!
|
||||
|
||||
# Even with threading enabled:
|
||||
# - GIL prevents true parallelism
|
||||
# - ~8MB per thread
|
||||
# - OS limits: ~1000-2000 threads max
|
||||
# - Context switch: 1-10μs (kernel)
|
||||
```
|
||||
|
||||
### Rust Async (Tokio)
|
||||
|
||||
```rust
|
||||
// Rust with Tokio
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Can handle 100K+ concurrent operations
|
||||
// - ~2KB per task (4000x less than thread)
|
||||
// - Work-stealing scheduler
|
||||
// - Context switch: ~10ns (userspace)
|
||||
}
|
||||
```
|
||||
|
||||
| Metric | Python Threading | Rust Tokio |
|
||||
|--------|------------------|------------|
|
||||
| Memory per task | 8 MB | 2 KB |
|
||||
| Max concurrent | ~1,000 | ~100,000+ |
|
||||
| Context switch | 1-10μs | ~10ns |
|
||||
| Parallelism | Blocked by GIL | True multi-core |
|
||||
|
||||
---
|
||||
|
||||
## 6. Zero-Copy I/O
|
||||
|
||||
### Python (Current)
|
||||
|
||||
```python
|
||||
# Every read copies data through Python:
|
||||
self.file_object.read() # syscall → kernel buffer
|
||||
# kernel buffer → Python bytes object
|
||||
# Python bytes → FUSE reply buffer
|
||||
# = 2-3 copies per read
|
||||
```
|
||||
|
||||
### Rust (Proposed)
|
||||
|
||||
```rust
|
||||
// Memory-mapped file + zero-copy reply:
|
||||
let mmap = unsafe { MmapOptions::new().map(&file)? };
|
||||
|
||||
fn read(&self, ..., reply: ReplyData) {
|
||||
// Direct slice from mmap → FUSE kernel
|
||||
reply.data(&self.mmap[offset..offset+size]);
|
||||
// = 0 copies (kernel reads directly from mapped pages)
|
||||
}
|
||||
```
|
||||
|
||||
### I/O Comparison
|
||||
|
||||
| Scenario | Python | Rust | Benefit |
|
||||
|----------|--------|------|---------|
|
||||
| Serve 50MB file | 50MB copied to RAM | 0 bytes copied | **50MB saved** |
|
||||
| 100 concurrent reads | 5GB buffers | ~0 (shared mmap) | **5GB saved** |
|
||||
| Throughput | 115 MB/s | 260+ MB/s | **2.3x faster** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Real-World Migration Results
|
||||
|
||||
### Case Studies
|
||||
|
||||
| Project | Metric | Python | Rust | Improvement |
|
||||
|---------|--------|--------|------|-------------|
|
||||
| API Service | Response time | 200ms | 8ms | **96% faster** |
|
||||
| Data Pipeline | Processing | 3 hours | 4.5 min | **40x faster** |
|
||||
| Web Backend | Memory | 1.2 GB | 180 MB | **85% less** |
|
||||
| Trajectory Lib | Compute | baseline | 10x faster | **10x** |
|
||||
|
||||
### AWS Mountpoint for S3
|
||||
|
||||
- Built on **fuser** (Rust FUSE)
|
||||
- Handles **terabits/sec** aggregate throughput
|
||||
- Production-ready since 2024
|
||||
- Validates Rust FUSE at scale
|
||||
|
||||
---
|
||||
|
||||
## 8. Migration Architecture
|
||||
|
||||
### Proposed Rust beetfs Structure
|
||||
|
||||
```
|
||||
beetfs-rs/
|
||||
├── Cargo.toml
|
||||
├── src/
|
||||
│ ├── main.rs # Entry point, mount logic
|
||||
│ ├── lib.rs # Library root
|
||||
│ ├── fs/
|
||||
│ │ ├── mod.rs # FUSE filesystem impl
|
||||
│ │ ├── tree.rs # Virtual directory tree (FSNode equivalent)
|
||||
│ │ ├── file.rs # File handler with mmap
|
||||
│ │ └── stat.rs # File attributes
|
||||
│ ├── metadata/
|
||||
│ │ ├── mod.rs # Metadata overlay logic
|
||||
│ │ ├── flac.rs # FLAC header generation (using lofty)
|
||||
│ │ ├── mp3.rs # MP3 ID3 header generation
|
||||
│ │ └── db.rs # Database interface (SQLite or custom)
|
||||
│ └── config.rs # Configuration (path templates, etc.)
|
||||
└── tests/
|
||||
├── fs_tests.rs
|
||||
└── metadata_tests.rs
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
```rust
|
||||
// Virtual directory tree (equivalent to FSNode)
|
||||
pub struct VirtualTree {
|
||||
root: Arc<RwLock<DirNode>>,
|
||||
}
|
||||
|
||||
pub struct DirNode {
|
||||
dirs: HashMap<OsString, Arc<RwLock<DirNode>>>,
|
||||
files: HashMap<OsString, FileEntry>,
|
||||
}
|
||||
|
||||
pub struct FileEntry {
|
||||
inode: u64,
|
||||
real_path: PathBuf,
|
||||
metadata_id: i64, // Database reference
|
||||
}
|
||||
|
||||
// File handler with memory-mapped audio
|
||||
pub struct OpenFile {
|
||||
header: Vec<u8>, // Generated header with DB metadata
|
||||
header_len: usize,
|
||||
mmap: Mmap, // Memory-mapped original file
|
||||
audio_offset: usize, // Where audio starts in original
|
||||
}
|
||||
|
||||
impl OpenFile {
|
||||
pub fn read(&self, offset: usize, size: usize) -> &[u8] {
|
||||
if offset < self.header_len {
|
||||
// Return from generated header (DB metadata)
|
||||
&self.header[offset..min(offset + size, self.header_len)]
|
||||
} else {
|
||||
// Return from mmap (original audio, zero-copy)
|
||||
let audio_off = offset - self.header_len + self.audio_offset;
|
||||
&self.mmap[audio_off..audio_off + size]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration Effort Estimate
|
||||
|
||||
### Timeline
|
||||
|
||||
| Phase | Duration | Deliverable |
|
||||
|-------|----------|-------------|
|
||||
| **1. Prototype** | 1-2 weeks | Basic FUSE mount, read-only |
|
||||
| **2. Core features** | 2-3 weeks | Metadata overlay, FLAC support |
|
||||
| **3. Full parity** | 2-3 weeks | MP3, write support, all fields |
|
||||
| **4. Testing** | 1-2 weeks | Unit tests, integration tests |
|
||||
| **5. Optimization** | 1-2 weeks | mmap, async, benchmarking |
|
||||
|
||||
**Total: 7-12 weeks**
|
||||
|
||||
### Skill Requirements
|
||||
|
||||
- Rust fundamentals (ownership, borrowing, lifetimes)
|
||||
- FUSE protocol knowledge (from Python experience)
|
||||
- Audio metadata formats (FLAC, ID3)
|
||||
- Async Rust (Tokio) - optional for Phase 5
|
||||
|
||||
---
|
||||
|
||||
## 10. Risk Assessment
|
||||
|
||||
### Low Risk ✅
|
||||
|
||||
| Factor | Why Low Risk |
|
||||
|--------|--------------|
|
||||
| FUSE library | fuser is production-proven (AWS) |
|
||||
| Metadata library | lofty has full mutagen parity |
|
||||
| Core algorithm | Same logic, different language |
|
||||
| File format support | FLAC/MP3/OGG all supported |
|
||||
|
||||
### Medium Risk ⚠️
|
||||
|
||||
| Factor | Mitigation |
|
||||
|--------|------------|
|
||||
| Learning curve | Existing Rust experience helps |
|
||||
| Edge cases | Port Python tests to Rust |
|
||||
| Async complexity | Start with sync API, add async later |
|
||||
|
||||
### Benefits vs Effort
|
||||
|
||||
```
|
||||
Current Python Issues:
|
||||
├── Memory: OOM on library scan → Fixed by mmap
|
||||
├── Latency: 200-500ms file open → Fixed by zero-copy
|
||||
├── GC pauses: 50-2200ms → Eliminated
|
||||
├── Concurrency: single-threaded → Fixed by async
|
||||
└── MP3 support: disabled → Implemented properly
|
||||
|
||||
Migration Effort: 7-12 weeks
|
||||
Expected Lifetime: 5+ years
|
||||
ROI: Highly positive
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommendation
|
||||
|
||||
### ✅ **Proceed with Rust Migration**
|
||||
|
||||
**Justification:**
|
||||
1. **10x memory reduction** via mmap (eliminates OOM)
|
||||
2. **5-10x latency improvement** (eliminates blocking reads)
|
||||
3. **GC pauses eliminated** (deterministic performance)
|
||||
4. **100x concurrency** improvement (Tokio async)
|
||||
5. **Production-proven** ecosystem (fuser + lofty)
|
||||
6. **Reasonable effort** (7-12 weeks)
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Set up Rust project** with fuser and lofty dependencies
|
||||
2. **Port FSNode** to Rust VirtualTree
|
||||
3. **Implement basic FUSE** operations (read, getattr, readdir)
|
||||
4. **Add metadata overlay** with lofty for FLAC
|
||||
5. **Add mmap** for zero-copy audio serving
|
||||
6. **Benchmark** against Python implementation
|
||||
7. **Add MP3/OGG** support
|
||||
8. **Add async** with Tokio (optional)
|
||||
|
||||
### Dependencies
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
fuser = "0.17"
|
||||
lofty = "0.21"
|
||||
memmap2 = "0.9"
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
rusqlite = "0.31" # For beets DB compatibility
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,579 +0,0 @@
|
||||
# Metadata Enrichment (Standalone Mode): Design Doc
|
||||
|
||||
**Authors:** Sisyphus
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2026-05-18
|
||||
**Reviewers:** —
|
||||
**Approvers:** —
|
||||
**Document Link:** `docs/v2/plans/metadata-enrichment-standalone.md`
|
||||
**Prerequisites:** [architecture.md](../architecture.md), [week-12-external-metadata.md](week-12-external-metadata.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
|
||||
When musicfs operates without the music-agregator orchestrator, it should
|
||||
still be able to enrich file metadata (genres, label, artwork URL, album
|
||||
type) by querying the metadata-agregator service directly. This document
|
||||
describes a **built-in metadata provider** compiled into musicfs that
|
||||
queries metadata-agregator's gRPC `SearchAlbums` endpoint using
|
||||
artist + album names extracted from file tags. Enrichment is lazy and
|
||||
non-blocking — file access always returns immediately using embedded
|
||||
tags, while a background worker enriches metadata asynchronously.
|
||||
|
||||
This plan **supersedes** the week-12 plan's approach of embedding
|
||||
MusicBrainz/Discogs/Last.fm HTTP clients directly into musicfs. Instead,
|
||||
musicfs delegates all external metadata resolution to metadata-agregator,
|
||||
which already handles provider APIs, rate limiting, and caching.
|
||||
|
||||
## 2. Background
|
||||
|
||||
### 2.1. Current State
|
||||
|
||||
musicfs extracts audio metadata via symphonia (FLAC, MP3, AAC, OGG,
|
||||
Opus) and stores it in `AudioMeta`. This metadata is whatever the file
|
||||
tags contain — typically title, artist, album, year, track number.
|
||||
|
||||
The existing plugin system (`musicfs-plugins`) defines a `MetadataPlugin`
|
||||
trait for external metadata lookup, but:
|
||||
|
||||
- No plugins have been implemented yet.
|
||||
- The plugin system only supports native `.so` and WASM plugins.
|
||||
- A gRPC client to metadata-agregator would require bundling an async
|
||||
runtime and tonic inside a `.so` — an awkward fit.
|
||||
|
||||
Meanwhile, metadata-agregator is a Go gRPC service that:
|
||||
|
||||
- Searches MusicBrainz by artist + album name (`SearchAlbums` RPC).
|
||||
- Caches results in PostgreSQL.
|
||||
- Returns rich metadata: genres, cover URL, label, release date, album
|
||||
type, artist credits.
|
||||
|
||||
### 2.2. Pain Points
|
||||
|
||||
- musicfs files lack genres, artwork URLs, and label info unless the
|
||||
original files were meticulously tagged.
|
||||
- The week-12 plan proposed embedding 4 separate HTTP API clients
|
||||
(MusicBrainz, Discogs, Last.fm, AcoustID) directly into musicfs,
|
||||
duplicating what metadata-agregator already does.
|
||||
- The `MetadataPlugin` trait is designed for `.so`/WASM plugins, which
|
||||
is wrong for a core infrastructure gRPC client.
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### 3.1. Goals
|
||||
|
||||
- **G1:** Enrich file metadata with genres, label, album type, and cover
|
||||
URL by querying metadata-agregator via gRPC.
|
||||
- **G2:** Never block file access — enrichment happens in background.
|
||||
- **G3:** Make the provider entirely optional — disabled by default,
|
||||
musicfs works identically without it.
|
||||
- **G4:** Respect enrichment source priority so orchestrator pushes
|
||||
(from the full-system mode) are not overwritten.
|
||||
|
||||
### 3.2. Non-Goals
|
||||
|
||||
- **NG1:** Embedding MusicBrainz/Discogs/Last.fm HTTP clients directly
|
||||
into musicfs (metadata-agregator handles this).
|
||||
- **NG2:** Audio fingerprinting (AcoustID) — deferred to future work.
|
||||
- **NG3:** Modifying the existing `MetadataPlugin` trait — the built-in
|
||||
provider is separate from the plugin system.
|
||||
- **NG4:** Bidirectional communication — musicfs only queries
|
||||
metadata-agregator, never the reverse.
|
||||
|
||||
## 4. Proposed Design
|
||||
|
||||
### 4.1. High-Level Architecture
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
!theme plain
|
||||
skinparam componentStyle rectangle
|
||||
|
||||
package "musicfs" as mfs {
|
||||
component "FUSE Layer\n(readdir/open/read)" as fuse
|
||||
component "MetadataCache / DB" as db
|
||||
component "OverlayReader\n(synthesize headers)" as overlay
|
||||
component "EnrichmentQueue\n(bounded, async)" as queue
|
||||
component "EnrichmentWorker\n(background)" as worker
|
||||
}
|
||||
|
||||
component "metadata-agregator\nSearchAlbums(query, artist)" as meta
|
||||
|
||||
fuse -right-> db : lookup metadata
|
||||
db -right-> overlay : serve with overlay
|
||||
|
||||
fuse -down-> queue : enriched_at NULL?\npush request
|
||||
queue -down-> worker : dequeue
|
||||
worker -down-> meta : gRPC:\nSearchAlbums(\n query=album,\n artist=artist)
|
||||
meta -up-> worker : Album (genres,\nlabel, cover_url)
|
||||
worker -up-> db : write enriched\nmetadata to overlay
|
||||
|
||||
note bottom of meta
|
||||
metadata-agregator handles:
|
||||
• MusicBrainz API
|
||||
• rate limiting
|
||||
• PostgreSQL cache
|
||||
end note
|
||||
|
||||
note right of fuse
|
||||
File access is never blocked.
|
||||
Returns embedded tags immediately.
|
||||
Enrichment happens async.
|
||||
end note
|
||||
@enduml
|
||||
```
|
||||
|
||||
### 4.2. Enrichment Flow
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
!theme plain
|
||||
skinparam sequenceMessageAlign center
|
||||
|
||||
participant "Media Player" as mp
|
||||
participant "FUSE Layer" as fuse
|
||||
participant "MetadataCache\n(SQLite)" as db
|
||||
participant "EnrichmentQueue" as queue
|
||||
participant "EnrichmentWorker" as worker
|
||||
participant "metadata-agregator" as meta
|
||||
|
||||
== File Access (non-blocking) ==
|
||||
|
||||
mp -> fuse : open("/Pink Floyd/The Wall/01 - In the Flesh.flac")
|
||||
fuse -> db : lookup(virtual_path)
|
||||
db --> fuse : AudioMeta(artist, album, title, ...)\nenriched_at = NULL
|
||||
|
||||
fuse -> queue : try_push(file_id, artist="Pink Floyd", album="The Wall")
|
||||
note right of queue : non-blocking,\nbounded queue
|
||||
|
||||
fuse --> mp : return file handle\n(with embedded tags only)
|
||||
|
||||
== Background Enrichment (async) ==
|
||||
|
||||
queue -> worker : dequeue(file_id, artist, album)
|
||||
|
||||
worker -> worker : check enrichment_source\n(skip if 'orchestrator' or 'provider')
|
||||
|
||||
worker -> worker : dedup check:\nalready enriched same album?\n(reuse cached result)
|
||||
|
||||
worker -> meta : SearchAlbums(\n query="The Wall",\n artist="Pink Floyd",\n limit=1)
|
||||
meta --> worker : Album(\n genres=["Progressive Rock", "Art Rock"],\n label="Harvest",\n cover_url="https://...",\n album_type="album")
|
||||
|
||||
worker -> db : update_metadata(\n file_id,\n genres, label, cover_url,\n enrichment_source='provider',\n enriched_at=now())
|
||||
|
||||
worker -> worker : publish EventBus::FileModified
|
||||
|
||||
note over mp : next access sees\nenriched metadata
|
||||
@enduml
|
||||
```
|
||||
|
||||
### 4.3. Detailed Design
|
||||
|
||||
#### 4.3.1. Configuration
|
||||
|
||||
Add `[metadata_provider]` section to `config.toml`:
|
||||
|
||||
```toml
|
||||
[metadata_provider]
|
||||
enabled = false # disabled by default
|
||||
endpoint = "http://localhost:50051" # metadata-agregator gRPC
|
||||
timeout_ms = 5000 # per-request timeout
|
||||
retry_max = 3 # max retries on failure
|
||||
retry_backoff_ms = 1000 # initial backoff between retries
|
||||
queue_size = 256 # enrichment queue capacity
|
||||
```
|
||||
|
||||
Config struct addition in `musicfs-core/src/config.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MetadataProviderConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_provider_endpoint")]
|
||||
pub endpoint: String,
|
||||
#[serde(default = "default_provider_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default = "default_retry_max")]
|
||||
pub retry_max: u32,
|
||||
#[serde(default = "default_retry_backoff_ms")]
|
||||
pub retry_backoff_ms: u64,
|
||||
#[serde(default = "default_queue_size")]
|
||||
pub queue_size: usize,
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.2. Built-in Metadata Provider
|
||||
|
||||
New module in `musicfs-metadata` (not a plugin, compiled in):
|
||||
|
||||
```rust
|
||||
// musicfs-metadata/src/provider.rs
|
||||
|
||||
pub struct MetadataAgregatorProvider {
|
||||
client: MetadataServiceClient<Channel>,
|
||||
config: MetadataProviderConfig,
|
||||
}
|
||||
|
||||
impl MetadataAgregatorProvider {
|
||||
pub async fn connect(config: &MetadataProviderConfig)
|
||||
-> Result<Self>;
|
||||
|
||||
/// Query metadata-agregator by artist + album names.
|
||||
/// Returns enriched metadata if a match is found.
|
||||
pub async fn lookup(
|
||||
&self,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
) -> Result<Option<EnrichedMetadata>>;
|
||||
}
|
||||
```
|
||||
|
||||
The `lookup` method calls `SearchAlbums(query=album, artist=artist,
|
||||
limit=1)` on metadata-agregator. If a result is returned, it maps
|
||||
the response to `EnrichedMetadata`:
|
||||
|
||||
```rust
|
||||
pub struct EnrichedMetadata {
|
||||
pub genres: Vec<String>,
|
||||
pub label: Option<String>,
|
||||
pub album_type: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
pub release_date: Option<String>,
|
||||
pub total_tracks: Option<u32>,
|
||||
pub total_discs: Option<u32>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.3. ExternalMetadata Extension
|
||||
|
||||
Extend the existing `ExternalMetadata` in `musicfs-plugins/src/traits.rs`
|
||||
to carry richer data:
|
||||
|
||||
```rust
|
||||
pub struct ExternalMetadata {
|
||||
// existing fields...
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub genre: Option<String>, // kept for backward compat
|
||||
pub year: Option<u32>,
|
||||
pub track: Option<u32>,
|
||||
pub disc: Option<u32>,
|
||||
pub musicbrainz_id: Option<String>,
|
||||
pub artwork_url: Option<String>,
|
||||
|
||||
// new fields
|
||||
pub genres: Vec<String>,
|
||||
pub label: Option<String>,
|
||||
pub album_type: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.4. Database Schema Changes
|
||||
|
||||
Add columns to `file_metadata` table in
|
||||
`musicfs-cache/src/schema.sql`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE file_metadata ADD COLUMN enrichment_source TEXT;
|
||||
-- 'embedded' | 'provider' | 'orchestrator'
|
||||
ALTER TABLE file_metadata ADD COLUMN enriched_at INTEGER;
|
||||
-- unix timestamp, NULL = not enriched
|
||||
ALTER TABLE file_metadata ADD COLUMN enrichment_attempts INTEGER DEFAULT 0;
|
||||
-- number of failed enrichment attempts
|
||||
ALTER TABLE file_metadata ADD COLUMN last_enrichment_error TEXT;
|
||||
-- last error message, NULL if no error
|
||||
ALTER TABLE file_metadata ADD COLUMN genres_json TEXT;
|
||||
-- JSON array: '["Progressive Rock","Art Rock"]'
|
||||
-- separate from existing `genre` (singular) for backward compat
|
||||
ALTER TABLE file_metadata ADD COLUMN label TEXT;
|
||||
ALTER TABLE file_metadata ADD COLUMN album_type TEXT;
|
||||
ALTER TABLE file_metadata ADD COLUMN cover_url TEXT;
|
||||
```
|
||||
|
||||
> **Note:** The existing `genre TEXT` column (singular) is preserved
|
||||
> for backward compatibility. `genres_json` stores the full list.
|
||||
> The singular `genre` field is set to the first genre in the array
|
||||
> when enriched.
|
||||
|
||||
#### 4.3.5. Background Enrichment Queue + Worker
|
||||
|
||||
```rust
|
||||
// musicfs-metadata/src/enrichment.rs
|
||||
|
||||
pub struct EnrichmentQueue {
|
||||
tx: mpsc::Sender<EnrichmentRequest>,
|
||||
/// Tracks in-flight (artist, album) pairs to prevent duplicate
|
||||
/// API calls when multiple tracks from the same album are
|
||||
/// accessed simultaneously.
|
||||
in_flight: Arc<DashSet<(String, String)>>,
|
||||
}
|
||||
|
||||
struct EnrichmentRequest {
|
||||
file_id: FileId,
|
||||
artist: String,
|
||||
album: String,
|
||||
}
|
||||
|
||||
pub struct EnrichmentWorker {
|
||||
rx: mpsc::Receiver<EnrichmentRequest>,
|
||||
provider: Arc<MetadataAgregatorProvider>,
|
||||
db: Arc<Database>,
|
||||
event_bus: Arc<EventBus>,
|
||||
in_flight: Arc<DashSet<(String, String)>>,
|
||||
config: MetadataProviderConfig,
|
||||
}
|
||||
```
|
||||
|
||||
##### Enqueue-time dedup
|
||||
|
||||
When `EnrichmentQueue::try_push()` is called, it checks the
|
||||
`in_flight` `DashSet` before pushing. If `(artist, album)` is
|
||||
already in the set, the request is dropped (the worker will enrich
|
||||
all files with the same album in one pass). This prevents 12
|
||||
simultaneous track opens from making 12 identical API calls.
|
||||
|
||||
If `try_push` fails because the queue is full, log at WARN level
|
||||
and increment `enrichment_queue_drops_total` metric.
|
||||
|
||||
##### Worker loop (single-threaded, processes one at a time):
|
||||
|
||||
1. Dequeue `EnrichmentRequest`.
|
||||
2. Check `enrichment_attempts` — skip if `>= retry_max`.
|
||||
3. **Atomic conflict check**: write uses conditional SQL:
|
||||
```sql
|
||||
UPDATE file_metadata SET
|
||||
genres_json = ?, label = ?, album_type = ?, cover_url = ?,
|
||||
genre = ?, -- first genre for backward compat
|
||||
enrichment_source = 'provider',
|
||||
enriched_at = strftime('%s', 'now'),
|
||||
enrichment_attempts = 0,
|
||||
last_enrichment_error = NULL
|
||||
WHERE file_id = ?
|
||||
AND (enrichment_source IS NULL OR enrichment_source = 'embedded')
|
||||
```
|
||||
This prevents the TOCTOU race — if the orchestrator wrote between
|
||||
dequeue and now, the `WHERE` clause prevents overwrite. The UPDATE
|
||||
returns rows_affected=0, which the worker treats as "skip, already
|
||||
enriched by higher-priority source".
|
||||
4. Deduplicate by (artist, album) — if another file in the same album
|
||||
was already enriched, reuse the cached `EnrichedMetadata` result
|
||||
for all files with the same (artist, album) pair.
|
||||
5. Call `provider.lookup(artist, album)`.
|
||||
6. On success: execute atomic update (step 3) for all files with this
|
||||
(artist, album). Publish `EventBus::FileModified` for each updated
|
||||
file. Remove `(artist, album)` from `in_flight` set.
|
||||
7. On failure: increment `enrichment_attempts`, set
|
||||
`last_enrichment_error`. If `attempts < retry_max`, re-enqueue
|
||||
with exponential backoff (`retry_backoff_ms * 2^attempts`).
|
||||
If `attempts >= retry_max`, log at WARN and stop retrying.
|
||||
Remove from `in_flight` set.
|
||||
|
||||
##### Shutdown behavior
|
||||
|
||||
Queue contents are lost on shutdown. This is acceptable — files will
|
||||
be re-queued on next access since `enriched_at` is still NULL.
|
||||
Enrichment is idempotent.
|
||||
|
||||
#### 4.3.6. FUSE Integration Point
|
||||
|
||||
In the FUSE `readdir` / `getattr` / `open` path
|
||||
(`musicfs-fuse/src/ops.rs`), after loading `AudioMeta` from DB:
|
||||
|
||||
```rust
|
||||
if metadata_provider.is_enabled()
|
||||
&& file_meta.enriched_at.is_none()
|
||||
&& file_meta.enrichment_attempts < config.retry_max
|
||||
&& file_meta.audio.artist.is_some()
|
||||
&& file_meta.audio.album.is_some()
|
||||
{
|
||||
if let Err(_) = enrichment_queue.try_push(EnrichmentRequest {
|
||||
file_id: file_meta.id,
|
||||
artist: file_meta.audio.artist.unwrap(),
|
||||
album: file_meta.audio.album.unwrap(),
|
||||
}) {
|
||||
// Queue full — file will be retried on next access
|
||||
tracing::warn!(
|
||||
file_id = ?file_meta.id,
|
||||
"enrichment queue full, dropping request"
|
||||
);
|
||||
metrics::ENRICHMENT_QUEUE_DROPS.inc();
|
||||
}
|
||||
// Non-blocking: returns immediately with embedded tags
|
||||
}
|
||||
```
|
||||
|
||||
The `enrichment_attempts < retry_max` check prevents files that have
|
||||
permanently failed enrichment (e.g., metadata-agregator has no match)
|
||||
from being re-queued on every access.
|
||||
|
||||
#### 4.3.7. Conflict Resolution
|
||||
|
||||
| Source | Priority | Writes When |
|
||||
|--------|----------|-------------|
|
||||
| `orchestrator` | Highest | Always overwrites (full-system mode push) |
|
||||
| `provider` | Medium | Only if current source is NULL or `'embedded'` |
|
||||
| `embedded` | Lowest | Implicit default from file tag parsing |
|
||||
|
||||
Conflict resolution is enforced **atomically at write time** using
|
||||
conditional SQL (`WHERE enrichment_source IS NULL OR
|
||||
enrichment_source = 'embedded'`), not at dequeue time. This prevents
|
||||
the TOCTOU race where the orchestrator writes between the worker's
|
||||
check and the worker's write.
|
||||
|
||||
#### 4.3.8. Proto Changes Required
|
||||
|
||||
The existing `UpdateMetadataRequest` in `musicfs.proto` must be
|
||||
extended to carry the new enrichment fields:
|
||||
|
||||
```protobuf
|
||||
// Add to UpdateMetadataRequest:
|
||||
optional string label = 40;
|
||||
optional string album_type = 41;
|
||||
optional string cover_url = 42;
|
||||
```
|
||||
|
||||
> **Note on genres:** metadata-agregator returns `repeated Genre`
|
||||
> (objects with `id` + `name`). The provider extracts genre names
|
||||
> and stores them as a JSON array in `genres_json`. The singular
|
||||
> `genre` field in `UpdateMetadataRequest` (already exists at
|
||||
> field 9) is set to the first/primary genre for backward compat.
|
||||
|
||||
#### 4.3.9. `cover_url` Usage
|
||||
|
||||
`cover_url` is stored in the metadata overlay but is **not used by
|
||||
musicfs for artwork embedding or display** in this plan. It is
|
||||
stored for consumption by external tools (e.g., media players that
|
||||
query musicfs's gRPC `GetMetadata` and fetch artwork themselves).
|
||||
Artwork download and caching is deferred to future work.
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1. Security & Privacy
|
||||
|
||||
- gRPC connection to metadata-agregator is plaintext (internal network).
|
||||
TLS can be added via config if needed.
|
||||
- No PII involved — only music metadata.
|
||||
- No API keys stored in musicfs — metadata-agregator handles provider
|
||||
auth.
|
||||
|
||||
### 5.2. Observability
|
||||
|
||||
New tracing spans and metrics:
|
||||
|
||||
| Metric | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `enrichment_queue_depth` | Gauge | Current queue size |
|
||||
| `enrichment_queue_drops_total` | Counter | Requests dropped (queue full) |
|
||||
| `enrichment_inflight_albums` | Gauge | In-flight (artist, album) dedup set size |
|
||||
| `enrichment_lookups_total` | Counter | Total provider lookups |
|
||||
| `enrichment_hits_total` | Counter | Successful matches |
|
||||
| `enrichment_misses_total` | Counter | No match found |
|
||||
| `enrichment_errors_total` | Counter | Provider errors |
|
||||
| `enrichment_skipped_total` | Counter | Skipped (higher-priority source already wrote) |
|
||||
| `enrichment_latency_ms` | Histogram | Lookup latency |
|
||||
|
||||
### 5.3. Scalability & Performance
|
||||
|
||||
- Queue is bounded (default 256) — backpressure via `try_push`.
|
||||
- Album-level deduplication: 12 tracks in same album = 1 lookup.
|
||||
- No impact on file read latency — enrichment is fully async.
|
||||
- metadata-agregator caches in PostgreSQL, so repeated lookups are
|
||||
cheap.
|
||||
|
||||
### 5.4. Testing Plan
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_provider_connect` | Unit | gRPC connection setup |
|
||||
| `test_lookup_match` | Unit (mock) | SearchAlbums → EnrichedMetadata mapping |
|
||||
| `test_lookup_no_match` | Unit (mock) | Graceful handling of empty results, increments attempts |
|
||||
| `test_enrichment_queue_push` | Unit | Queue push + in_flight dedup |
|
||||
| `test_enrichment_queue_full_drops` | Unit | try_push fails gracefully, logs, increments metric |
|
||||
| `test_enrichment_worker_writes_db` | Integration | DB write after lookup |
|
||||
| `test_enrichment_atomic_conflict` | Integration | Orchestrator writes between dequeue and worker write → worker does NOT overwrite |
|
||||
| `test_enrichment_retry_backoff` | Unit | Failed attempts increment counter, exponential backoff |
|
||||
| `test_enrichment_max_attempts_stop` | Unit | After retry_max failures, file not re-queued |
|
||||
| `test_config_disabled` | Unit | No queue/worker when disabled |
|
||||
| `test_album_dedup_simultaneous` | Integration | 12 tracks opened at once → 1 API call |
|
||||
| `test_genre_backward_compat` | Unit | genres_json stored as array, genre set to first entry |
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 6.1. Native .so Plugin
|
||||
|
||||
Rejected. Requires bundling a separate async runtime + tonic gRPC
|
||||
stack inside a dynamically loaded library. ABI instability, duplicate
|
||||
runtimes, and deployment complexity outweigh the "purity" of using the
|
||||
plugin system.
|
||||
|
||||
### 6.2. Direct MusicBrainz/Discogs/Last.fm HTTP Clients (week-12 plan)
|
||||
|
||||
Rejected. metadata-agregator already handles these providers with rate
|
||||
limiting, caching, and deduplication. Embedding HTTP clients in musicfs
|
||||
would duplicate this work and couple musicfs to specific provider APIs.
|
||||
|
||||
### 6.3. WASM Plugin
|
||||
|
||||
Rejected. WASI networking is immature. gRPC over WASM adds unnecessary
|
||||
latency and complexity.
|
||||
|
||||
### 6.4. On-Demand Blocking Lookup
|
||||
|
||||
Rejected. Blocking file access while waiting for a gRPC response would
|
||||
cause latency spikes and kill media player UX. Background async is the
|
||||
only acceptable approach.
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Day 1)
|
||||
|
||||
- [ ] Add `MetadataProviderConfig` to config.rs
|
||||
- [ ] Add DB schema columns: `enrichment_source`, `enriched_at`,
|
||||
`enrichment_attempts`, `last_enrichment_error`, `genres_json`,
|
||||
`label`, `album_type`, `cover_url`
|
||||
- [ ] Add `label`, `album_type`, `cover_url` fields to
|
||||
`UpdateMetadataRequest` in `musicfs.proto`
|
||||
- [ ] Extend `ExternalMetadata` struct
|
||||
- [ ] Update `config.example.toml`
|
||||
|
||||
### Phase 2: Provider + Worker (Day 1–2)
|
||||
|
||||
- [ ] Implement `MetadataAgregatorProvider` (gRPC client wrapper)
|
||||
- [ ] Implement `EnrichmentQueue` with `DashSet` in-flight dedup
|
||||
- [ ] Implement `EnrichmentWorker` with:
|
||||
- Atomic conditional write (`WHERE enrichment_source IS NULL OR ...`)
|
||||
- Retry tracking (`enrichment_attempts`, exponential backoff)
|
||||
- Album-level result caching
|
||||
- [ ] Add queue drop logging + metrics
|
||||
- [ ] Wire into startup (musicfs-cli) — conditional on config
|
||||
|
||||
### Phase 3: Integration + Tests (Day 2)
|
||||
|
||||
- [ ] Wire enrichment trigger in FUSE getattr/readdir path
|
||||
(with `enrichment_attempts < retry_max` guard)
|
||||
- [ ] Write unit tests: atomic conflict, queue drops, retry backoff,
|
||||
max attempts, genre backward compat
|
||||
- [ ] Write integration test: 12-track simultaneous dedup
|
||||
- [ ] Write integration test with in-memory DB + mock gRPC server
|
||||
- [ ] Update architecture.md with metadata provider component
|
||||
|
||||
## 8. Glossary / References
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| metadata-agregator | Go gRPC service that searches MusicBrainz and caches results in PostgreSQL |
|
||||
| Enrichment | Adding genres, label, artwork URL to file metadata beyond what's in file tags |
|
||||
| Overlay | musicfs mechanism for serving modified metadata without changing origin files |
|
||||
| `AudioMeta` | Core metadata struct extracted from file tags by symphonia |
|
||||
| `ExternalMetadata` | Metadata returned by external providers (plugin trait) |
|
||||
| `enrichment_source` | Tracks who last wrote metadata: `embedded`, `provider`, or `orchestrator` |
|
||||
|
||||
- [metadata-agregator proto](../../../../metadata-agregator/proto/metadata/v1/metadata.proto)
|
||||
- [musicfs-plugins traits](../../crates/musicfs-plugins/src/traits.rs)
|
||||
- [musicfs-cache overlay](../../crates/musicfs-cache/src/overlay.rs)
|
||||
- [architecture.md](../architecture.md)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,105 +0,0 @@
|
||||
**Date**: 2026-05-17
|
||||
**Status**: Shipped
|
||||
|
||||
# Feature: Create Directory (mkdir)
|
||||
|
||||
## Overview
|
||||
|
||||
MusicFS supports creating directories in the virtual filesystem. This enables organizing files into custom folder structures beyond the auto-generated metadata-based layout.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
mkdir "/mnt/music/New Artist"
|
||||
mkdir "/mnt/music/New Artist/New Album"
|
||||
```
|
||||
|
||||
- Creates empty directory at specified path
|
||||
- Parent directory must exist
|
||||
- Standard POSIX semantics
|
||||
|
||||
### Nested Directories
|
||||
|
||||
```bash
|
||||
# This works (shell handles -p)
|
||||
mkdir -p "/mnt/music/A/B/C"
|
||||
|
||||
# Equivalent to:
|
||||
mkdir "/mnt/music/A"
|
||||
mkdir "/mnt/music/A/B"
|
||||
mkdir "/mnt/music/A/B/C"
|
||||
```
|
||||
|
||||
The `-p` flag is handled by the shell, which makes multiple `mkdir` syscalls.
|
||||
|
||||
### Brace Expansion
|
||||
|
||||
```bash
|
||||
# Shell expands this to multiple mkdir calls
|
||||
mkdir "/mnt/music/Artist/{Album1,Album2,Album3}"
|
||||
|
||||
# Equivalent to:
|
||||
mkdir "/mnt/music/Artist/Album1"
|
||||
mkdir "/mnt/music/Artist/Album2"
|
||||
mkdir "/mnt/music/Artist/Album3"
|
||||
```
|
||||
|
||||
Brace expansion is shell functionality, not filesystem.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Condition | Error |
|
||||
|-----------|-------|
|
||||
| Parent doesn't exist | `ENOENT` |
|
||||
| Path already exists | `EEXIST` |
|
||||
|
||||
## Persistence
|
||||
|
||||
**Empty directories persist across remounts.**
|
||||
|
||||
- User-created directories are stored in the `directories` table
|
||||
- On mount, directories are restored from database
|
||||
- Directories survive even when empty
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Organizing Downloads
|
||||
|
||||
```bash
|
||||
# Create structure
|
||||
mkdir "/mnt/music/Unsorted"
|
||||
mkdir "/mnt/music/Unsorted/2026"
|
||||
|
||||
# Move untagged files
|
||||
mv "/mnt/music/Unknown Artist/Unknown Album/"*.flac "/mnt/music/Unsorted/2026/"
|
||||
```
|
||||
|
||||
### Custom Collections
|
||||
|
||||
```bash
|
||||
# Create playlist-like structure
|
||||
mkdir "/mnt/music/_Playlists"
|
||||
mkdir "/mnt/music/_Playlists/Road Trip"
|
||||
|
||||
# Move tracks (they'll still be in original location too - wait, no they won't)
|
||||
# Note: mv moves, doesn't copy
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
| Component | File |
|
||||
|-----------|------|
|
||||
| Tree | `crates/musicfs-cache/src/tree.rs` |
|
||||
| FUSE | `crates/musicfs-fuse/src/filesystem.rs` |
|
||||
|
||||
### Key Functions
|
||||
|
||||
- `VirtualTree::mkdir()` - Create directory node in tree
|
||||
- `Filesystem::mkdir()` - FUSE operation handler
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No permissions**: Mode/umask parameters are ignored (always 0755)
|
||||
- **No ownership**: UID/GID set to mounting user
|
||||
@@ -1,94 +0,0 @@
|
||||
**Date**: 2026-05-17
|
||||
**Status**: Shipped
|
||||
|
||||
# Feature: Move/Rename (mv)
|
||||
|
||||
## Overview
|
||||
|
||||
MusicFS supports moving and renaming files and directories within the virtual filesystem. Moves are persisted to the SQLite database and survive remounts.
|
||||
|
||||
## Behavior
|
||||
|
||||
### File Rename
|
||||
|
||||
```bash
|
||||
mv "/mnt/music/Artist/Album/old.flac" "/mnt/music/Artist/Album/new.flac"
|
||||
```
|
||||
|
||||
- Renames file within same directory
|
||||
- Updates `virtual_path` in database
|
||||
- Original file on origin is unchanged
|
||||
|
||||
### File Move
|
||||
|
||||
```bash
|
||||
mv "/mnt/music/Artist/Album/track.flac" "/mnt/music/Other Artist/Other Album/track.flac"
|
||||
```
|
||||
|
||||
- Moves file to different directory
|
||||
- **Requires target directory to exist** (use `mkdir` first)
|
||||
- Returns `ENOENT` if target parent doesn't exist
|
||||
|
||||
### Directory Rename
|
||||
|
||||
```bash
|
||||
mv "/mnt/music/Old Artist" "/mnt/music/New Artist"
|
||||
```
|
||||
|
||||
- Renames directory and all descendants
|
||||
- All files under the directory have their `virtual_path` updated in DB
|
||||
- Single atomic operation
|
||||
|
||||
### Directory Move
|
||||
|
||||
```bash
|
||||
mv "/mnt/music/Artist/Album" "/mnt/music/Other Artist/Album"
|
||||
```
|
||||
|
||||
- Moves directory subtree to new parent
|
||||
- **Requires target parent to exist**
|
||||
- Returns `ENOENT` if target parent doesn't exist
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Condition | Error |
|
||||
|-----------|-------|
|
||||
| Source doesn't exist | `ENOENT` |
|
||||
| Target already exists | `EEXIST` |
|
||||
| Target parent doesn't exist | `ENOENT` |
|
||||
| Source is file but treated as dir | `EISDIR` |
|
||||
| Source is dir but treated as file | `ENOTDIR` |
|
||||
|
||||
## Persistence
|
||||
|
||||
- File moves: `virtual_path` column updated in `files` table
|
||||
- Directory moves: All matching `virtual_path` entries updated with new prefix
|
||||
- User directories: Tracked in separate `directories` table
|
||||
- Changes persist across unmount/remount cycles
|
||||
|
||||
On mount, the CLI:
|
||||
1. Scans origin files
|
||||
2. For each file, checks DB for stored `virtual_path` (by origin_id + real_path)
|
||||
3. Uses stored path if found, otherwise generates from metadata
|
||||
4. Restores user-created directories from `directories` table
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Read-only content**: File contents cannot be modified, only paths
|
||||
- **No cross-origin moves**: All files remain on their original origin
|
||||
- **No overwrite**: Moving to existing path fails (no implicit delete)
|
||||
|
||||
## Implementation
|
||||
|
||||
| Component | File |
|
||||
|-----------|------|
|
||||
| Database | `crates/musicfs-cache/src/db.rs` |
|
||||
| Tree | `crates/musicfs-cache/src/tree.rs` |
|
||||
| FUSE | `crates/musicfs-fuse/src/filesystem.rs` |
|
||||
|
||||
### Key Functions
|
||||
|
||||
- `Database::update_virtual_path()` - Update single file path
|
||||
- `Database::rename_directory()` - Bulk update paths with prefix
|
||||
- `VirtualTree::rename_file()` - Move file node in tree
|
||||
- `VirtualTree::rename_directory()` - Move directory subtree
|
||||
@@ -1,166 +0,0 @@
|
||||
**Date**: 2026-05-17
|
||||
**Status**: Shipped
|
||||
|
||||
# Feature: Remove (rm)
|
||||
|
||||
## Overview
|
||||
|
||||
MusicFS supports removing files and directories. Deleted files are moved to a virtual `/.trash/` directory and can be restored. The trash is browsable — users can manually move files out.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Remove File
|
||||
|
||||
```bash
|
||||
rm "/mnt/music/Artist/Album/track.flac"
|
||||
```
|
||||
|
||||
- File moves to `/.trash/Artist/Album/track.flac`
|
||||
- Original directory structure preserved in trash
|
||||
- File still accessible via `/.trash/` path
|
||||
- Database marks file as `trashed=1` with original path stored
|
||||
|
||||
### Remove Empty Directory
|
||||
|
||||
```bash
|
||||
rmdir "/mnt/music/Empty Folder"
|
||||
```
|
||||
|
||||
- Removes empty directory from tree
|
||||
- Removes from `directories` table if user-created
|
||||
- Fails with `ENOTEMPTY` if directory has children
|
||||
|
||||
### Remove Directory Recursively
|
||||
|
||||
```bash
|
||||
rm -rf "/mnt/music/Artist"
|
||||
```
|
||||
|
||||
- Shell handles recursion (depth-first unlink + rmdir)
|
||||
- All files moved to `/.trash/Artist/...`
|
||||
- Empty directories removed after files are trashed
|
||||
|
||||
## The `.trash/` Directory
|
||||
|
||||
Deleted files live in `/.trash/` with their original path structure:
|
||||
|
||||
```
|
||||
/.trash/
|
||||
├── Artist/
|
||||
│ └── Album/
|
||||
│ ├── track1.flac
|
||||
│ └── track2.flac
|
||||
└── Other Artist/
|
||||
└── song.flac
|
||||
```
|
||||
|
||||
### Browse Trash
|
||||
|
||||
```bash
|
||||
ls "/.trash/"
|
||||
ls "/.trash/Artist/Album/"
|
||||
```
|
||||
|
||||
### Manual Restore
|
||||
|
||||
```bash
|
||||
# Move file back manually - trashed flag is automatically cleared
|
||||
mv "/.trash/Artist/Album/track.flac" "/Artist/Album/"
|
||||
```
|
||||
|
||||
When moving a file out of `/.trash/`, the database `trashed` flag is automatically cleared.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
All trash commands require either `--config` or `--cache-dir`:
|
||||
|
||||
```bash
|
||||
musicfs trash -c config.toml <command>
|
||||
musicfs trash --cache-dir ./dev/cache/musicfs <command>
|
||||
```
|
||||
|
||||
### List Deleted Files
|
||||
|
||||
```bash
|
||||
musicfs trash -c config.toml list
|
||||
musicfs trash -c config.toml list --origin local-storage
|
||||
musicfs trash -c config.toml list --since 7d
|
||||
musicfs trash -c config.toml list --path "/Artist"
|
||||
```
|
||||
|
||||
Output shows index, deletion time, and original path.
|
||||
|
||||
### Restore Files
|
||||
|
||||
```bash
|
||||
# Restore single file or folder
|
||||
musicfs trash -c config.toml restore "/Artist/Album/track.flac"
|
||||
|
||||
# Restore entire folder recursively
|
||||
musicfs trash -c config.toml restore "/Artist"
|
||||
|
||||
# Restore everything
|
||||
musicfs trash -c config.toml restore --all
|
||||
```
|
||||
|
||||
CLI restore writes paths to a pending restore file and sends SIGHUP to the daemon.
|
||||
The daemon processes pending restores and moves files back from `/.trash/`.
|
||||
|
||||
### Empty Trash
|
||||
|
||||
```bash
|
||||
# Permanently delete all trashed files
|
||||
musicfs trash -c config.toml empty
|
||||
|
||||
# Delete old items only
|
||||
musicfs trash -c config.toml empty --older-than 30d
|
||||
|
||||
# Delete by path pattern
|
||||
musicfs trash -c config.toml empty --pattern "/Artist"
|
||||
```
|
||||
|
||||
**Warning:** Empty permanently removes files from MusicFS database. Origin files are unaffected.
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Condition | Error |
|
||||
|-----------|-------|
|
||||
| Path doesn't exist | `ENOENT` |
|
||||
| `rm` on directory (without `-r`) | `EISDIR` |
|
||||
| `rmdir` on file | `ENOTDIR` |
|
||||
| `rmdir` on non-empty directory | `ENOTEMPTY` |
|
||||
| `rmdir` on `/.trash/` | `EPERM` |
|
||||
|
||||
## Database Schema
|
||||
|
||||
Files table extended with trash columns:
|
||||
|
||||
```sql
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
original_path TEXT,
|
||||
trashed_at INTEGER
|
||||
```
|
||||
|
||||
Partial index for efficient trash queries:
|
||||
```sql
|
||||
CREATE INDEX idx_files_trashed ON files(trashed) WHERE trashed = 1;
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Delete (`rm`)**: FUSE `unlink` moves file to `/.trash/`, marks `trashed=1` in DB
|
||||
2. **Manual restore (`mv`)**: Moving out of `/.trash/` automatically clears `trashed` flag
|
||||
3. **CLI restore**: Writes pending paths, sends SIGHUP to daemon, daemon processes restores
|
||||
4. **Empty**: Deletes matching records from database
|
||||
|
||||
## Persistence
|
||||
|
||||
- Trashed files persist across remounts (stored in `/.trash/` subtree)
|
||||
- Files marked with `trashed=1`, `original_path`, `trashed_at` in database
|
||||
- PID file at `{cache_dir}/musicfs.pid` for CLI→daemon communication
|
||||
|
||||
## Limitations
|
||||
|
||||
- **No hard delete of remote files**: Origin content is never modified
|
||||
- **Trash uses virtual space**: Files still in tree under `/.trash/` until emptied
|
||||
- **CLI restore requires running daemon**: Manual `mv` works without daemon
|
||||
@@ -1,239 +0,0 @@
|
||||
# MusicFS MVP Performance Review
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Test Data**: Metallica - 72 Seasons (12 FLAC tracks, 625MB, 16-bit/44.1kHz)
|
||||
**Origin**: Local filesystem (Docker volume)
|
||||
**System**: Linux, NixOS
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Phase 1 MVP is functional** - the system mounts, browses, and reads files successfully. Audio playback works with valid FLAC headers served. However, there's a **critical gap** between the architecture specification and current implementation regarding content chunking.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### Throughput Comparison
|
||||
|
||||
| Metric | Direct FS | MusicFS Cold | MusicFS Warm | Target (Spec) | Status |
|
||||
|--------|-----------|--------------|--------------|---------------|--------|
|
||||
| Single file read (64MB) | 0.022s (3 GB/s) | 0.035s (1.8 GB/s) | 0.020s (3.2 GB/s) | >500 MB/s | ✅ |
|
||||
| Full album read (625MB) | 0.149s (4.2 GB/s) | 0.274s (2.3 GB/s) | 0.211s (3.0 GB/s) | >500 MB/s | ✅ |
|
||||
|
||||
### Metadata Operations
|
||||
|
||||
| Operation | Result | Target (Spec) | Status |
|
||||
|-----------|--------|---------------|--------|
|
||||
| Root listing | 0.006s | <10ms | ✅ |
|
||||
| Full tree traversal (12 files) | 0.007s | <50ms | ✅ |
|
||||
| stat() per operation | 0.003s | <1ms | ⚠️ |
|
||||
| 4KB small reads (per op) | 0.006s | <1ms | ⚠️ |
|
||||
| Random seek 1MB | 0.008-0.015s | <50ms | ✅ |
|
||||
| Mount time | ~8ms | <500ms | ✅ |
|
||||
|
||||
### Cache Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Cache speedup (single file) | 1.75x |
|
||||
| Cache speedup (full album) | 1.30x |
|
||||
| Cache size | 25MB |
|
||||
| Chunk count | 12 |
|
||||
| Expected cache size | 625MB |
|
||||
|
||||
### FUSE Overhead
|
||||
|
||||
| Scenario | Overhead vs Direct |
|
||||
|----------|-------------------|
|
||||
| Single file cold cache | 59% slower |
|
||||
| Single file warm cache | 9% faster* |
|
||||
| Full album cold cache | 84% slower |
|
||||
| Full album warm cache | 42% slower |
|
||||
|
||||
*Warm cache appears faster due to OS page cache effects on both paths.
|
||||
|
||||
---
|
||||
|
||||
## What's Working Well ✅
|
||||
|
||||
### 1. Mount Performance
|
||||
- Mount completes in ~8ms (spec: <500ms) — **62x better than target**
|
||||
- O(1) mount time achieved — no file scanning blocks mount
|
||||
- Lazy loading working as designed per architecture section 4.3.1
|
||||
|
||||
### 2. Virtual Tree Organization
|
||||
- Correct Artist/Album/Track hierarchy derived from metadata
|
||||
- Example path: `/Metallica/72 Seasons/01. 72 Seasons.flac`
|
||||
- Special character sanitization working (`/`, `\`, `:`, etc.)
|
||||
|
||||
### 3. File Reading
|
||||
- Valid FLAC headers served (`fLaC` magic bytes verified)
|
||||
- Sequential reads work correctly
|
||||
- Random access (seek) functional
|
||||
- Concurrent reads from multiple processes work
|
||||
|
||||
### 4. FUSE Integration
|
||||
- Read-only enforcement (EROFS returned on write attempts)
|
||||
- Proper inode assignment and file attributes
|
||||
- AllowOther mount option working
|
||||
- Clean unmount via fusermount3
|
||||
|
||||
### 5. Throughput
|
||||
- Exceeds 500 MB/s target significantly (2-3 GB/s achieved)
|
||||
- Parallel reads scale appropriately (4 files in 0.060s)
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues 🔴
|
||||
|
||||
### Issue 1: Incomplete File Caching
|
||||
|
||||
**Symptom**: Cache is 25MB instead of expected 625MB (12 files × ~2MB each instead of full files)
|
||||
|
||||
**Root Cause**: In `fetcher.rs:74`:
|
||||
```rust
|
||||
let data = origin.read(&meta.real_path.path, 0, meta.size as u32).await?;
|
||||
```
|
||||
|
||||
And in `local.rs:96-98`:
|
||||
```rust
|
||||
let mut buffer = vec![0u8; size as usize];
|
||||
let bytes_read = file.read(&mut buffer).await?;
|
||||
buffer.truncate(bytes_read);
|
||||
```
|
||||
|
||||
`tokio::fs::File::read()` reads **up to** buffer size but returns when the kernel buffer is exhausted (~2MB typical). Only first ~2MB of each file is being cached.
|
||||
|
||||
**Impact**:
|
||||
- Subsequent reads beyond 2MB offset hit origin every time
|
||||
- No cache benefit for majority of file content
|
||||
- Cache eviction policy not being exercised
|
||||
|
||||
**Required Fix**: Use `read_to_end()` or loop until all bytes read:
|
||||
```rust
|
||||
let mut buffer = Vec::with_capacity(size as usize);
|
||||
file.read_to_end(&mut buffer).await?;
|
||||
```
|
||||
|
||||
### Issue 2: No CDC Chunking Implemented
|
||||
|
||||
**Architecture Spec** (Section 4.3.2):
|
||||
> "All file content is stored as content-addressed chunks... Avg chunk: 64KB, Min: 16KB, Max: 256KB"
|
||||
|
||||
**Current Implementation**: Each file stored as ONE chunk (no FastCDC integration)
|
||||
|
||||
**Impact**:
|
||||
- No content deduplication possible
|
||||
- Delta sync impossible (FR-11.2 unmet)
|
||||
- Cache efficiency severely reduced for similar files
|
||||
|
||||
---
|
||||
|
||||
## Architecture Gaps 🟡
|
||||
|
||||
| Spec Requirement | Current State | Gap |
|
||||
|------------------|---------------|-----|
|
||||
| CDC chunking (64KB avg) | No chunking | Missing FastCDC integration |
|
||||
| Delta sync (>90% bandwidth reduction) | Not implemented | Requires CDC first |
|
||||
| Deduplication (FR-20) | Not implemented | Requires CDC first |
|
||||
| Search engine (tantivy) | Not implemented | Phase 3 scope |
|
||||
| gRPC Control API | Not implemented | Phase 4 scope |
|
||||
| Multi-origin federation | Single origin only | Phase 2 scope |
|
||||
| Metadata persistence (SQLite) | In-memory HashMap | Missing persistence |
|
||||
|
||||
---
|
||||
|
||||
## Performance Analysis
|
||||
|
||||
### Why Warm Cache Appears Faster Than Direct FS
|
||||
|
||||
The warm cache shows 3.2 GB/s vs direct 3.0 GB/s because:
|
||||
1. OS page cache is warm for both MusicFS chunks AND origin files
|
||||
2. Both measurements are essentially hitting RAM, variance expected
|
||||
3. MusicFS chunks may have slightly better cache locality
|
||||
|
||||
### stat() Latency Above Target
|
||||
|
||||
Current: 3ms per stat() vs target <1ms
|
||||
|
||||
Possible causes:
|
||||
1. `RwLock<VirtualTree>` contention overhead
|
||||
2. HashMap lookup plus FUSE context switch
|
||||
3. Measurement includes full round-trip through FUSE
|
||||
|
||||
Mitigation options:
|
||||
- Consider lock-free concurrent data structures
|
||||
- Implement finer-grained locking
|
||||
- Cache hot inodes in separate fast-path structure
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Fixes (Before Phase 2)
|
||||
|
||||
1. **Fix file reading** — Use `read_to_end()` or implement proper streaming read loop
|
||||
2. **Add CDC chunking** — Integrate FastCDC per architecture spec section 4.3.2
|
||||
3. **Persist metadata** — Move from in-memory HashMap to SQLite as specified
|
||||
|
||||
### Phase 2 Priorities
|
||||
|
||||
1. Complete CDC chunking implementation (prerequisite for delta sync)
|
||||
2. Add SQLite metadata persistence (FR-7.2)
|
||||
3. Implement multi-origin support (FR-13)
|
||||
|
||||
### Testing Gaps to Address
|
||||
|
||||
1. No automated E2E tests for real FUSE operations
|
||||
2. No stress testing with concurrent access patterns
|
||||
3. No large library testing (target: 1M+ files per NFR-3.1)
|
||||
4. No offline mode testing (origin unavailable scenarios)
|
||||
|
||||
---
|
||||
|
||||
## Test Environment Details
|
||||
|
||||
```
|
||||
Origin Path: /home/fujin/.local/share/docker/volumes/containers_downloads/_data/Metallica - 72 Seasons (2023) [FLAC] 88/
|
||||
Mount Point: /tmp/musicfs-benchmark/mount
|
||||
Cache Dir: /tmp/musicfs-benchmark/cache
|
||||
Binary: target/release/musicfs (via nix develop)
|
||||
|
||||
Files:
|
||||
01. 72 Seasons.flac 64MB
|
||||
02. Shadows Follow.flac 50MB
|
||||
03. Screaming Suicide.flac 45MB
|
||||
04. Sleepwalk My Life Away.flac 54MB
|
||||
05. You Must Burn!.flac 57MB
|
||||
06. Lux Æterna.flac 27MB
|
||||
07. Crown Of Barbed Wire.flac 46MB
|
||||
08. Chasing Light.flac 55MB
|
||||
09. If Darkness Had A Son.flac 51MB
|
||||
10. Too Far Gone_.flac 37MB
|
||||
11. Room Of Mirrors.flac 45MB
|
||||
12. Inamorata.flac 89MB
|
||||
Total: 625MB, 12 tracks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**The MVP demonstrates core functionality works** — mounting, browsing, and reading audio files through FUSE. Throughput performance exceeds targets significantly.
|
||||
|
||||
However, **the cache implementation is incomplete**:
|
||||
- Only ~4% of file content is being cached (25MB/625MB)
|
||||
- No CDC chunking means no deduplication or delta sync capability
|
||||
- Architecture requirements FR-8.2, FR-11.2, FR-20 are unmet
|
||||
|
||||
**Recommendation**: Fix the file reading issue and add CDC chunking before proceeding to Phase 2. The architecture is sound; implementation needs to catch up to specification.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Architecture Specification](architecture.md) — Section 4.3.2 (CAS), Section 4.3.5 (Read Flow)
|
||||
- [Requirements Specification](requirements.md) — FR-8 (Content Cache), FR-11 (Delta Sync), FR-20 (CAS)
|
||||
- [Week 4b Plan](plans/week-04b-origin-connector.md) — ContentFetcher implementation
|
||||
@@ -1,982 +0,0 @@
|
||||
# Comprehensive Logging Plan
|
||||
|
||||
**Goal**: Add production-grade logging with trace-level observability, file rotation, and systemd integration
|
||||
**Effort**: ~10-12 hours
|
||||
**Dependencies**: Existing libraries only (no custom code)
|
||||
|
||||
> **Review Status**: Reviewed by Oracle - all gaps addressed
|
||||
|
||||
---
|
||||
|
||||
## Libraries Used
|
||||
|
||||
| Need | Library | Status |
|
||||
|------|---------|--------|
|
||||
| Instrumentation | `tracing` | Already in workspace |
|
||||
| Subscriber/filtering | `tracing-subscriber` | Already in workspace |
|
||||
| File rotation | `tracing-appender` | Add to workspace |
|
||||
| systemd journal | `tracing-journald` | Add to workspace |
|
||||
| Compression | `logrotate` (Linux tool) | Config file only |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Config & Dependencies (2 hours)
|
||||
|
||||
### 1.1 Add dependencies to workspace
|
||||
|
||||
```toml
|
||||
# Cargo.toml [workspace.dependencies]
|
||||
tracing-appender = "0.2"
|
||||
tracing-journald = "0.3"
|
||||
```
|
||||
|
||||
```toml
|
||||
# crates/musicfs-cli/Cargo.toml
|
||||
tracing-appender.workspace = true
|
||||
tracing-journald.workspace = true
|
||||
```
|
||||
|
||||
### 1.2 Add LoggingConfig to config.rs
|
||||
|
||||
```rust
|
||||
// crates/musicfs-core/src/config.rs
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub mount_point: PathBuf,
|
||||
pub cache_dir: PathBuf,
|
||||
pub origins: Vec<OriginConfig>,
|
||||
#[serde(default)]
|
||||
pub cache: CacheConfig,
|
||||
#[serde(default)]
|
||||
pub health: HealthConfig,
|
||||
#[serde(default)]
|
||||
pub logging: LoggingConfig, // NEW
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
#[serde(default = "default_log_dir")]
|
||||
pub log_dir: PathBuf,
|
||||
|
||||
#[serde(default)]
|
||||
pub json_output: bool,
|
||||
|
||||
#[serde(default = "default_true")]
|
||||
pub journald: bool,
|
||||
|
||||
#[serde(default = "default_log_level")]
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
impl Default for LoggingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
log_dir: default_log_dir(),
|
||||
json_output: false,
|
||||
journald: true,
|
||||
level: default_log_level(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_log_dir() -> PathBuf {
|
||||
PathBuf::from("/var/log/musicfs")
|
||||
}
|
||||
fn default_log_level() -> String {
|
||||
"musicfs=info,warn".to_string()
|
||||
}
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Expand init_logging() in main.rs
|
||||
|
||||
```rust
|
||||
// crates/musicfs-cli/src/main.rs
|
||||
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
|
||||
fn init_logging(config: &LoggingConfig) -> Result<WorkerGuard> {
|
||||
std::fs::create_dir_all(&config.log_dir)?;
|
||||
|
||||
// File layer with daily rotation
|
||||
let file_appender = tracing_appender::rolling::daily(&config.log_dir, "musicfs.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let file_layer = if config.json_output {
|
||||
fmt::layer()
|
||||
.json()
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.boxed()
|
||||
} else {
|
||||
fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.boxed()
|
||||
};
|
||||
|
||||
// Journald layer (Linux only)
|
||||
#[cfg(target_os = "linux")]
|
||||
let journald_layer = if config.journald {
|
||||
tracing_journald::layer()
|
||||
.ok()
|
||||
.map(|l| l.with_syslog_identifier("musicfs".to_string()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Stderr layer for interactive use
|
||||
let stderr_layer = fmt::layer()
|
||||
.with_writer(std::io::stderr)
|
||||
.compact();
|
||||
|
||||
// Filter from config or env
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new(&config.level));
|
||||
|
||||
// Compose
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(file_layer)
|
||||
.with(stderr_layer);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let subscriber = subscriber.with(journald_layer);
|
||||
|
||||
subscriber.init();
|
||||
|
||||
tracing::info!(version = env!("CARGO_PKG_VERSION"), "MusicFS starting");
|
||||
Ok(guard)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 Add logrotate config
|
||||
|
||||
```bash
|
||||
# dist/logrotate.d/musicfs
|
||||
/var/log/musicfs/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 musicfs musicfs
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Add tracing to musicfs-core (1 hour)
|
||||
|
||||
### 2.1 Add dependency
|
||||
|
||||
```toml
|
||||
# crates/musicfs-core/Cargo.toml
|
||||
[dependencies]
|
||||
tracing.workspace = true # ADD THIS
|
||||
```
|
||||
|
||||
### 2.2 Instrument core modules
|
||||
|
||||
| File | What to Add |
|
||||
|------|-------------|
|
||||
| `config.rs` | Log config file loading, parse errors |
|
||||
| `credentials.rs` | Log credential loading (redacted values) |
|
||||
| `events.rs` | Log event publishing with counts |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Instrument Hot Paths (4 hours)
|
||||
|
||||
### Priority order by impact
|
||||
|
||||
| Crate | Files | What to Add |
|
||||
|-------|-------|-------------|
|
||||
| musicfs-fuse | `filesystem.rs` | `#[instrument]` on all FUSE ops, trace at decision points |
|
||||
| musicfs-origins | `failover.rs`, `health.rs`, `router.rs` | Retry loops, state transitions, selection logic |
|
||||
| musicfs-cache | `tree.rs`, `metadata.rs` | Tree mutations, cache hit/miss |
|
||||
| musicfs-cas | `reader.rs`, `store.rs` | Chunk operations, dedup decisions |
|
||||
| musicfs-sync | `delta.rs`, `watcher.rs` | Change detection, file events |
|
||||
|
||||
### Instrumentation patterns
|
||||
|
||||
```rust
|
||||
// Function level - add to all public async functions
|
||||
#[tracing::instrument(level = "debug", skip(self), fields(path = %path))]
|
||||
pub async fn read(&self, path: &str) -> Result<Bytes> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// Decision points - add trace! at match/if branches
|
||||
match result {
|
||||
Ok(data) => {
|
||||
tracing::trace!(bytes = data.len(), "read success");
|
||||
data
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::trace!(error = %e, "read failed");
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// State changes - use info! for important transitions
|
||||
tracing::info!(old = ?old_status, new = ?new_status, origin = %id, "health changed");
|
||||
|
||||
// Cache operations
|
||||
tracing::trace!(hit = true, fresh = true, "cache hit");
|
||||
tracing::trace!(hit = false, "cache miss");
|
||||
```
|
||||
|
||||
### FUSE operations (filesystem.rs) - highest priority
|
||||
|
||||
| Operation | Level | Fields |
|
||||
|-----------|-------|--------|
|
||||
| `lookup()` | debug | parent, name, result_ino |
|
||||
| `getattr()` | debug | ino, file_type |
|
||||
| `readdir()` | debug | ino, entry_count |
|
||||
| `read()` | debug | ino, offset, size, bytes_read |
|
||||
| `open()` | debug | ino, flags |
|
||||
| `release()` | trace | ino |
|
||||
|
||||
### Origin operations - critical for debugging
|
||||
|
||||
| Function | Level | Fields |
|
||||
|----------|-------|--------|
|
||||
| `read_with_failover()` | debug | path, origins_tried, success |
|
||||
| `read_with_retry()` | trace | origin, attempt, success |
|
||||
| `check_health()` | debug | origin, old_status, new_status |
|
||||
| `select_origin()` | trace | candidates, selected, reason |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update Production Files (1 hour)
|
||||
|
||||
### 4.1 Update systemd service
|
||||
|
||||
```ini
|
||||
# dist/musicfs.service (add these lines)
|
||||
Environment="RUST_LOG=musicfs=info,warn"
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=musicfs
|
||||
RateLimitIntervalSec=30s
|
||||
RateLimitBurst=1000
|
||||
```
|
||||
|
||||
### 4.2 Example config.toml
|
||||
|
||||
```toml
|
||||
# dist/config.example.toml
|
||||
mount_point = "/mnt/music"
|
||||
cache_dir = "/var/cache/musicfs"
|
||||
|
||||
[logging]
|
||||
log_dir = "/var/log/musicfs"
|
||||
json_output = true
|
||||
journald = true
|
||||
level = "musicfs=info,warn"
|
||||
|
||||
[cache]
|
||||
metadata_cache_mb = 100
|
||||
content_cache_gb = 10
|
||||
|
||||
[health]
|
||||
check_interval_secs = 30
|
||||
timeout_ms = 5000
|
||||
|
||||
[[origins]]
|
||||
id = "local"
|
||||
origin_type = "local"
|
||||
priority = 1
|
||||
path = "/srv/music"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Log Locations by Level
|
||||
|
||||
### ERROR Level (25+ locations) - Unrecoverable Failures
|
||||
|
||||
| File | Line | Log Message |
|
||||
|------|------|-------------|
|
||||
| `musicfs-grpc/src/webhook.rs` | 43 | `error!("Failed to initialize webhook HTTP client: {error}")` |
|
||||
| `musicfs-grpc/src/webhook.rs` | 133 | `error!("Invalid HMAC secret key for webhook signature: {error}")` |
|
||||
| `musicfs-plugins/src/manager.rs` | 272 | `error!("Plugin manager initialization failed: {error}")` |
|
||||
| `musicfs-plugins/src/wasm.rs` | 142,183 | `error!("WASM plugin host initialization failed: {error}")` |
|
||||
| `musicfs-search/src/index.rs` | 211,217 | `error!("Search index corrupted: failed to deserialize at position {pos}")` |
|
||||
| `musicfs-cas/src/store.rs` | 105 | `error!("CAS chunk not found: {hash} - possible data loss")` |
|
||||
| `musicfs-cas/src/store.rs` | 124-131 | `error!("CAS integrity check failed: expected {expected}, got {actual}")` |
|
||||
| `musicfs-fuse/src/filesystem.rs` | 103 | `error!("Failed to mount filesystem at {mountpoint}: {error}")` |
|
||||
| `musicfs-origins/src/failover.rs` | 76 | `error!("No origins available for path {path}")` |
|
||||
| `musicfs-origins/src/failover.rs` | 125,186 | `error!("Max retries ({max_attempts}) exceeded for origin {origin_id}")` |
|
||||
| `musicfs-origins/src/nfs.rs` | 63 | `error!("NFS stale file handle after {max_retries} retries for {path}")` |
|
||||
| `musicfs-cas/src/reader.rs` | 75 | `error!("File manifest not found for file_id {file_id}")` |
|
||||
| `musicfs-cas/src/fetcher.rs` | 60,68 | `error!("File/Origin not found for file_id {file_id}")` |
|
||||
| `musicfs-search/src/indexer.rs` | 44,56 | `error!("Search indexer/commit failed: {error}")` |
|
||||
| `musicfs-sync/src/watcher.rs` | 36,59,63 | `error!("Watcher failed for origin {origin_id}: {error}")` |
|
||||
|
||||
### WARN Level (50+ locations) - Recoverable Issues
|
||||
|
||||
| Category | File | Line | Log Message |
|
||||
|----------|------|------|-------------|
|
||||
| **Retry Logic** | `failover.rs` | 90 | `warn!("Origin {origin_id} failed: {error}, trying next (attempt {n}/{total})")` |
|
||||
| **Retry Logic** | `failover.rs` | 111-118 | `warn!("Retrying origin {origin_id} after {delay:?} (attempt {n}/{max})")` |
|
||||
| **Retry Logic** | `nfs.rs` | 47-52 | `warn!("NFS stale handle for {path} (attempt {n}/{max}), retrying")` |
|
||||
| **Retry Logic** | `smb.rs` | 45 | `warn!("SMB connection lost (ENOTCONN), retrying (attempt {n}/{max})")` |
|
||||
| **Retry Logic** | `webhook.rs` | 94-108 | `warn!("Webhook delivery failed to {url} (attempt {n}/{max}): {error}")` |
|
||||
| **Fallback** | `failover.rs` | 70-73 | `warn!("No healthy origins for {path}, using fallback {origin_id}")` |
|
||||
| **Timeout** | `smb.rs` | 107-109 | `warn!("SMB health check timed out after 5s for {origin_id}")` |
|
||||
| **Timeout** | `nfs.rs` | 104-106 | `warn!("NFS health check timed out after 5s for {origin_id}")` |
|
||||
| **Timeout** | `prefetch.rs` | 91 | `warn!("Prefetch event receive timed out after 1s")` |
|
||||
| **Health** | `health.rs` | 209 | `warn!("Origin {origin_id} is degraded (failures: {count})")` |
|
||||
| **Health** | `health.rs` | 217-220 | `warn!("Origin {origin_id} is now unhealthy after {n} consecutive failures")` |
|
||||
| **Remote FS** | `smb.rs` | 118 | `warn!("SMB watch using inotify on {share_path} - may be unreliable")` |
|
||||
| **Remote FS** | `nfs.rs` | 115 | `warn!("NFS watch using inotify on {mount_point} - may be unreliable")` |
|
||||
| **Plugin** | `manager.rs` | 152 | `warn!("Failed to load plugin from {path}: {error}")` |
|
||||
| **Plugin** | `manager.rs` | 193-194 | `warn!("Failed to unload plugin {plugin_id}: {error}")` |
|
||||
| **Prefetch** | `prefetch.rs` | 97 | `warn!("Failed to record access pattern for {file_id}: {error}")` |
|
||||
| **Prefetch** | `prefetch.rs` | 159-161 | `warn!("Prefetch skipped: concurrency limit reached ({max})")` |
|
||||
| **Search** | `indexer.rs` | 49 | `warn!("Search indexer event receive error: {error}")` |
|
||||
| **Search** | `indexer.rs` | 82 | `warn!("No metadata found for file {path}, skipping indexing")` |
|
||||
| **Collections** | `collections.rs` | 146,180 | `warn!("Failed to save/delete collection {name}: {error}")` |
|
||||
|
||||
### INFO Level (35+ locations) - Lifecycle & Major Operations
|
||||
|
||||
| Category | File | Line | Log Message |
|
||||
|----------|------|------|-------------|
|
||||
| **Lifecycle** | `main.rs` | 118 | `info!(version = env!("CARGO_PKG_VERSION"), "MusicFS starting")` |
|
||||
| **Lifecycle** | `filesystem.rs` | 94 | `info!("Mounting MusicFS at {:?}", mountpoint)` |
|
||||
| **Lifecycle** | `filesystem.rs` | 154 | `info!("MusicFS initialized")` |
|
||||
| **Lifecycle** | `filesystem.rs` | 159 | `info!("MusicFS destroyed")` |
|
||||
| **Origin** | `registry.rs` | 28 | `info!("Registering origin {} with priority {}", id, priority)` |
|
||||
| **Origin** | `registry.rs` | 36 | `info!("Unregistering origin {}", id)` |
|
||||
| **Origin** | `watcher.rs` | 65 | `info!("Watching origin {} at {:?}", origin_id, path)` |
|
||||
| **Config** | `main.rs` | 127 | `info!("Cache directory: {:?}", cache_dir)` |
|
||||
| **Config** | `main.rs` | 141 | `info!("CAS store initialized")` |
|
||||
| **Config** | `store.rs` | 51 | `info!("CAS store opened: {} chunks, {} bytes", count, size)` (ADD) |
|
||||
| **Sync** | `main.rs` | 150,152 | `info!("Scanning music files...")` / `info!("Found {} music files", count)` |
|
||||
| **Sync** | `delta.rs` | 104 | `info!("Delta complete: {} added, {} removed, {} modified", a, r, m)` |
|
||||
| **Sync** | `delta.rs` | 63 | `info!("Sync started for origin {}", origin_id)` (ADD) |
|
||||
| **Index** | `main.rs` | 160 | `info!("Virtual tree built")` |
|
||||
| **Index** | `indexer.rs` | 62 | `info!("Indexer stopping")` |
|
||||
| **Index** | `indexer.rs` | 114 | `info!("Indexed {} files", count)` |
|
||||
| **Index** | `index.rs` | 170 | `info!("Search index committed")` |
|
||||
| **Health** | `health.rs` | 202 | `info!("Origin {} is now healthy", id)` |
|
||||
| **Health** | `health.rs` | 150 | `info!("Health monitor started with interval {:?}", interval)` (ADD) |
|
||||
| **Plugin** | `manager.rs` | 127 | `info!("Initializing plugin system")` |
|
||||
| **Plugin** | `manager.rs` | 150 | `info!("Loaded plugin '{}' with id {:?}", name, id)` |
|
||||
| **Plugin** | `manager.rs` | 256 | `info!("Shutting down plugin system")` |
|
||||
| **Cache** | `prefetch.rs` | 123 | `info!("Prefetch engine stopped")` |
|
||||
| **Cache** | `prefetch.rs` | 174 | `info!("Prefetched {:?}: {} chunks, {} bytes", file_id, chunks, bytes)` |
|
||||
| **Cache** | `eviction.rs` | 51 | `info!("Evicted {} bytes from cache", bytes)` |
|
||||
| **Cache** | `prefetch.rs` | 73 | `info!("Prefetch engine started (lookahead: {}, max_concurrent: {})")` (ADD) |
|
||||
|
||||
### DEBUG Level (60+ locations) - Operation Details
|
||||
|
||||
| Category | File | Line | Log Message |
|
||||
|----------|------|------|-------------|
|
||||
| **FUSE lookup** | `filesystem.rs` | 162,195,200 | Entry + result/miss |
|
||||
| **FUSE getattr** | `filesystem.rs` | 203,230,233 | Entry + result/miss |
|
||||
| **FUSE readdir** | `filesystem.rs` | 237,263,303 | Entry + result/miss |
|
||||
| **FUSE read** | `filesystem.rs` | 325,338,362,364 | Entry + file_id + result/error |
|
||||
| **Local origin** | `local.rs` | 51,68 | readdir entry + result |
|
||||
| **Local origin** | `local.rs` | 88-91,112 | read entry + result |
|
||||
| **SMB origin** | `smb.rs` | 86,93 | readdir/read entry + result |
|
||||
| **NFS origin** | `nfs.rs` | 81,89 | readdir/read entry + result |
|
||||
| **Failover** | `failover.rs` | 66,82,87 | Entry + trying origin + success |
|
||||
| **Tree lookup** | `tree.rs` | 124,132 | Entry + result |
|
||||
| **Metadata cache** | `metadata.rs` | 36,40 | lookup + is_fresh entry/result |
|
||||
| **CAS store** | `store.rs` | 70,101 | put/get entry |
|
||||
| **File reader** | `reader.rs` | 66,86 | manifest cache + read entry |
|
||||
| **Search** | `ops/search.rs` | 107,141,182 | readdir_query + readlink + execute_query |
|
||||
| **Search index** | `index.rs` | 98,174 | index_file + search entry |
|
||||
| **Fetcher** | `fetcher.rs` | 54,61,121 | fetch_file entry + meta + ensure_cached |
|
||||
|
||||
**Key DEBUG fields**: `ino`, `parent`, `name`, `offset`, `size`, `bytes_read`, `origin_id`, `path`, `file_id`, `query`, `results_count`, `latency_ms`
|
||||
|
||||
### TRACE Level (100+ locations) - Fine-Grained Flow
|
||||
|
||||
| Category | File | Lines | What to Log |
|
||||
|----------|------|-------|-------------|
|
||||
| **Manifest cache** | `reader.rs` | 67-74 | Cache hit/miss decision |
|
||||
| **Chunk iteration** | `reader.rs` | 107-127 | Each chunk: skip/read boundaries |
|
||||
| **CAS dedup** | `store.rs` | 74-77 | Dedup hit decision |
|
||||
| **CAS integrity** | `store.rs` | 121-134 | Verification result |
|
||||
| **Tree lookup** | `tree.rs` | 118-129 | Path→inode + child lookup |
|
||||
| **Tree parent** | `tree.rs` | 148-153 | Parent resolution path |
|
||||
| **Prefetch event** | `prefetch.rs` | 91-120 | Event type match arms |
|
||||
| **Prefetch semaphore** | `prefetch.rs` | 150-164 | In-flight check + acquire |
|
||||
| **Delta scan** | `delta.rs` | 79-102 | Each file: cached/modified/unchanged/removed |
|
||||
| **Delta entries** | `delta.rs` | 128-146 | Each entry: dir/audio/skip |
|
||||
| **CDC chunking** | `cdc.rs` | 84-93 | Each chunk: offset/length/hash |
|
||||
| **Failover origin** | `failover.rs` | 68-93 | Each origin attempt result |
|
||||
| **Failover retry** | `failover.rs` | 107-122 | Each retry: attempt/success/delay |
|
||||
| **Router select** | `router.rs` | 79-108 | Each candidate + selection reason |
|
||||
| **FUSE node→attr** | `filesystem.rs` | 109-145 | Directory vs file conversion |
|
||||
| **FUSE lookup** | `filesystem.rs` | 192-200 | Found/not found |
|
||||
| **FUSE readdir** | `filesystem.rs` | 274-291 | Each child entry |
|
||||
| **FUSE read** | `filesystem.rs` | 340-367 | file_id resolution + result |
|
||||
| **Metadata tag** | `parser.rs` | 86-100 | Each tag extraction |
|
||||
| **Health transition** | `health.rs` | 199-237 | State transition details |
|
||||
| **Latency recording** | `router.rs` | 23-42 | Stats update per sample |
|
||||
|
||||
**Key TRACE patterns**:
|
||||
- Every `match` arm: `trace!("match arm: {variant}")`
|
||||
- Every `if/else`: `trace!("branch: {condition}={value}")`
|
||||
- Every loop iteration: `trace!("iteration {i}/{total}: ...")`
|
||||
- Every cache lookup: `trace!("cache lookup key={key}, hit={hit}")`
|
||||
|
||||
---
|
||||
|
||||
## gRPC Handler Instrumentation (ADDED - Oracle Review)
|
||||
|
||||
**Gap identified**: 8/10 gRPC handlers had no logging.
|
||||
|
||||
### server.rs - All Handlers
|
||||
|
||||
| Handler | Line | Level | Log Message |
|
||||
|---------|------|-------|-------------|
|
||||
| `get_status()` | 209 | DEBUG | `debug!("gRPC get_status called")` |
|
||||
| `get_cache_stats()` | 241 | DEBUG | `debug!("gRPC get_cache_stats called")` |
|
||||
| `clear_cache()` | 278 | INFO | `info!("gRPC clear_cache: clearing {tier}")` |
|
||||
| `prefetch()` | 296 | DEBUG | `debug!(file_count = paths.len(), "gRPC prefetch started")` |
|
||||
| `list_origins()` | 322 | DEBUG | `debug!("gRPC list_origins called")` |
|
||||
| `get_origin_health()` | 329 | DEBUG | `debug!(origin_id = %id, "gRPC get_origin_health")` |
|
||||
| `rescan_origin()` | 337 | INFO | `info!(origin_id = %id, "gRPC rescan_origin started")` |
|
||||
| `subscribe_events()` | 376 | INFO | `info!("gRPC subscribe_events: client connected")` |
|
||||
| `shutdown()` | 402 | INFO | `info!(graceful = graceful, "gRPC shutdown requested")` |
|
||||
|
||||
### search_service.rs
|
||||
|
||||
| Handler | Line | Level | Log Message |
|
||||
|---------|------|-------|-------------|
|
||||
| `search()` | entry | DEBUG | `debug!(query = %q, limit = limit, "gRPC search")` |
|
||||
| `search()` | result | DEBUG | `debug!(results = results.len(), "gRPC search completed")` |
|
||||
|
||||
### Pattern: Use `#[instrument]` on all handlers
|
||||
|
||||
```rust
|
||||
#[tracing::instrument(level = "debug", skip(self, request), fields(method = "get_status"))]
|
||||
async fn get_status(&self, request: Request<()>) -> Result<Response<StatusResponse>, Status> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async Task Spawn Instrumentation (ADDED - Oracle Review)
|
||||
|
||||
**Gap identified**: 14 `tokio::spawn` sites need correlation IDs and span propagation.
|
||||
|
||||
### Spawn Sites Requiring Instrumentation
|
||||
|
||||
| File | Line | Task | Instrumentation |
|
||||
|------|------|------|-----------------|
|
||||
| `server.rs` | 305 | prefetch stream | `spawn(async { ... }.instrument(info_span!("prefetch_stream")))` |
|
||||
| `server.rs` | 354 | rescan stream | `spawn(async { ... }.instrument(info_span!("rescan_stream", origin_id = %id)))` |
|
||||
| `server.rs` | 384 | subscribe events | `spawn(async { ... }.instrument(info_span!("event_subscriber")))` |
|
||||
| `search_service.rs` | spawn | search task | `spawn(async { ... }.instrument(debug_span!("search_task", query = %q)))` |
|
||||
| `indexer.rs` | spawn | indexer loop | `spawn(async { ... }.instrument(info_span!("indexer")))` |
|
||||
| `prefetch.rs` | 87 | prefetch engine | `spawn(async { ... }.instrument(info_span!("prefetch_engine")))` |
|
||||
| `prefetch.rs` | 169 | prefetch file | `spawn(async { ... }.instrument(debug_span!("prefetch_file", file_id = ?id)))` |
|
||||
| `health.rs` | 154 | health monitor | `spawn(async { ... }.instrument(info_span!("health_monitor")))` |
|
||||
| `watcher.rs` | 34 | file watcher | `spawn(async { ... }.instrument(info_span!("file_watcher", origin_id = %id)))` |
|
||||
| `artwork.rs` | spawn | image decode | `spawn_blocking(|| { ... })` - add span before spawn |
|
||||
|
||||
### Pattern: Span Propagation
|
||||
|
||||
```rust
|
||||
use tracing::Instrument;
|
||||
|
||||
// BEFORE (loses context)
|
||||
tokio::spawn(async move {
|
||||
do_work().await;
|
||||
});
|
||||
|
||||
// AFTER (preserves correlation)
|
||||
let span = tracing::info_span!("task_name", task_id = %id);
|
||||
tokio::spawn(async move {
|
||||
do_work().await;
|
||||
}.instrument(span));
|
||||
```
|
||||
|
||||
### Add to init_logging() for request IDs
|
||||
|
||||
```rust
|
||||
// Generate request ID for correlation
|
||||
use tracing::Span;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn with_request_id<F, R>(f: F) -> R
|
||||
where F: FnOnce() -> R {
|
||||
let request_id = Uuid::new_v4();
|
||||
let span = tracing::info_span!("request", request_id = %request_id);
|
||||
span.in_scope(f)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Operation Logging (ADDED - Oracle Review)
|
||||
|
||||
**Gap identified**: Zero logging for rusqlite operations in db.rs, collections.rs, patterns.rs, artwork.rs.
|
||||
|
||||
### db.rs - Core Database
|
||||
|
||||
| Function | Line | Level | Log Message |
|
||||
|----------|------|-------|-------------|
|
||||
| `open()` | entry | INFO | `info!(path = ?path, "Opening metadata database")` |
|
||||
| `open()` | success | INFO | `info!(file_count = count, "Database opened")` |
|
||||
| `upsert_file()` | entry | DEBUG | `debug!(file_id = ?id, path = %path, "Upserting file")` |
|
||||
| `upsert_file()` | error | ERROR | `error!(file_id = ?id, error = %e, "Failed to upsert file")` |
|
||||
| `get_file_by_id()` | miss | TRACE | `trace!(file_id = ?id, "File not found in db")` |
|
||||
| `delete_file()` | entry | DEBUG | `debug!(file_id = ?id, "Deleting file from db")` |
|
||||
| `list_files_by_origin()` | result | DEBUG | `debug!(origin_id = %id, count = files.len(), "Listed files")` |
|
||||
|
||||
### collections.rs
|
||||
|
||||
| Function | Line | Level | Log Message |
|
||||
|----------|------|-------|-------------|
|
||||
| `create()` | entry | INFO | `info!(name = %name, "Creating collection")` |
|
||||
| `save()` | error | WARN | `warn!(name = %name, error = %e, "Failed to save collection")` |
|
||||
| `delete()` | entry | INFO | `info!(name = %name, "Deleting collection")` |
|
||||
| `list()` | result | DEBUG | `debug!(count = collections.len(), "Listed collections")` |
|
||||
|
||||
### patterns.rs - Access Patterns
|
||||
|
||||
| Function | Line | Level | Log Message |
|
||||
|----------|------|-------|-------------|
|
||||
| `record_access()` | entry | TRACE | `trace!(file_id = ?id, "Recording access pattern")` |
|
||||
| `predict_next()` | result | DEBUG | `debug!(predictions = preds.len(), "Predicted next files")` |
|
||||
|
||||
### artwork.rs
|
||||
|
||||
| Function | Line | Level | Log Message |
|
||||
|----------|------|-------|-------------|
|
||||
| `store()` | entry | DEBUG | `debug!(file_id = ?id, size_bytes = data.len(), "Storing artwork")` |
|
||||
| `get()` | hit/miss | TRACE | `trace!(file_id = ?id, found = found, "Artwork lookup")` |
|
||||
|
||||
### Pattern: Database Error Wrapper
|
||||
|
||||
```rust
|
||||
// Add to musicfs-cache/src/db.rs
|
||||
fn log_db_result<T>(op: &str, result: Result<T, rusqlite::Error>) -> Result<T, Error> {
|
||||
match result {
|
||||
Ok(v) => {
|
||||
tracing::trace!(op = op, "db operation succeeded");
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(op = op, error = %e, "db operation failed");
|
||||
Err(Error::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Channel Operation Logging (ADDED - Oracle Review)
|
||||
|
||||
**Gap identified**: No logging for channel capacity, close, or broadcast lag.
|
||||
|
||||
### Channel Locations
|
||||
|
||||
| File | Type | Log Points |
|
||||
|------|------|------------|
|
||||
| `events.rs` | broadcast | Lag warning when receiver falls behind |
|
||||
| `watcher.rs` | mpsc | Channel close on watcher shutdown |
|
||||
| `server.rs` | mpsc | gRPC stream channel capacity |
|
||||
| `indexer.rs` | mpsc | Event queue depth |
|
||||
| `health.rs` | mpsc | Health check channel |
|
||||
|
||||
### Patterns
|
||||
|
||||
```rust
|
||||
// Broadcast lag detection (events.rs)
|
||||
match rx.recv().await {
|
||||
Ok(event) => { /* handle */ }
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(skipped = n, "Event subscriber lagged, skipped events");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
tracing::debug!("Event channel closed");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Channel capacity warning (before send)
|
||||
if tx.capacity() < 10 {
|
||||
tracing::warn!(remaining = tx.capacity(), "Channel near capacity");
|
||||
}
|
||||
|
||||
// Channel close
|
||||
impl Drop for EventBus {
|
||||
fn drop(&mut self) {
|
||||
tracing::debug!("Event bus shutting down");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Drop Implementation Logging (ADDED - Oracle Review)
|
||||
|
||||
**Gap identified**: No logging in Drop impls for cleanup verification.
|
||||
|
||||
| File | Type | Log Message |
|
||||
|------|------|-------------|
|
||||
| `manager.rs:276` | `PluginManager` | `debug!("PluginManager dropping, unloading {} plugins", self.plugins.len())` |
|
||||
| `watcher.rs:157` | `WatchHandle` | `trace!(origin_id = %self.origin_id, "WatchHandle dropped")` |
|
||||
| `prefetch.rs` | `PrefetchEngine` | `debug!("PrefetchEngine dropping, {} in-flight", self.in_flight.len())` |
|
||||
| `server.rs` | gRPC server | `info!("gRPC server shutting down")` |
|
||||
|
||||
### Pattern
|
||||
|
||||
```rust
|
||||
impl Drop for PluginManager {
|
||||
fn drop(&mut self) {
|
||||
tracing::debug!(
|
||||
plugin_count = self.plugins.len(),
|
||||
"PluginManager dropping"
|
||||
);
|
||||
// existing cleanup...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Credential Loading (ADDED - Oracle Review)
|
||||
|
||||
**Gap identified**: No logging in credentials.rs::load().
|
||||
|
||||
| Function | Level | Log Message |
|
||||
|----------|-------|-------------|
|
||||
| `load()` entry | DEBUG | `debug!(origin_id = %origin_id, "Loading credentials")` |
|
||||
| `load()` cache hit | TRACE | `trace!(origin_id = %origin_id, "Credential cache hit")` |
|
||||
| `load()` success | INFO | `info!(origin_id = %origin_id, cred_type = %cred.type_name(), "Credential loaded")` |
|
||||
| `load()` not found | DEBUG | `debug!(origin_id = %origin_id, "No credential found")` |
|
||||
| `load()` error | WARN | `warn!(origin_id = %origin_id, error = %e, "Credential load failed")` |
|
||||
|
||||
**SECURITY**: Never log credential values. The existing Debug impl with redaction is correct.
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations (ADDED - Oracle Review)
|
||||
|
||||
### Never Log These
|
||||
|
||||
| Data | Location | Mitigation |
|
||||
|------|----------|------------|
|
||||
| `WebhookConfig.secret` | webhook.rs | Add `#[serde(skip_serializing)]`, use custom Debug |
|
||||
| Credential values | credentials.rs | Already redacted in Debug impl ✓ |
|
||||
| Full file paths with usernames | everywhere | Sanitize `/home/{user}/` → `~/` |
|
||||
| API keys/tokens | config.rs | Mark sensitive fields |
|
||||
|
||||
### Sanitization Helper
|
||||
|
||||
```rust
|
||||
// Add to musicfs-core/src/lib.rs
|
||||
pub fn sanitize_path(path: &Path) -> String {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
path.to_string_lossy()
|
||||
.replace(&home, "~")
|
||||
.to_string()
|
||||
} else {
|
||||
path.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
debug!(path = %sanitize_path(&path), "Reading file");
|
||||
```
|
||||
|
||||
### WebhookConfig Fix
|
||||
|
||||
```rust
|
||||
// webhook.rs - add custom Debug
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct WebhookConfig {
|
||||
pub url: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub secret: Option<String>, // Never serialize
|
||||
// ...
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WebhookConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WebhookConfig")
|
||||
.field("url", &self.url)
|
||||
.field("secret", &self.secret.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations (ADDED - Oracle Review)
|
||||
|
||||
### Hot Path Warnings
|
||||
|
||||
| Path | Risk | Mitigation |
|
||||
|------|------|------------|
|
||||
| `reader.rs` chunk loop | 100s of TRACE logs per seek | Log summary only: `trace!(chunks_read = n, "Read complete")` |
|
||||
| `store.rs` put/get | 1000s during sync | Keep at DEBUG, not TRACE |
|
||||
| `delta.rs` file scan | Log per file during full scan | Use TRACE, batch summaries at DEBUG |
|
||||
| `parser.rs` tag extraction | Many TRACE per file | Sample: log every 100th file |
|
||||
|
||||
### Trace Sampling Config
|
||||
|
||||
```rust
|
||||
// Add to LoggingConfig
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoggingConfig {
|
||||
// ... existing fields ...
|
||||
|
||||
/// Sample rate for TRACE logs in hot paths (0.0-1.0, default 1.0)
|
||||
#[serde(default = "default_sample_rate")]
|
||||
pub trace_sample_rate: f32,
|
||||
}
|
||||
|
||||
fn default_sample_rate() -> f32 { 1.0 }
|
||||
|
||||
// Usage in hot paths
|
||||
if rand::random::<f32>() < config.trace_sample_rate {
|
||||
trace!(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Rate-Limited Warnings
|
||||
|
||||
```rust
|
||||
// For repeating warnings during outages (failover.rs)
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static LAST_FAILOVER_WARN: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn warn_rate_limited(origin_id: &str, error: &str) {
|
||||
let now = Instant::now().elapsed().as_secs();
|
||||
let last = LAST_FAILOVER_WARN.load(Ordering::Relaxed);
|
||||
if now - last >= 60 { // Max once per minute
|
||||
LAST_FAILOVER_WARN.store(now, Ordering::Relaxed);
|
||||
warn!(origin_id = %origin_id, error = %error, "Origin failover");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Standardized Field Names (ADDED - Oracle Review)
|
||||
|
||||
Use these consistently across all log statements:
|
||||
|
||||
| Field | Type | Usage |
|
||||
|-------|------|-------|
|
||||
| `origin_id` | String | Origin identifier (not `origin`) |
|
||||
| `file_id` | FileId | File identifier |
|
||||
| `path` | String | Virtual or real path (sanitized) |
|
||||
| `size_bytes` | u64 | Size in bytes (not `size`, `bytes`, `len`) |
|
||||
| `offset` | u64 | Read offset |
|
||||
| `duration_ms` | u64 | Operation duration in milliseconds |
|
||||
| `count` | usize | Generic count |
|
||||
| `attempt` | u32 | Retry attempt number |
|
||||
| `max_attempts` | u32 | Maximum retry attempts |
|
||||
| `error` | impl Display | Error message (not `err`, `e`) |
|
||||
| `request_id` | Uuid | Correlation ID for requests |
|
||||
|
||||
---
|
||||
|
||||
## Instrumentation Patterns (ADDED - Oracle Review)
|
||||
|
||||
### Use `#[instrument(err)]` for Automatic Error Logging
|
||||
|
||||
```rust
|
||||
// BEFORE: Manual error logging
|
||||
pub async fn read(&self, path: &Path) -> Result<Bytes> {
|
||||
match self.inner_read(path).await {
|
||||
Ok(data) => Ok(data),
|
||||
Err(e) => {
|
||||
error!(path = ?path, error = %e, "Read failed");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER: Automatic with #[instrument]
|
||||
#[tracing::instrument(level = "debug", skip(self), err)]
|
||||
pub async fn read(&self, path: &Path) -> Result<Bytes> {
|
||||
self.inner_read(path).await
|
||||
}
|
||||
```
|
||||
|
||||
### Span Events vs Regular Logs
|
||||
|
||||
```rust
|
||||
// Regular log - standalone event
|
||||
info!("Operation completed");
|
||||
|
||||
// Span event - attached to current span context
|
||||
tracing::Span::current().record("result", "success");
|
||||
|
||||
// Prefer span events for operation outcomes
|
||||
#[instrument(fields(result))]
|
||||
async fn operation() -> Result<()> {
|
||||
// ... work ...
|
||||
Span::current().record("result", "success");
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fixes: Incorrect Line References (ADDED - Oracle Review)
|
||||
|
||||
| File | Issue | Fix |
|
||||
|------|-------|-----|
|
||||
| `webhook.rs:43` | Uses `expect()` (panics) | Replace with `?` + error log |
|
||||
| `webhook.rs:133` | Uses `expect()` (panics) | Replace with `?` + error log |
|
||||
|
||||
```rust
|
||||
// webhook.rs - BEFORE
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
// webhook.rs - AFTER
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
error!(error = %e, "Failed to create webhook HTTP client");
|
||||
WebhookError::ClientInit(e.to_string())
|
||||
})?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Log Levels Guide
|
||||
|
||||
| Level | Use Case | Example |
|
||||
|-------|----------|---------|
|
||||
| `ERROR` | Unrecoverable failures | Mount failed, DB corruption |
|
||||
| `WARN` | Recoverable issues | Origin timeout, retry needed |
|
||||
| `INFO` | Lifecycle events | Service start/stop, health change |
|
||||
| `DEBUG` | Operation details | Function entry, request params |
|
||||
| `TRACE` | Fine-grained flow | Match arms, cache hit/miss |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Basic Functionality
|
||||
- [ ] Log files created in configured directory
|
||||
- [ ] Daily rotation creates new files at midnight
|
||||
- [ ] JSON output parseable by `jq`
|
||||
- [ ] `journalctl -t musicfs` shows logs
|
||||
- [ ] `RUST_LOG=musicfs=trace` enables trace output
|
||||
- [ ] WorkerGuard kept alive (logs flush on shutdown)
|
||||
- [ ] Logrotate compresses old files
|
||||
|
||||
### Correlation & Context (NEW)
|
||||
- [ ] Request IDs propagate through async tasks
|
||||
- [ ] Spawned task logs include parent span context
|
||||
- [ ] gRPC handler logs show method name in span
|
||||
|
||||
### Security (NEW)
|
||||
- [ ] WebhookConfig.secret never appears in logs
|
||||
- [ ] Credential values never appear in logs
|
||||
- [ ] File paths with `/home/{user}` show as `~/`
|
||||
|
||||
### Performance (NEW)
|
||||
- [ ] TRACE sampling respects `trace_sample_rate` config
|
||||
- [ ] Hot path chunk loops log summary, not per-chunk
|
||||
- [ ] Origin failover warnings are rate-limited (1/minute)
|
||||
- [ ] Database operations log without blocking
|
||||
|
||||
### Database & Channels (NEW)
|
||||
- [ ] Database open logs file count
|
||||
- [ ] Channel capacity warnings appear when queue fills
|
||||
- [ ] Broadcast lag warnings appear when subscriber falls behind
|
||||
- [ ] Drop implementations log cleanup
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Phase | Effort | Deliverables |
|
||||
|-------|--------|--------------|
|
||||
| 1. Config & Dependencies | 2h | LoggingConfig, init_logging(), logrotate, trace sampling |
|
||||
| 2. Core instrumentation | 1h | tracing in musicfs-core, credentials, sanitization |
|
||||
| 3. Hot path instrumentation | 4h | #[instrument] + trace! across 5 crates |
|
||||
| 4. gRPC & async tasks | 2h | Handler instrumentation, spawn correlation |
|
||||
| 5. Database & channels | 2h | rusqlite logging, channel capacity/close |
|
||||
| 6. Production files | 1h | Updated systemd, example config |
|
||||
| **Total** | **12h** | Full observability |
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Phase 1: Config & Dependencies
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `Cargo.toml` (workspace) | Add tracing-appender, tracing-journald |
|
||||
| `crates/musicfs-cli/Cargo.toml` | Add dependencies |
|
||||
| `crates/musicfs-core/Cargo.toml` | Add tracing |
|
||||
| `crates/musicfs-core/src/config.rs` | Add LoggingConfig with trace_sample_rate |
|
||||
| `crates/musicfs-cli/src/main.rs` | Expand init_logging(), request ID helper |
|
||||
| `crates/musicfs-core/src/lib.rs` | Add sanitize_path() helper |
|
||||
|
||||
### Phase 2: Core Instrumentation
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `crates/musicfs-core/src/credentials.rs` | Add load() logging (redacted) |
|
||||
| `crates/musicfs-core/src/events.rs` | Add broadcast lag detection |
|
||||
|
||||
### Phase 3: Hot Path Instrumentation
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `crates/musicfs-fuse/src/filesystem.rs` | Add #[instrument], trace! |
|
||||
| `crates/musicfs-origins/src/failover.rs` | Add #[instrument], trace!, rate-limited warn |
|
||||
| `crates/musicfs-origins/src/health.rs` | Add state transition logging |
|
||||
| `crates/musicfs-origins/src/router.rs` | Add selection logging |
|
||||
| `crates/musicfs-cache/src/tree.rs` | Add mutation logging |
|
||||
| `crates/musicfs-cache/src/metadata.rs` | Add hit/miss logging |
|
||||
| `crates/musicfs-cas/src/reader.rs` | Add chunk assembly logging (summary, not per-chunk) |
|
||||
| `crates/musicfs-cas/src/store.rs` | Add dedup logging |
|
||||
| `crates/musicfs-sync/src/delta.rs` | Add change detection logging |
|
||||
|
||||
### Phase 4: gRPC & Async Tasks (NEW)
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `crates/musicfs-grpc/src/server.rs` | Add #[instrument] to all 10 handlers, spawn correlation |
|
||||
| `crates/musicfs-grpc/src/search_service.rs` | Add #[instrument], spawn instrumentation |
|
||||
| `crates/musicfs-grpc/src/webhook.rs` | Fix expect() → error!, custom Debug for secret |
|
||||
| `crates/musicfs-cache/src/prefetch.rs` | Add spawn instrumentation, Drop logging |
|
||||
| `crates/musicfs-search/src/indexer.rs` | Add spawn instrumentation |
|
||||
| `crates/musicfs-sync/src/watcher.rs` | Add spawn instrumentation, Drop logging |
|
||||
| `crates/musicfs-plugins/src/manager.rs` | Add Drop logging |
|
||||
|
||||
### Phase 5: Database & Channels (NEW)
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `crates/musicfs-cache/src/db.rs` | Add log_db_result() helper, open/upsert/query logging |
|
||||
| `crates/musicfs-search/src/collections.rs` | Add CRUD operation logging |
|
||||
| `crates/musicfs-cache/src/patterns.rs` | Add access pattern logging |
|
||||
| `crates/musicfs-cache/src/artwork.rs` | Add store/get logging |
|
||||
|
||||
### Phase 6: Production Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `dist/musicfs.service` | Add logging directives |
|
||||
| `dist/logrotate.d/musicfs` | New file |
|
||||
| `dist/config.example.toml` | Add logging section with trace_sample_rate |
|
||||
@@ -1,796 +0,0 @@
|
||||
# Persistent State: Implementation Plan
|
||||
|
||||
**Authors:** AI-assisted
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2026-05-13
|
||||
**Reviewers:** TBD
|
||||
**Approvers:** TBD
|
||||
**Prerequisites:** [persistent-state.md](persistent-state.md) (research), [phase-a-stop-dying.md](phase-a-stop-dying.md) (signal handling + shutdown)
|
||||
**Estimated Effort:** ~8 days
|
||||
|
||||
---
|
||||
|
||||
[TOC]
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
|
||||
Wire up the existing SQLite persistence layer into the mount path so that subsequent mounts load from database instead of rescanning origins. This transforms mount time from O(N × origin_latency) to O(N × SQLite_read) — roughly 1000x faster for remote origins.
|
||||
|
||||
**Storage decision: SQLite (Option A).** Rationale:
|
||||
- `Database` struct with full CRUD already exists in `musicfs-cache/src/db.rs`
|
||||
- Schema with `chunk_manifest BLOB` column already exists in `schema.sql`
|
||||
- `ChunkManifest::from_db()` and `chunks_to_bytes()` already exist but are never called
|
||||
- Row-to-`FileMeta` mapping already exists in `get_file_by_virtual_path()`
|
||||
- WAL mode crash safety already configured
|
||||
- 2-4 second bulk load for 1M rows is acceptable (target is <5s, not <500ms — the <500ms target is for the mount syscall itself, which returns immediately with lazy tree loading)
|
||||
|
||||
No new storage engine. No new dependencies. Wire existing code.
|
||||
|
||||
---
|
||||
|
||||
## 2. Background
|
||||
|
||||
### 2.1 Current State
|
||||
|
||||
`run_mount()` in `main.rs`:
|
||||
1. Opens CAS store ✅
|
||||
2. Creates origin connection ✅
|
||||
3. `scan_music_files()` — walks entire origin, parses every file with symphonia ❌ **BOTTLENECK**
|
||||
4. Builds VirtualTree from scan results (in-memory only) ❌ **LOST ON RESTART**
|
||||
5. Registers every file in ContentFetcher (in-memory only) ❌ **LOST ON RESTART**
|
||||
6. Mounts FUSE ✅
|
||||
|
||||
### 2.2 What Exists But Is Not Wired
|
||||
|
||||
| Component | Exists | Wired Into Mount? |
|
||||
|-----------|--------|--------------------|
|
||||
| `Database::open()` + schema + WAL | ✅ | ❌ |
|
||||
| `Database::upsert_file()` | ✅ | ❌ |
|
||||
| `Database::get_file_by_virtual_path()` (returns `FileMeta`) | ✅ | ❌ |
|
||||
| `schema.sql` with `chunk_manifest BLOB` column | ✅ | ❌ |
|
||||
| `ChunkManifest::chunks_to_bytes()` (serialize) | ✅ | ❌ |
|
||||
| `ChunkManifest::from_db()` (deserialize) | ✅ | ❌ |
|
||||
| `TreeBuilder::add_file(&FileMeta)` | ✅ | ✅ (from scan, not from DB) |
|
||||
| `ContentFetcher::register_file(FileMeta)` | ✅ | ✅ (from scan, not from DB) |
|
||||
| `PatternStore::new(db_path)` (loads from SQLite on open) | ✅ | ❌ |
|
||||
| `CollectionStore::new(db_path)` | ✅ | ❌ |
|
||||
| `SearchIndex::open(path)` (opens tantivy from disk) | ✅ | ❌ |
|
||||
|
||||
### 2.3 What's Missing
|
||||
|
||||
| Component | Needs Building |
|
||||
|-----------|----------------|
|
||||
| `Database::list_all_files()` → `Vec<FileMeta>` | New method (SQL exists, just needs `SELECT *`) |
|
||||
| `Database::update_manifest(FileId, &[u8])` | New method (column exists) |
|
||||
| `Database::get_manifest(FileId)` → `Option<Vec<u8>>` | New method |
|
||||
| `Database::list_all_manifests()` → `Vec<(FileId, ChunkManifest)>` | New method |
|
||||
| Background delta sync task | New (compare DB state vs origin) |
|
||||
| First-mount detection | New (check `file_count() > 0`) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
- Subsequent mount loads tree from SQLite, not origin scan
|
||||
- Chunk manifests persist to SQLite, loaded on mount (no re-download)
|
||||
- tantivy index, PatternStore, CollectionStore opened on mount
|
||||
- Background delta sync reconciles DB vs origin after mount
|
||||
- First mount (empty DB) falls back to current full-scan behavior
|
||||
- Mount time for 10K files: <1 second (subsequent mount)
|
||||
- All existing tests pass, no regressions
|
||||
|
||||
### 3.2 Non-Goals
|
||||
|
||||
- Achieving <500ms mount for 1M+ files (requires lazy tree loading — future work)
|
||||
- LRU eviction persistence (separate task, low urgency)
|
||||
- Changing the storage engine (SQLite is the decision)
|
||||
- Config file parsing changes (origin config stays in TOML, not DB)
|
||||
- Schema migrations for existing data (fresh DB on first mount)
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Design
|
||||
|
||||
### 4.1 Implementation Order
|
||||
|
||||
```
|
||||
4.2 Database: list_all_files() + manifest CRUD (foundation)
|
||||
↓
|
||||
4.3 Mount path: load tree + fetcher from DB (core change)
|
||||
↓
|
||||
4.4 Persist manifests after fetch (write path)
|
||||
↓
|
||||
4.5 Open tantivy + PatternStore + CollectionStore (quick wiring)
|
||||
↓
|
||||
4.6 Background delta sync (post-mount reconciliation)
|
||||
↓
|
||||
4.7 First-mount detection + fallback (edge case)
|
||||
↓
|
||||
4.8 Shutdown: WAL checkpoint + flush (cleanup)
|
||||
```
|
||||
|
||||
### 4.2 Database: New Methods
|
||||
|
||||
**File**: `musicfs-cache/src/db.rs`
|
||||
|
||||
#### list_all_files()
|
||||
|
||||
Bulk load all files from DB. Reuses the existing row-to-FileMeta mapping from `get_file_by_virtual_path()`.
|
||||
|
||||
```rust
|
||||
pub fn list_all_files(&self) -> Result<Vec<FileMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
r#"SELECT id, origin_id, real_path, virtual_path,
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
origin_mtime, origin_size, content_hash
|
||||
FROM files
|
||||
ORDER BY virtual_path"#
|
||||
).map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
|
||||
|
||||
let files = stmt.query_map([], |row| {
|
||||
// Same mapping as get_file_by_virtual_path
|
||||
Ok(Self::row_to_file_meta(row))
|
||||
})
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
```
|
||||
|
||||
Extract the row mapping into a shared `row_to_file_meta(row)` helper to avoid duplication with `get_file_by_virtual_path()`.
|
||||
|
||||
#### Manifest CRUD
|
||||
|
||||
```rust
|
||||
pub fn update_manifest(&self, file_id: FileId, manifest_blob: &[u8]) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE files SET chunk_manifest = ?1 WHERE id = ?2",
|
||||
params![manifest_blob, file_id.0],
|
||||
).map_err(|e| Error::Database(format!("update manifest failed: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_manifest(&self, file_id: FileId) -> Result<Option<Vec<u8>>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT chunk_manifest FROM files WHERE id = ?1",
|
||||
params![file_id.0],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(format!("get manifest failed: {}", e)))
|
||||
}
|
||||
|
||||
pub fn list_all_manifests(&self) -> Result<Vec<(FileId, u64, i64, Vec<u8>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, origin_size, origin_mtime, chunk_manifest FROM files WHERE chunk_manifest IS NOT NULL"
|
||||
).map_err(|e| Error::Database(format!("prepare failed: {}", e)))?;
|
||||
|
||||
let manifests = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
FileId(row.get(0)?),
|
||||
row.get::<_, i64>(1)? as u64,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, Vec<u8>>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| Error::Database(format!("query failed: {}", e)))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(manifests)
|
||||
}
|
||||
```
|
||||
|
||||
#### WAL Checkpoint
|
||||
|
||||
```rust
|
||||
pub fn checkpoint(&self) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
.map_err(|e| Error::Database(format!("WAL checkpoint failed: {}", e)))?;
|
||||
info!("SQLite WAL checkpoint completed");
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
#### Tests
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_list_all_files() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
// Insert 3 files
|
||||
// list_all_files() returns 3
|
||||
// Verify FileMeta fields match what was inserted
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_roundtrip() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
// Insert file, update_manifest with blob, get_manifest returns same blob
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_all_manifests_skips_null() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
// Insert 3 files, only 1 with manifest
|
||||
// list_all_manifests() returns 1
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Mount Path: Load From DB
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs` — rewrite `run_mount()`
|
||||
|
||||
The key change: replace `scan_music_files()` with DB load when data exists.
|
||||
|
||||
```rust
|
||||
fn run_mount(mountpoint: PathBuf, origin_path: Option<PathBuf>, cache_dir: Option<PathBuf>) -> Result<()> {
|
||||
let origin_path = origin_path.context("--origin is required")?;
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
let (tree, reader, db) = runtime.block_on(async {
|
||||
let cache_dir = resolve_cache_dir(cache_dir);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
std::fs::create_dir_all(&mountpoint)?;
|
||||
|
||||
// Open CAS store
|
||||
let store = Arc::new(CasStore::open(CasConfig {
|
||||
chunks_dir: cache_dir.join("chunks"),
|
||||
..Default::default()
|
||||
}).await?);
|
||||
|
||||
// Open database
|
||||
let db_path = cache_dir.join("metadata.db");
|
||||
let db = Arc::new(Database::open_with_integrity_check(&db_path)
|
||||
.or_else(|_| Database::open(&db_path))?); // Fallback to normal open if integrity check fails
|
||||
|
||||
let fetcher = Arc::new(ContentFetcher::new(store.clone()));
|
||||
let origin_id = OriginId::from("local");
|
||||
let origin = Arc::new(LocalOrigin::new(origin_id.clone(), origin_path.clone()));
|
||||
fetcher.register_origin(origin);
|
||||
|
||||
// Decide: load from DB or full scan
|
||||
let file_count = db.file_count().unwrap_or(0);
|
||||
|
||||
let files = if file_count > 0 {
|
||||
// SUBSEQUENT MOUNT — load from DB
|
||||
info!(file_count, "Loading metadata from database");
|
||||
let start = Instant::now();
|
||||
let files = db.list_all_files()?;
|
||||
info!(elapsed_ms = start.elapsed().as_millis() as u64, "Database load complete");
|
||||
files
|
||||
} else {
|
||||
// FIRST MOUNT — full origin scan
|
||||
info!("First mount: scanning origin");
|
||||
let files = scan_music_files(&origin_path, &origin_id).await?;
|
||||
info!(file_count = files.len(), "Scan complete, persisting to database");
|
||||
|
||||
// Persist to DB for next mount
|
||||
for file in &files {
|
||||
if let Some(ref audio) = file.audio {
|
||||
db.upsert_file(
|
||||
&file.real_path.origin_id,
|
||||
&file.real_path.path,
|
||||
&file.virtual_path,
|
||||
audio,
|
||||
file.mtime,
|
||||
file.size,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
info!("Metadata persisted to database");
|
||||
files
|
||||
};
|
||||
|
||||
// Build tree + register files (same as before, but from DB or scan)
|
||||
let mut builder = TreeBuilder::new();
|
||||
for file in &files {
|
||||
builder.add_file(file);
|
||||
fetcher.register_file(file.clone());
|
||||
}
|
||||
let tree = Arc::new(RwLock::new(builder.build()));
|
||||
|
||||
// Load manifests from DB
|
||||
let reader = Arc::new(FileReader::with_fetcher(store, fetcher));
|
||||
let manifest_count = load_manifests_from_db(&db, &reader)?;
|
||||
if manifest_count > 0 {
|
||||
info!(manifest_count, "Loaded chunk manifests from database");
|
||||
}
|
||||
|
||||
Ok::<_, anyhow::Error>((tree, reader, db))
|
||||
})?;
|
||||
|
||||
// Open search index
|
||||
let search_dir = cache_dir.join("search.idx");
|
||||
let _search_index = SearchIndex::open_with_recovery(&search_dir)
|
||||
.context("Failed to open search index")?;
|
||||
|
||||
// Open pattern store
|
||||
let patterns_path = cache_dir.join("patterns.db");
|
||||
let _pattern_store = PatternStore::new(&patterns_path, 30)
|
||||
.context("Failed to open pattern store")?;
|
||||
|
||||
// ... mount, signal handler, shutdown (same as current) ...
|
||||
|
||||
// On shutdown: checkpoint WAL
|
||||
db.checkpoint().unwrap_or_else(|e| warn!("WAL checkpoint failed: {}", e));
|
||||
}
|
||||
```
|
||||
|
||||
Helper function:
|
||||
|
||||
```rust
|
||||
fn load_manifests_from_db(db: &Database, reader: &FileReader) -> Result<usize> {
|
||||
let manifests = db.list_all_manifests()?;
|
||||
let mut count = 0;
|
||||
for (file_id, total_size, mtime, blob) in manifests {
|
||||
if let Some(manifest) = ChunkManifest::from_db(file_id, total_size, mtime, &blob) {
|
||||
reader.register_manifest(manifest);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Persist Manifests After Fetch
|
||||
|
||||
**File**: `musicfs-cas/src/fetcher.rs`
|
||||
|
||||
After `fetch_file()` downloads and chunks a file, persist the manifest to SQLite.
|
||||
|
||||
The fetcher currently doesn't have access to the Database. Two options:
|
||||
1. Pass `Arc<Database>` to ContentFetcher (adds dependency musicfs-cas → musicfs-cache)
|
||||
2. Emit an event with the manifest, have the caller persist it
|
||||
|
||||
**Approach**: Option 2 — use the existing EventBus. Add a new event variant:
|
||||
|
||||
**File**: `musicfs-core/src/events.rs`
|
||||
|
||||
```rust
|
||||
pub enum Event {
|
||||
// ... existing variants
|
||||
ManifestCached {
|
||||
file_id: FileId,
|
||||
manifest_blob: Vec<u8>,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `musicfs-cas/src/fetcher.rs` — emit event after fetch:
|
||||
|
||||
```rust
|
||||
pub async fn fetch_file(&self, file_id: FileId) -> Result<ChunkManifest, FetchError> {
|
||||
// ... existing fetch + chunk logic ...
|
||||
|
||||
// Emit manifest for persistence
|
||||
if let Some(bus) = &self.event_bus {
|
||||
bus.publish(Event::ManifestCached {
|
||||
file_id,
|
||||
manifest_blob: manifest.chunks_to_bytes(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs` — subscribe to ManifestCached events:
|
||||
|
||||
```rust
|
||||
// Spawn manifest persistence listener
|
||||
let db_for_manifests = db.clone();
|
||||
let mut manifest_rx = event_bus.subscribe();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = manifest_rx.recv().await {
|
||||
if let Event::ManifestCached { file_id, manifest_blob } = event {
|
||||
if let Err(e) = db_for_manifests.update_manifest(file_id, &manifest_blob) {
|
||||
warn!(file_id = ?file_id, error = %e, "Failed to persist manifest");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Open tantivy + PatternStore + CollectionStore
|
||||
|
||||
These already have `open()` methods that load from disk. Just call them in the mount path.
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs`
|
||||
|
||||
```rust
|
||||
// After tree is built, before FUSE mount
|
||||
|
||||
// Search index
|
||||
let search_dir = cache_dir.join("search.idx");
|
||||
let search_index = Arc::new(
|
||||
SearchIndex::open_with_recovery(&search_dir)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Search index failed, creating fresh: {}", e);
|
||||
SearchIndex::open(&search_dir).expect("Failed to create search index")
|
||||
})
|
||||
);
|
||||
|
||||
// Pattern store (already persists to SQLite, loads sequence_counts on open)
|
||||
let patterns_path = cache_dir.join("patterns.db");
|
||||
let pattern_store = Arc::new(
|
||||
PatternStore::new(&patterns_path, 30)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Pattern store failed: {}", e);
|
||||
PatternStore::new(&patterns_path, 30).expect("Failed to create pattern store")
|
||||
})
|
||||
);
|
||||
|
||||
// Collection store
|
||||
let collections_path = cache_dir.join("collections.db");
|
||||
let collection_store = Arc::new(
|
||||
CollectionStore::new(&collections_path)
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Collection store failed: {}", e);
|
||||
CollectionStore::new(&collections_path).expect("Failed to create collection store")
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
For tantivy: if this is a first mount, index all files after scan:
|
||||
|
||||
```rust
|
||||
if file_count == 0 {
|
||||
// First mount — index all files
|
||||
info!("First mount: building search index");
|
||||
let indexer = Indexer::new(search_index.clone(), event_bus.clone(), /* metadata_lookup */);
|
||||
indexer.index_batch(&files)?;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Background Delta Sync
|
||||
|
||||
After mount completes, spawn a background task that compares DB state against origin and reconciles differences.
|
||||
|
||||
**File**: `musicfs-sync/src/delta.rs` or new `musicfs-cli/src/sync.rs`
|
||||
|
||||
```rust
|
||||
pub async fn background_delta_sync(
|
||||
origin: Arc<dyn Origin>,
|
||||
origin_id: OriginId,
|
||||
db: Arc<Database>,
|
||||
tree: Arc<RwLock<VirtualTree>>,
|
||||
fetcher: Arc<ContentFetcher>,
|
||||
event_bus: Arc<EventBus>,
|
||||
) -> Result<SyncSummary> {
|
||||
info!("Starting background delta sync");
|
||||
let start = Instant::now();
|
||||
|
||||
let mut added = 0u64;
|
||||
let mut modified = 0u64;
|
||||
let mut removed = 0u64;
|
||||
let mut unchanged = 0u64;
|
||||
|
||||
// Get all files currently in DB
|
||||
let db_files: HashMap<PathBuf, FileMeta> = db.list_all_files()?
|
||||
.into_iter()
|
||||
.map(|f| (f.real_path.path.clone(), f))
|
||||
.collect();
|
||||
|
||||
// Walk origin
|
||||
let origin_files = scan_origin_recursive(&origin, Path::new("/")).await?;
|
||||
|
||||
// Compare
|
||||
for (path, origin_stat) in &origin_files {
|
||||
match db_files.get(path) {
|
||||
Some(db_file) if db_file.mtime == origin_stat.mtime && db_file.size == origin_stat.size => {
|
||||
unchanged += 1;
|
||||
}
|
||||
Some(db_file) => {
|
||||
// Modified — re-parse metadata, update DB, update tree
|
||||
modified += 1;
|
||||
// ... update logic ...
|
||||
}
|
||||
None => {
|
||||
// New file — parse metadata, add to DB + tree
|
||||
added += 1;
|
||||
// ... add logic ...
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed files (in DB but not on origin)
|
||||
let origin_paths: HashSet<_> = origin_files.keys().collect();
|
||||
for (path, db_file) in &db_files {
|
||||
if !origin_paths.contains(path) {
|
||||
removed += 1;
|
||||
db.delete_file(db_file.id)?;
|
||||
tree.write().remove_file(&db_file.virtual_path);
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
info!(
|
||||
added, modified, removed, unchanged,
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
"Delta sync complete"
|
||||
);
|
||||
|
||||
Ok(SyncSummary { added, modified, removed, unchanged })
|
||||
}
|
||||
```
|
||||
|
||||
Spawn in `run_mount()` after FUSE mount:
|
||||
|
||||
```rust
|
||||
// Background delta sync (non-blocking)
|
||||
let sync_db = db.clone();
|
||||
let sync_tree = tree.clone();
|
||||
let sync_fetcher = fetcher.clone();
|
||||
let sync_origin = origin.clone();
|
||||
let sync_origin_id = origin_id.clone();
|
||||
let sync_bus = event_bus.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = background_delta_sync(
|
||||
sync_origin, sync_origin_id, sync_db, sync_tree, sync_fetcher, sync_bus,
|
||||
).await {
|
||||
warn!("Delta sync failed: {}", e);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.7 First-Mount Detection
|
||||
|
||||
Simple: check `db.file_count()`:
|
||||
|
||||
```rust
|
||||
let file_count = db.file_count().unwrap_or(0);
|
||||
|
||||
if file_count > 0 {
|
||||
// Load from DB
|
||||
} else {
|
||||
// Full scan + persist
|
||||
}
|
||||
```
|
||||
|
||||
This is already shown in Section 4.3. No separate implementation step.
|
||||
|
||||
---
|
||||
|
||||
### 4.8 Shutdown: WAL Checkpoint + Flush
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs` — in the shutdown sequence (after signal, before dropping session):
|
||||
|
||||
```rust
|
||||
info!("Beginning ordered shutdown");
|
||||
shutdown_token.cancel();
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Flush persistence
|
||||
if let Err(e) = db.checkpoint() {
|
||||
warn!("SQLite WAL checkpoint failed: {}", e);
|
||||
}
|
||||
info!("Background tasks stopped, state flushed");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1 Security & Privacy
|
||||
|
||||
- No new attack surface — SQLite file has same permissions as cache directory
|
||||
- Metadata in DB is the same as what's already in the FUSE virtual tree (not new data)
|
||||
- `chunk_manifest` BLOB is binary chunk hashes — not sensitive
|
||||
|
||||
### 5.2 Observability
|
||||
|
||||
- Mount time logged: "Loading metadata from database" with elapsed_ms
|
||||
- First-mount detected and logged: "First mount: scanning origin"
|
||||
- Delta sync summary logged: added/modified/removed/unchanged counts + elapsed
|
||||
- WAL checkpoint logged on shutdown
|
||||
- Manifest persistence failures logged at WARN (non-fatal)
|
||||
|
||||
### 5.3 Scalability
|
||||
|
||||
| Library Size | First Mount (scan) | Subsequent Mount (DB load) |
|
||||
|---|---|---|
|
||||
| 1K files | ~1-2s | <100ms |
|
||||
| 10K files | ~10-20s | ~200ms |
|
||||
| 100K files | ~2-5 min | ~1-2s |
|
||||
| 1M files | ~20-60 min | ~2-4s |
|
||||
|
||||
Delta sync runs in background — mount returns immediately, user sees stale-but-functional data while sync catches up.
|
||||
|
||||
### 5.4 Testing
|
||||
|
||||
```rust
|
||||
// Test: subsequent mount loads from DB
|
||||
#[tokio::test]
|
||||
async fn test_mount_loads_from_db() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db = Database::open(dir.path().join("test.db")).unwrap();
|
||||
|
||||
// Insert files
|
||||
for i in 0..100 {
|
||||
db.upsert_file(/* ... */).unwrap();
|
||||
}
|
||||
|
||||
// Load all
|
||||
let files = db.list_all_files().unwrap();
|
||||
assert_eq!(files.len(), 100);
|
||||
|
||||
// Build tree from DB files (same as mount path)
|
||||
let mut builder = TreeBuilder::new();
|
||||
for f in &files { builder.add_file(f); }
|
||||
let tree = builder.build();
|
||||
assert_eq!(tree.file_count(), 100);
|
||||
}
|
||||
|
||||
// Test: manifest roundtrip through DB
|
||||
#[tokio::test]
|
||||
async fn test_manifest_persists_and_loads() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db = Database::open(dir.path().join("test.db")).unwrap();
|
||||
|
||||
let id = db.upsert_file(/* ... */).unwrap();
|
||||
|
||||
let manifest = ChunkManifest { /* ... */ };
|
||||
let blob = manifest.chunks_to_bytes();
|
||||
db.update_manifest(id, &blob).unwrap();
|
||||
|
||||
let loaded = db.get_manifest(id).unwrap().unwrap();
|
||||
let restored = ChunkManifest::from_db(id, 1000, 0, &loaded).unwrap();
|
||||
assert_eq!(restored.chunks.len(), manifest.chunks.len());
|
||||
}
|
||||
|
||||
// Test: first mount detects empty DB
|
||||
#[tokio::test]
|
||||
async fn test_first_mount_detection() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db = Database::open(dir.path().join("test.db")).unwrap();
|
||||
assert_eq!(db.file_count().unwrap(), 0); // First mount
|
||||
}
|
||||
|
||||
// Test: delta sync detects changes
|
||||
#[tokio::test]
|
||||
async fn test_delta_sync_detects_added_file() {
|
||||
// DB has files A, B
|
||||
// Origin has files A, B, C
|
||||
// Delta sync should detect C as added
|
||||
}
|
||||
|
||||
// Test: delta sync detects removed file
|
||||
#[tokio::test]
|
||||
async fn test_delta_sync_detects_removed_file() {
|
||||
// DB has files A, B, C
|
||||
// Origin has files A, B
|
||||
// Delta sync should detect C as removed
|
||||
}
|
||||
|
||||
// Test: shutdown checkpoints WAL
|
||||
#[tokio::test]
|
||||
async fn test_shutdown_checkpoints_wal() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.upsert_file(/* ... */).unwrap();
|
||||
|
||||
// WAL file should exist
|
||||
let wal_path = db_path.with_extension("db-wal");
|
||||
// After checkpoint, WAL should be truncated
|
||||
db.checkpoint().unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 6.1 sled for Tree Storage (Option B)
|
||||
|
||||
sled is faster for bulk key-value reads (~1-2s for 1M entries vs SQLite's ~2-4s). Rejected because:
|
||||
- SQLite code already exists (schema, CRUD, row mapping)
|
||||
- sled would require new serialization layer (bincode/msgpack for FileMeta)
|
||||
- Two persistence engines is more complex
|
||||
- SQLite's 2-4s is acceptable for the target
|
||||
|
||||
### 6.2 Flat File Snapshot (Option C)
|
||||
|
||||
Fastest possible bulk load (<1s via mmap). Rejected because:
|
||||
- No incremental updates — every change rewrites the entire file
|
||||
- At 1M files (~500MB), delta sync triggers a 500MB write for each changed file
|
||||
- No concurrent access safety
|
||||
- No crash recovery for partial writes
|
||||
|
||||
### 6.3 Lazy Tree Loading
|
||||
|
||||
Instead of loading all files into memory on mount, load only the root directories and fetch deeper levels on demand from SQLite. This would achieve true O(1) mount. Deferred because:
|
||||
- Requires significant refactoring of VirtualTree (currently all-in-memory)
|
||||
- SQLite 2-4s load is good enough for production
|
||||
- Can be added later as optimization without changing the persistence layer
|
||||
|
||||
### 6.4 Separate Manifest Store
|
||||
|
||||
Instead of storing manifests in the `files.chunk_manifest` column, use a separate sled tree or SQLite table. Rejected because the column already exists and the schema already supports it.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### 7.1 Task Sequence
|
||||
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Database methods: `list_all_files()`, `update_manifest()`, `get_manifest()`, `list_all_manifests()`, `checkpoint()`. Extract `row_to_file_meta()` helper. | New DB methods + tests |
|
||||
| 2 | Rewrite `run_mount()`: DB load path vs scan path. First-mount detection. | Core mount change |
|
||||
| 3 | Persist manifests: `ManifestCached` event + listener in main.rs. Load manifests on mount via `load_manifests_from_db()`. | Manifest persistence |
|
||||
| 4 | Wire tantivy + PatternStore + CollectionStore into mount path. First-mount indexing. | Search/patterns on mount |
|
||||
| 5 | Background delta sync: compare DB vs origin, update differences. | Delta sync task |
|
||||
| 6 | Shutdown: WAL checkpoint. Upsert files to DB during first-mount scan. | Clean shutdown |
|
||||
| 7 | Integration testing: full mount→read→restart→mount cycle. Verify tree + manifests survive restart. | E2E validation |
|
||||
| 8 | Buffer for issues found during integration. | — |
|
||||
|
||||
### 7.2 Verification Checklist
|
||||
|
||||
- [ ] `cargo check` — zero errors
|
||||
- [ ] `cargo test --workspace --exclude musicfs-grpc` — all pass
|
||||
- [ ] Manual test: first mount (empty cache dir) — scans origin, creates DB
|
||||
- [ ] Manual test: second mount (DB exists) — loads from DB, no origin scan
|
||||
- [ ] Manual test: add file to origin, restart — delta sync discovers it
|
||||
- [ ] Manual test: `kill -9` daemon, restart — DB loads, manifests intact
|
||||
- [ ] Mount time for 10K test files: <1 second on subsequent mount
|
||||
- [ ] `ls -la ~/.cache/musicfs/metadata.db` exists after first mount
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `musicfs-cache/src/db.rs` | `list_all_files()`, `update_manifest()`, `get_manifest()`, `list_all_manifests()`, `checkpoint()`, `row_to_file_meta()` refactor |
|
||||
| `musicfs-core/src/events.rs` | Add `ManifestCached` event variant |
|
||||
| `musicfs-cli/src/main.rs` | Rewrite `run_mount()`: DB load vs scan, open tantivy/patterns/collections, manifest listener, delta sync spawn, shutdown checkpoint |
|
||||
| `musicfs-cli/Cargo.toml` | Add `musicfs-search`, `musicfs-cache` dependencies (for PatternStore, CollectionStore, SearchIndex) |
|
||||
| `musicfs-cas/src/fetcher.rs` | Emit `ManifestCached` event after `fetch_file()` |
|
||||
| `musicfs-sync/src/delta.rs` | New `background_delta_sync()` function (or new file) |
|
||||
| `musicfs-test-utils/tests/resilience.rs` | New tests: mount-from-DB, manifest roundtrip, delta sync, first-mount detection |
|
||||
|
||||
---
|
||||
|
||||
## 9. Glossary / References
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **First mount** | Initial mount with empty database — triggers full origin scan |
|
||||
| **Subsequent mount** | Mount with existing database — loads from SQLite |
|
||||
| **Delta sync** | Background task that compares DB state against origin after mount |
|
||||
| **Stale data window** | Time between mount and delta sync completion when data may be outdated |
|
||||
| **WAL checkpoint** | SQLite operation that flushes write-ahead log to main database file |
|
||||
|
||||
| Document | Path |
|
||||
|----------|------|
|
||||
| Persistent state research | [persistent-state.md](persistent-state.md) |
|
||||
| Phase A (signals, shutdown) | [phase-a-stop-dying.md](phase-a-stop-dying.md) |
|
||||
| Phase B (crash recovery) | [phase-b-crash-recovery.md](phase-b-crash-recovery.md) |
|
||||
| Architecture | [architecture.md](../architecture.md) |
|
||||
@@ -1,353 +0,0 @@
|
||||
# MusicFS Persistent State Plan
|
||||
|
||||
**Date**: 2026-05-13
|
||||
**Status**: Research Complete — Design Decision Needed
|
||||
**Prerequisites**: [architecture.md](../architecture.md), [resilience-fault-tolerance.md](resilience-fault-tolerance.md)
|
||||
**Related Requirements**: G1 (O(1) mount time), NFR-1.7 (<500ms mount), FR-7.1 (cache persists across restarts)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Every mount is a full cold start. The `run_mount()` function in `main.rs` does not use any persistent storage — it walks the entire origin filesystem, parses metadata from every audio file, and builds all runtime state from scratch.
|
||||
|
||||
The architecture designed persistence infrastructure (SQLite schema, `chunk_manifest` column, `ChunkManifest::from_db()`, `chunks_to_bytes()`) but **none of it is wired into the mount path**. The mount flow doesn't even open the database.
|
||||
|
||||
### Mount Time by Library Size (Current)
|
||||
|
||||
| Library Size | Estimated Mount Time | Target (NFR-1.7) |
|
||||
|---|---|---|
|
||||
| 1K files | ~1-2s | <500ms |
|
||||
| 10K files | ~10-20s | <500ms |
|
||||
| 100K files | ~2-5 minutes | <500ms |
|
||||
| 1M files | ~20-60 minutes | <500ms |
|
||||
| 10M files (stretch) | hours | <500ms |
|
||||
|
||||
---
|
||||
|
||||
## 2. In-Memory State Inventory
|
||||
|
||||
### 2.1 State That Must Survive Restart
|
||||
|
||||
These are the large, expensive-to-rebuild data structures. Losing them forces a full origin rescan.
|
||||
|
||||
#### VirtualTree (~300-400MB at 1M files)
|
||||
|
||||
**Location**: `musicfs-cache/src/tree.rs`
|
||||
|
||||
**Contents**:
|
||||
- `nodes: HashMap<Inode, VirtualNode>` — every directory and file node
|
||||
- `path_to_inode: HashMap<VirtualPath, Inode>` — reverse path lookup
|
||||
- `next_inode: AtomicU64` — inode counter
|
||||
|
||||
**Currently rebuilt from**: Full recursive origin scan + metadata parse of every audio file. This is the single most expensive operation on mount — it touches every file on origin, runs symphonia metadata extraction, and builds the entire tree structure.
|
||||
|
||||
**What's needed**: Load from persistent storage on mount. Rebuild only on first-ever mount or if storage is corrupt.
|
||||
|
||||
---
|
||||
|
||||
#### ContentFetcher.file_meta (~200MB at 1M files)
|
||||
|
||||
**Location**: `musicfs-cas/src/fetcher.rs`
|
||||
|
||||
**Contents**:
|
||||
- `file_meta: RwLock<HashMap<FileId, FileMeta>>` — full metadata for every file
|
||||
- Each `FileMeta` contains: id, virtual_path, real_path (origin_id + path), size, mtime, content_hash, audio metadata
|
||||
|
||||
**Currently rebuilt from**: Same origin scan that builds the tree. Every file is registered via `fetcher.register_file(meta)`.
|
||||
|
||||
**What's needed**: This is essentially a duplicate of the tree data in a different shape. If the tree is loaded from storage, this map should be populated from the same source.
|
||||
|
||||
---
|
||||
|
||||
#### FileReader.manifests (~100MB at 1M files)
|
||||
|
||||
**Location**: `musicfs-cas/src/reader.rs`
|
||||
|
||||
**Contents**:
|
||||
- `manifests: RwLock<HashMap<FileId, ChunkManifest>>` — maps FileId to list of chunk hashes + offsets
|
||||
- Each `ChunkManifest` contains: file_id, total_size, mtime, chunks (Vec<ChunkRef> with hash + offset + size)
|
||||
|
||||
**Currently rebuilt from**: Re-fetched from origin on first `read()` after restart. The fetcher downloads the entire file, chunks it via CDC, stores chunks in CAS (dedup catches existing ones), and builds the manifest. This means every file is re-downloaded once after restart even though the chunks are already on disk.
|
||||
|
||||
**What's needed**: Persist manifests to storage after fetch. Load on mount. This is the difference between "restart = re-download everything" and "restart = instant reads from cache."
|
||||
|
||||
**Existing dead code**: SQLite `files` table has `chunk_manifest BLOB` column. `ChunkManifest::chunks_to_bytes()` and `ChunkManifest::from_db()` exist but are never called.
|
||||
|
||||
---
|
||||
|
||||
#### LruEviction access times (~50MB at 100K chunks)
|
||||
|
||||
**Location**: `musicfs-cache/src/eviction.rs`
|
||||
|
||||
**Contents**:
|
||||
- `access_times: RwLock<BTreeMap<Instant, ChunkHash>>` — ordered by access time
|
||||
- `hash_to_time: RwLock<HashMap<ChunkHash, Instant>>` — reverse lookup
|
||||
|
||||
**Currently rebuilt from**: Nothing. After restart, all chunks have equal eviction priority. The album you're currently listening to is just as likely to be evicted as something you played 6 months ago.
|
||||
|
||||
**What's needed**: Persist last-access timestamps. On mount, load and reconstruct the LRU order so hot data stays cached.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 State That Survives But Is Ignored on Mount
|
||||
|
||||
These persist on disk but `run_mount()` never opens them.
|
||||
|
||||
| Component | Persisted To | Loaded on Mount? | Effect |
|
||||
|---|---|---|---|
|
||||
| SQLite metadata (files table) | `metadata.db` | ❌ | All metadata re-scanned from origin |
|
||||
| tantivy search index | `search.idx/` | ❌ | Index rebuilt from scratch (or not at all) |
|
||||
| PatternStore (access patterns) | SQLite (separate DB) | ❌ | Predictions reset to zero |
|
||||
| CollectionStore (smart collections) | SQLite (same as patterns) | ❌ | Collections unavailable until opened |
|
||||
|
||||
### 2.3 State That Correctly Does Not Need Persistence
|
||||
|
||||
| Component | Why Transient Is Fine |
|
||||
|---|---|
|
||||
| OriginRegistry (origin connections) | Reconstructed from config on startup |
|
||||
| Router (priorities, latency stats) | Priorities from config; latency stats warm up within seconds |
|
||||
| HealthMonitor (health state) | All origins start as Unknown, converge within one check cycle (~30s) |
|
||||
| EventBus (in-flight events) | Transient by nature |
|
||||
| PrefetchEngine.in_flight | Transient work queue |
|
||||
| PluginManager | Re-loaded from config + plugin directories |
|
||||
| MusicFs.query_inodes | Transient search session state |
|
||||
| CasStore.current_size | Recalculated on open (though currently broken — see resilience doc 3.10) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Storage Decision
|
||||
|
||||
### 3.1 Requirements for Persistent State
|
||||
|
||||
1. **Bulk sequential read on mount** — load ~1M records into in-memory structures as fast as possible
|
||||
2. **Incremental updates at runtime** — delta sync adds/removes/modifies individual files
|
||||
3. **Crash safety** — no corruption on unclean shutdown (SIGKILL, power loss)
|
||||
4. **Manifest storage** — binary blobs (msgpack-encoded chunk lists), variable size (100 bytes to 10KB per file)
|
||||
5. **LRU timestamps** — simple key-value (ChunkHash → last_access_timestamp)
|
||||
6. **Already in project** — minimize new dependencies
|
||||
|
||||
### 3.2 Options
|
||||
|
||||
#### Option A: SQLite (Current Architecture Choice)
|
||||
|
||||
**Already in project**: `rusqlite` dependency, `schema.sql` with `files` table, `Database` struct with full CRUD, `chunk_manifest BLOB` column ready.
|
||||
|
||||
| Metric | Performance |
|
||||
|---|---|
|
||||
| Bulk load 1M rows | ~2-4 seconds (WAL mode, indexed) |
|
||||
| Single row upsert | ~50μs |
|
||||
| Crash safety | WAL mode — excellent |
|
||||
| Manifest blobs | Native BLOB support, no size limit |
|
||||
|
||||
**Pros**: Already built (schema, code, tests exist). Well-understood crash semantics. Single file backup. SQL queries for debugging. The `chunk_manifest` column and `from_db()`/`to_bytes()` methods are already written.
|
||||
|
||||
**Cons**: Not the fastest for pure key-value workloads. WAL checkpoint can cause brief write pauses. Single-writer limitation (Mutex around connection).
|
||||
|
||||
**Effort to wire up**: ~5-7 days (mostly connecting existing code, not writing new code)
|
||||
|
||||
---
|
||||
|
||||
#### Option B: sled (Already in Project for CAS Index)
|
||||
|
||||
**Already in project**: Used for CAS chunk hash → location mapping.
|
||||
|
||||
| Metric | Performance |
|
||||
|---|---|
|
||||
| Bulk load 1M entries | ~1-2 seconds (LSM, sequential reads) |
|
||||
| Single entry upsert | ~10-20μs |
|
||||
| Crash safety | Built-in WAL — good |
|
||||
| Manifest blobs | Native byte value support |
|
||||
|
||||
**Pros**: Faster than SQLite for pure key-value. Already a dependency. Good for LRU timestamps (simple k/v).
|
||||
|
||||
**Cons**: No SQL — querying for debugging is harder. No schema migration story. Limited tooling. Has known issues with large datasets (memory usage during compaction). Two persistence engines = two things to maintain.
|
||||
|
||||
**Effort**: ~7-9 days (new serialization layer, no existing code to reuse)
|
||||
|
||||
---
|
||||
|
||||
#### Option C: Flat File (bincode/msgpack dump)
|
||||
|
||||
| Metric | Performance |
|
||||
|---|---|
|
||||
| Bulk load 1M entries | <1 second (mmap, zero-parse with bincode) |
|
||||
| Single entry upsert | N/A — full rewrite required |
|
||||
| Crash safety | Must write atomically (tmp + rename) |
|
||||
| Manifest blobs | Part of serialized struct |
|
||||
|
||||
**Pros**: Fastest possible bulk load. Simplest implementation.
|
||||
|
||||
**Cons**: No incremental updates — every change requires serializing and rewriting the entire file. At 1M files (~500MB serialized), a single file modification triggers a 500MB write. No concurrent access. No recovery from partial corruption.
|
||||
|
||||
**Effort**: ~3-4 days but creates ongoing maintenance burden for delta updates
|
||||
|
||||
---
|
||||
|
||||
#### Option D: Hybrid (SQLite for metadata + sled for hot-path data)
|
||||
|
||||
Use SQLite for structured metadata (files, collections, patterns — already built) and sled for hot-path key-value data (manifests, LRU timestamps — performance-critical).
|
||||
|
||||
**Pros**: Each store optimized for its access pattern. SQLite for queryable metadata, sled for fast blob lookup.
|
||||
|
||||
**Cons**: Two persistence engines to coordinate. Consistency between them on crash. More complex startup/shutdown.
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Recommendation
|
||||
|
||||
**Pending your decision.** The tradeoffs are:
|
||||
- **Simplest path**: Option A (SQLite) — most code already exists, just needs wiring
|
||||
- **Fastest hot-path**: Option D (Hybrid) — but more complexity
|
||||
- **Fastest bulk load**: Option C (Flat file) — but no incremental updates
|
||||
|
||||
The choice depends on what you value most. SQLite at 1M files loads in ~2-4 seconds — is that acceptable vs the <500ms target? If not, a flat file or sled for the tree data with SQLite for everything else might be needed.
|
||||
|
||||
---
|
||||
|
||||
## 4. What Needs to Change
|
||||
|
||||
Regardless of storage choice, these are the code changes needed:
|
||||
|
||||
### 4.1 Mount Path (musicfs-cli/src/main.rs)
|
||||
|
||||
Current `run_mount()` flow:
|
||||
```
|
||||
1. Open CAS store → O(1)
|
||||
2. Create origin connection → O(1)
|
||||
3. scan_music_files() — FULL ORIGIN WALK → O(N × origin_latency) ← BOTTLENECK
|
||||
4. Build tree from scan results → O(N)
|
||||
5. Register files in fetcher → O(N)
|
||||
6. Mount FUSE → O(1)
|
||||
```
|
||||
|
||||
Required flow:
|
||||
```
|
||||
1. Open CAS store → O(1)
|
||||
2. Open persistent state store → O(1)
|
||||
3. IF store has data:
|
||||
Load tree from store → O(N × local_read) ← ~1000x faster
|
||||
Load manifests from store → O(N × local_read)
|
||||
Load LRU access times from store → O(chunks)
|
||||
ELSE (first mount):
|
||||
Full origin scan (current behavior) → O(N × origin_latency)
|
||||
Persist results to store → O(N × local_write)
|
||||
4. Open tantivy search index → O(1)
|
||||
5. Open PatternStore → O(1)
|
||||
6. Create origin connections → O(1)
|
||||
7. Mount FUSE → O(1)
|
||||
8. Background: delta sync (origin vs store) → incremental, non-blocking
|
||||
```
|
||||
|
||||
### 4.2 Runtime Persistence (Write Path)
|
||||
|
||||
These operations must persist state changes as they happen, not just on shutdown:
|
||||
|
||||
| Event | What to Persist | When |
|
||||
|---|---|---|
|
||||
| File discovered during sync | FileMeta → store | Immediately (in batch if scanning) |
|
||||
| File removed during sync | Delete from store | Immediately |
|
||||
| File metadata changed | Update FileMeta in store | Immediately |
|
||||
| File content fetched (cache miss) | ChunkManifest → store | After fetch completes |
|
||||
| Chunk accessed | Update LRU timestamp | Batched (every 10s or 100 accesses) |
|
||||
| Search index updated | tantivy handles its own persistence | On commit (every 5s) |
|
||||
| Access pattern recorded | PatternStore handles its own persistence | Already persisted per-access |
|
||||
|
||||
### 4.3 Files That Need Changes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `musicfs-cli/src/main.rs` | Rewrite `run_mount()` to load from store; add background delta sync |
|
||||
| `musicfs-cache/src/db.rs` | Add `list_all_files()` bulk load; add manifest read/write methods (if SQLite) |
|
||||
| `musicfs-cache/src/tree.rs` | Add `TreeBuilder::from_file_metas(iter)` — build tree from stored records |
|
||||
| `musicfs-cas/src/reader.rs` | Load manifests from store on startup; persist after fetch |
|
||||
| `musicfs-cas/src/fetcher.rs` | After `fetch_file()`, persist manifest to store |
|
||||
| `musicfs-cache/src/eviction.rs` | Persist access times; load on startup |
|
||||
| `musicfs-search/src/indexer.rs` | On mount, check what's already indexed vs what's in store — skip known files |
|
||||
| `musicfs-sync/src/delta.rs` | Background delta sync: compare store state vs origin, sync differences |
|
||||
|
||||
### 4.4 Shutdown Persistence
|
||||
|
||||
On graceful shutdown (after signal handling from resilience plan Phase A is implemented):
|
||||
|
||||
| Step | What |
|
||||
|---|---|
|
||||
| 1 | Flush any batched LRU timestamp updates |
|
||||
| 2 | Commit tantivy index writer |
|
||||
| 3 | WAL checkpoint SQLite (if SQLite): `PRAGMA wal_checkpoint(TRUNCATE)` |
|
||||
| 4 | Flush sled (if sled): `sled::Db::flush()` |
|
||||
| 5 | Close all database connections |
|
||||
|
||||
On crash (no graceful shutdown):
|
||||
- SQLite WAL mode: automatic recovery on next open (no data loss for committed transactions)
|
||||
- sled: automatic recovery via internal WAL
|
||||
- tantivy: up to 5 seconds of uncommitted documents lost, but recoverable from store
|
||||
- LRU timestamps: batched updates may lose last batch (10s window) — acceptable
|
||||
|
||||
---
|
||||
|
||||
## 5. Background Delta Sync
|
||||
|
||||
After mounting from persistent state, the data may be stale (origin changed while daemon was stopped). A background sync reconciles:
|
||||
|
||||
```
|
||||
1. Walk origin (or use watcher for inotify-capable origins)
|
||||
2. For each file on origin:
|
||||
a. Compare mtime + size against stored record
|
||||
b. If unchanged → skip
|
||||
c. If modified → re-parse metadata, update store, update tree, invalidate manifest
|
||||
d. If new → parse metadata, add to store + tree
|
||||
3. For each file in store not found on origin:
|
||||
a. Remove from store + tree
|
||||
4. Update search index for changed files
|
||||
5. Log summary: "Delta sync complete: N added, M modified, K removed, T unchanged"
|
||||
```
|
||||
|
||||
This runs in the background AFTER mount completes. Users see the filesystem immediately (from stored state), and it converges to current reality within minutes.
|
||||
|
||||
### 5.1 Stale Data Window
|
||||
|
||||
Between mount and delta sync completion, users may see:
|
||||
- Files that were deleted on origin (will get ENOENT or EIO on read — origin returns not found)
|
||||
- Files with old metadata (wrong track name, etc.)
|
||||
- Missing files that were added to origin (won't appear until sync discovers them)
|
||||
|
||||
This is acceptable — it's the same behavior as any cached filesystem (NFS, CIFS). The key insight: **stale data for 30 seconds is infinitely better than no data for 5 minutes.**
|
||||
|
||||
---
|
||||
|
||||
## 6. First Mount vs Subsequent Mount
|
||||
|
||||
| | First Mount (empty store) | Subsequent Mount (store has data) |
|
||||
|---|---|---|
|
||||
| **Tree source** | Origin scan + metadata parse | Load from store |
|
||||
| **Manifests** | None (populated on first read) | Loaded from store |
|
||||
| **Search index** | Built during/after scan | Opened from disk |
|
||||
| **LRU data** | Empty (cold cache) | Loaded from store |
|
||||
| **Mount time** | O(N × origin_latency) — same as today | O(N × local_read) — target <5s for 1M files |
|
||||
| **Accuracy** | 100% current | Stale until delta sync completes |
|
||||
| **Detection** | Store file doesn't exist or is empty | Store file exists with data |
|
||||
|
||||
---
|
||||
|
||||
## 7. Estimated Effort
|
||||
|
||||
| Task | Effort | Depends On |
|
||||
|---|---|---|
|
||||
| Rewrite `run_mount()` with store loading + fallback | 2 days | Storage decision |
|
||||
| Persist chunk manifests after fetch | 1 day | Storage decision |
|
||||
| Load manifests on mount + register in FileReader | 0.5 day | Above |
|
||||
| Open tantivy on mount, skip known files | 1 day | — |
|
||||
| Open PatternStore + CollectionStore on mount | 0.5 day | — |
|
||||
| Background delta sync | 1.5 days | — |
|
||||
| Persist LRU access times + load on mount | 1 day | Storage decision |
|
||||
| First-mount detection + fallback to full scan | 0.5 day | — |
|
||||
| **Total** | **~8 days** | |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open Decision
|
||||
|
||||
**Which storage engine for the persistent state?**
|
||||
|
||||
The answer drives the implementation of every task above. See Section 3 for tradeoffs.
|
||||
@@ -1,569 +0,0 @@
|
||||
# Phase A: Stop Dying — Implementation Plan
|
||||
|
||||
**Authors:** AI-assisted
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2026-05-13
|
||||
**Reviewers:** TBD
|
||||
**Approvers:** TBD
|
||||
**Prerequisites:** [resilience-fault-tolerance.md](resilience-fault-tolerance.md), [resilience-testing.md](resilience-testing.md)
|
||||
**Estimated Effort:** ~5 days
|
||||
|
||||
---
|
||||
|
||||
[TOC]
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
|
||||
Implement the 6 most critical resilience fixes (issues 2.1, 2.2, 2.7, 2.9, 2.10, 3.7 from the [resilience audit](resilience-fault-tolerance.md)) that prevent MusicFS from dying on common operational events: signals, panics, lock poisoning, and systemd lifecycle.
|
||||
|
||||
Issues 2.3 (shutdown orchestration), 2.4 (cache integrity), 2.5 (sync recovery), 2.6 (task supervisor), 2.8 (disk space) are deferred to Phase B — they depend on Phase A infrastructure or on the [persistent state](persistent-state.md) work.
|
||||
|
||||
**Development flow** (TDD, per-issue):
|
||||
1. Create stubs so the codebase compiles
|
||||
2. Write RED tests that express the expected behavior
|
||||
3. Implement the fix
|
||||
4. Verify tests turn GREEN
|
||||
5. Run full test suite — no regressions
|
||||
|
||||
---
|
||||
|
||||
## 2. Background
|
||||
|
||||
MusicFS currently dies on:
|
||||
- Any signal (SIGTERM, SIGINT) — instant death, no cleanup
|
||||
- Any panic in a writer thread — RwLock poisons, all FUSE ops crash
|
||||
- systemd lifecycle — `Type=notify` but no `sd_notify`, ExecStop is a stub
|
||||
- Crash leaves stale FUSE mount — users must manually `fusermount -u`
|
||||
|
||||
The [resilience test crate](../../musicfs/crates/musicfs-test-utils/) and RED tests are already in place. This plan implements the fixes to turn them GREEN.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
- Signal handler catches SIGTERM/SIGINT and initiates clean exit
|
||||
- Panics are logged with full context before process terminates
|
||||
- RwLock poisoning cannot cascade to kill FUSE operations
|
||||
- systemd integration works (`sd_notify READY=1`, `ExecStopPost`)
|
||||
- Stale FUSE mounts are detected and cleaned on startup
|
||||
- All existing 162 tests continue to pass
|
||||
- All Phase A RED tests turn GREEN
|
||||
|
||||
### 3.2 Non-Goals
|
||||
|
||||
- Graceful shutdown orchestration with ordered teardown (Phase B — needs CancellationToken plumbing through all components)
|
||||
- Task supervisor for background task restart (Phase B)
|
||||
- Cache integrity checks on startup (Phase B — needs persistent state)
|
||||
- Disk space monitoring (Phase B)
|
||||
- Interrupted sync recovery (Phase B — needs persistent state)
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Design
|
||||
|
||||
### 4.1 Implementation Order
|
||||
|
||||
Dependencies determine the order. Each issue is independent except where noted.
|
||||
|
||||
```
|
||||
4.2 RwLock poison fix (no deps, instant win, unblocks safety)
|
||||
↓
|
||||
4.3 Panic hook (no deps, complements RwLock fix)
|
||||
↓
|
||||
4.4 systemd ExecStopPost (no deps, config-only change)
|
||||
↓
|
||||
4.5 sd_notify integration (no deps, new crate dependency)
|
||||
↓
|
||||
4.6 Signal handling (depends on: FUSE mount change to spawn_mount2)
|
||||
↓
|
||||
4.7 Stale mount detection (depends on: signal handling for clean test)
|
||||
```
|
||||
|
||||
### 4.2 Issue 2.9: RwLock Poison Fix
|
||||
|
||||
**Approach**: Replace `std::sync::RwLock` with `parking_lot::RwLock` in all production paths. `parking_lot` never poisons — a panic in a writer releases the lock and subsequent readers see the pre-panic state.
|
||||
|
||||
**Why parking_lot over poison recovery**: The codebase already uses `parking_lot` in `prefetch.rs` and `index.rs`. Using it everywhere is consistent. The alternative (`.unwrap_or_else(|p| p.into_inner())`) is verbose and error-prone — one missed call re-introduces the bug.
|
||||
|
||||
#### Step 1: Stubs (compile)
|
||||
|
||||
None needed — `parking_lot::RwLock` is a drop-in replacement (same API, no `PoisonError`).
|
||||
|
||||
#### Step 2: RED tests
|
||||
|
||||
Already exist in `tests/resilience.rs`:
|
||||
- `test_poisoned_tree_lock_returns_eio_not_panic` — currently passes (demonstrates the problem)
|
||||
- `test_parking_lot_rwlock_survives_panic` — currently passes (proves the fix works)
|
||||
|
||||
Additional test to add: verify FUSE filesystem survives a writer panic on the tree lock.
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**Files to change:**
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `musicfs-fuse/src/filesystem.rs` | `use std::sync::RwLock` → `use parking_lot::RwLock`; remove all `.unwrap()` on lock calls (parking_lot returns guard directly, not `Result`) |
|
||||
| `musicfs-cas/src/reader.rs` | Same change for `manifests: RwLock<HashMap<...>>` |
|
||||
| `musicfs-cas/src/fetcher.rs` | Same change for `origins` and `file_meta` locks |
|
||||
| `musicfs-origins/src/registry.rs` | Same change for `origins` and `watch_handles` locks |
|
||||
| `musicfs-cache/src/eviction.rs` | Same change for `access_times` and `hash_to_time` locks |
|
||||
| `musicfs-core/src/metrics.rs` | Same change for histogram locks |
|
||||
| `musicfs-cache/src/tree.rs` | Same change for `last_refresh` lock |
|
||||
|
||||
**Pattern**: In each file:
|
||||
```rust
|
||||
// BEFORE
|
||||
use std::sync::RwLock;
|
||||
let guard = self.tree.read().unwrap();
|
||||
|
||||
// AFTER
|
||||
use parking_lot::RwLock;
|
||||
let guard = self.tree.read(); // No unwrap needed
|
||||
```
|
||||
|
||||
For the `MusicFs` struct in `filesystem.rs`, the `tree` field is `Arc<RwLock<VirtualTree>>` — this is passed in from `main.rs`. Change `main.rs` to use `parking_lot::RwLock` there too.
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test # All 162+ tests pass
|
||||
cargo test -p musicfs-test-utils # Resilience tests pass
|
||||
cargo check # No warnings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Issue 2.2: Panic Hook
|
||||
|
||||
**Approach**: Install a custom panic hook at daemon startup that logs the panic with `tracing::error!` before the default behavior (abort/unwind). This ensures panics are captured in log files and journald.
|
||||
|
||||
#### Step 1: Stubs
|
||||
|
||||
Add to `musicfs-core/src/lib.rs`:
|
||||
```rust
|
||||
pub fn install_panic_hook() {
|
||||
// stub — will be implemented
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: RED tests
|
||||
|
||||
Write in `tests/resilience.rs`:
|
||||
```rust
|
||||
#[test]
|
||||
fn test_panic_hook_logs_to_tracing() {
|
||||
// Install hook with a test tracing subscriber
|
||||
// Trigger panic via catch_unwind
|
||||
// Verify error! log contains panic message + thread name
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-core/src/lib.rs` (or new `musicfs-core/src/panic.rs`)
|
||||
|
||||
```rust
|
||||
pub fn install_panic_hook() {
|
||||
let default_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let thread = std::thread::current();
|
||||
let thread_name = thread.name().unwrap_or("<unnamed>");
|
||||
|
||||
let message = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
};
|
||||
|
||||
let location = info.location().map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
|
||||
.unwrap_or_else(|| "unknown location".to_string());
|
||||
|
||||
tracing::error!(
|
||||
thread = thread_name,
|
||||
location = %location,
|
||||
"PANIC: {}",
|
||||
message
|
||||
);
|
||||
|
||||
default_hook(info);
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
**Call site**: `musicfs-cli/src/main.rs`, at the very top of `main()`:
|
||||
```rust
|
||||
fn main() -> Result<()> {
|
||||
musicfs_core::install_panic_hook();
|
||||
let cli = Cli::parse();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-core # Panic hook unit tests
|
||||
cargo test -p musicfs-test-utils # Resilience tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Issue 3.7 + 2.7: systemd Service Fix + FUSE Cleanup
|
||||
|
||||
**Approach**: Fix the systemd service file and add stale mount detection on startup.
|
||||
|
||||
#### Step 1: No stubs needed (config change)
|
||||
|
||||
#### Step 2: RED tests
|
||||
|
||||
Already exists: `test_systemd_service_has_execstoppost` — currently fails because service file lacks `ExecStopPost`.
|
||||
|
||||
Add test for stale mount detection:
|
||||
```rust
|
||||
#[test]
|
||||
fn test_stale_mount_check_function_exists() {
|
||||
// Verify the function signature exists
|
||||
// (actual mount test needs privileged environment)
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `dist/musicfs.service`
|
||||
|
||||
```diff
|
||||
ExecStop=/usr/bin/musicfs shutdown
|
||||
+ExecStopPost=/usr/bin/fusermount -uz %h/music || true
|
||||
Restart=on-failure
|
||||
```
|
||||
|
||||
Note: `fusermount -uz` is "lazy unmount" — always succeeds even if mount is busy. The `|| true` prevents systemd from treating cleanup failure as a service failure.
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs` — add stale mount check before mounting:
|
||||
|
||||
```rust
|
||||
fn check_stale_mount(mountpoint: &Path) -> Result<()> {
|
||||
// Check /proc/mounts for existing mount at this path
|
||||
if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") {
|
||||
for line in mounts.lines() {
|
||||
if line.contains(&mountpoint.to_string_lossy().as_ref()) && line.contains("fuse") {
|
||||
warn!("Stale FUSE mount detected at {:?}, attempting cleanup", mountpoint);
|
||||
let status = std::process::Command::new("fusermount")
|
||||
.args(["-uz", &mountpoint.to_string_lossy()])
|
||||
.status();
|
||||
match status {
|
||||
Ok(s) if s.success() => info!("Stale mount cleaned up"),
|
||||
Ok(s) => warn!("fusermount exited with: {}", s),
|
||||
Err(e) => warn!("Failed to run fusermount: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
Also fix the `test_systemd_service_has_execstoppost` test path — currently points to wrong location.
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils -- test_systemd # Service file test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Issue 2.10: sd_notify Integration
|
||||
|
||||
**Approach**: Add `sd-notify` crate, call `READY=1` after mount, `STOPPING` on shutdown.
|
||||
|
||||
#### Step 1: Stubs
|
||||
|
||||
Add dependency to `musicfs-cli/Cargo.toml`:
|
||||
```toml
|
||||
sd-notify = "0.4"
|
||||
```
|
||||
|
||||
#### Step 2: RED tests
|
||||
|
||||
Write test that mocks the notify socket:
|
||||
```rust
|
||||
#[test]
|
||||
fn test_sd_notify_ready_sent() {
|
||||
// Create Unix datagram socket at $NOTIFY_SOCKET
|
||||
// Call sd_notify::notify(READY=1)
|
||||
// Verify message received on socket
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs`
|
||||
|
||||
After `fs.mount()` succeeds (or more precisely, after `spawn_mount2` — see 4.6):
|
||||
```rust
|
||||
// Notify systemd we're ready
|
||||
if let Err(e) = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]) {
|
||||
debug!("sd_notify not available (not running under systemd): {}", e);
|
||||
}
|
||||
```
|
||||
|
||||
On shutdown path:
|
||||
```rust
|
||||
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]);
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils -- test_sd_notify
|
||||
cargo build -p musicfs-cli # Verify it compiles with new dep
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Issue 2.1: Signal Handling
|
||||
|
||||
**Approach**: Switch from `fuser::mount2` (blocking) to `fuser::spawn_mount2` (background), then listen for signals on the main thread.
|
||||
|
||||
This is the most complex change in Phase A. It restructures the daemon's main loop.
|
||||
|
||||
#### Step 1: Stubs
|
||||
|
||||
Change `MusicFs::mount()` signature to return a session handle:
|
||||
|
||||
```rust
|
||||
// BEFORE
|
||||
pub fn mount(self, mountpoint: &Path) -> Result<()> {
|
||||
fuser::mount2(self, mountpoint, &options)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// AFTER (stub — returns BackgroundSession)
|
||||
pub fn spawn_mount(self, mountpoint: &Path) -> Result<fuser::BackgroundSession> {
|
||||
let session = fuser::spawn_mount2(self, mountpoint, &options)?;
|
||||
Ok(session)
|
||||
}
|
||||
```
|
||||
|
||||
Keep old `mount()` temporarily for compatibility.
|
||||
|
||||
#### Step 2: RED tests
|
||||
|
||||
Write in `tests/resilience.rs`:
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_sigterm_triggers_shutdown() {
|
||||
// Spawn daemon as child process
|
||||
// Wait for mount
|
||||
// Send SIGTERM
|
||||
// Verify clean exit within 10s
|
||||
// Verify mountpoint is unmounted
|
||||
}
|
||||
```
|
||||
|
||||
This test requires the signal handler to exist. It will be RED until implementation.
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs` — rewrite `run_mount()`:
|
||||
|
||||
```rust
|
||||
fn run_mount(mountpoint: PathBuf, origin_path: Option<PathBuf>, cache_dir: Option<PathBuf>) -> Result<()> {
|
||||
let origin_path = origin_path.context("--origin is required")?;
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
let (tree, reader) = runtime.block_on(async {
|
||||
// ... existing setup code (unchanged) ...
|
||||
Ok::<_, anyhow::Error>((tree, reader))
|
||||
})?;
|
||||
|
||||
// Check for stale mount before mounting
|
||||
check_stale_mount(&mountpoint)?;
|
||||
|
||||
let fs = MusicFs::with_reader(tree, reader, handle.clone());
|
||||
info!("Mounting filesystem at {:?}", mountpoint);
|
||||
|
||||
// spawn_mount2 returns immediately — FUSE runs in background
|
||||
let session = fs.spawn_mount(&mountpoint)
|
||||
.context("Failed to mount filesystem")?;
|
||||
|
||||
// Notify systemd
|
||||
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Ready]);
|
||||
info!("MusicFS ready, PID {}", std::process::id());
|
||||
|
||||
// Block on signal
|
||||
runtime.block_on(async {
|
||||
let mut sigterm = tokio::signal::unix::signal(
|
||||
tokio::signal::unix::SignalKind::terminate()
|
||||
)?;
|
||||
let mut sigint = tokio::signal::unix::signal(
|
||||
tokio::signal::unix::SignalKind::interrupt()
|
||||
)?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigterm.recv() => {
|
||||
info!("Received SIGTERM, shutting down");
|
||||
}
|
||||
_ = sigint.recv() => {
|
||||
info!("Received SIGINT, shutting down");
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})?;
|
||||
|
||||
// Shutdown sequence
|
||||
let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]);
|
||||
info!("Unmounting filesystem");
|
||||
drop(session); // BackgroundSession::drop() calls unmount
|
||||
info!("Shutdown complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `musicfs-fuse/src/filesystem.rs` — add `spawn_mount()`:
|
||||
|
||||
```rust
|
||||
pub fn spawn_mount(self, mountpoint: &Path) -> Result<fuser::BackgroundSession> {
|
||||
info!("Mounting MusicFS at {:?}", mountpoint);
|
||||
let options = vec![
|
||||
fuser::MountOption::RO,
|
||||
fuser::MountOption::FSName("musicfs".to_string()),
|
||||
fuser::MountOption::AutoUnmount,
|
||||
fuser::MountOption::AllowOther,
|
||||
];
|
||||
let session = fuser::spawn_mount2(self, mountpoint, &options)
|
||||
.map_err(musicfs_core::Error::Io)?;
|
||||
Ok(session)
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo build -p musicfs-cli
|
||||
cargo test -p musicfs-test-utils -- test_sigterm # Process-level test
|
||||
cargo test # No regressions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1 Security & Privacy
|
||||
|
||||
- No new attack surface — changes are internal lifecycle management
|
||||
- Panic hook does NOT log sensitive data (only panic message, thread name, location)
|
||||
- `sd_notify` uses existing systemd socket — no new IPC
|
||||
|
||||
### 5.2 Observability
|
||||
|
||||
- Panic hook ensures all panics are captured in logs/journald
|
||||
- Signal handling logs which signal triggered shutdown
|
||||
- sd_notify gives systemd accurate service state
|
||||
- Stale mount detection logs cleanup attempts
|
||||
|
||||
### 5.3 Testing
|
||||
|
||||
All changes follow the TDD flow:
|
||||
1. Stubs compile
|
||||
2. RED tests document expected behavior
|
||||
3. Implementation turns tests GREEN
|
||||
4. Full suite passes (no regressions)
|
||||
|
||||
---
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 6.1 Poison Recovery Instead of parking_lot
|
||||
|
||||
**Alternative**: Keep `std::sync::RwLock`, add `.unwrap_or_else(|p| p.into_inner())` to every lock call.
|
||||
|
||||
**Rejected**: 30+ call sites to change, easy to miss one, and the pattern is verbose. `parking_lot` is already a dependency and is strictly better for this use case (faster, no poison, correct API).
|
||||
|
||||
### 6.2 Keep mount2 (blocking) with Signal Thread
|
||||
|
||||
**Alternative**: Keep `fuser::mount2`, spawn a separate thread for signal handling, use a channel to communicate shutdown.
|
||||
|
||||
**Rejected**: `mount2` consumes `self` and blocks — there's no clean way to interrupt it from another thread. `spawn_mount2` is the canonical solution from the `fuser` crate.
|
||||
|
||||
### 6.3 Defer sd_notify Until Full Shutdown Orchestration
|
||||
|
||||
**Alternative**: Implement sd_notify only after CancellationToken + graceful shutdown are in place.
|
||||
|
||||
**Rejected**: sd_notify `READY=1` is critical now — without it, `Type=notify` in the service file means systemd will timeout and kill the daemon on every start. The shutdown `STOPPING` notification is a bonus but not required for Phase A.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### 7.1 Task Sequence
|
||||
|
||||
| Day | Task | Issue | Effort | Test Approach |
|
||||
|-----|------|-------|--------|---------------|
|
||||
| 1 (morning) | RwLock → parking_lot migration | 2.9 | 2h | Existing GREEN test validates; verify no `.unwrap()` on locks |
|
||||
| 1 (afternoon) | Panic hook | 2.2 | 2h | New test: panic → verify tracing output |
|
||||
| 2 (morning) | systemd ExecStopPost + stale mount check | 3.7 + 2.7 | 2h | Existing RED test → GREEN; new stale mount test |
|
||||
| 2 (afternoon) | sd_notify integration | 2.10 | 2h | New test: mock socket → verify READY=1 |
|
||||
| 3 | Signal handling (spawn_mount2 + signal loop) | 2.1 | 4h | Fork daemon → send SIGTERM → verify exit |
|
||||
| 4 | Integration + regression testing | — | 4h | Full `cargo test`, manual FUSE mount test |
|
||||
| 5 | Buffer for issues found during integration | — | 4h | — |
|
||||
|
||||
### 7.2 Verification Checklist
|
||||
|
||||
After all tasks complete:
|
||||
|
||||
- [ ] `cargo check` — zero errors, zero warnings
|
||||
- [ ] `cargo test` — all 162+ existing tests pass
|
||||
- [ ] `cargo test -p musicfs-test-utils` — all resilience tests pass
|
||||
- [ ] `cargo clippy` — no new warnings
|
||||
- [ ] `grep -r '\.read()\.unwrap()\|\.write()\.unwrap()' crates/` — zero hits in production code (test code is OK)
|
||||
- [ ] `dist/musicfs.service` contains `ExecStopPost`
|
||||
- [ ] Manual test: `musicfs mount`, then `kill -TERM <pid>`, verify clean exit + mount gone
|
||||
- [ ] Manual test: `kill -9 <pid>`, then `musicfs mount` again — no "already mounted" error
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Changed
|
||||
|
||||
| File | Change | Issue |
|
||||
|------|--------|-------|
|
||||
| `musicfs-fuse/src/filesystem.rs` | `std::sync::RwLock` → `parking_lot::RwLock`; add `spawn_mount()` | 2.9, 2.1 |
|
||||
| `musicfs-cas/src/reader.rs` | `std::sync::RwLock` → `parking_lot::RwLock` | 2.9 |
|
||||
| `musicfs-cas/src/fetcher.rs` | `std::sync::RwLock` → `parking_lot::RwLock` | 2.9 |
|
||||
| `musicfs-origins/src/registry.rs` | `std::sync::RwLock` → `parking_lot::RwLock` | 2.9 |
|
||||
| `musicfs-cache/src/eviction.rs` | `std::sync::RwLock` → `parking_lot::RwLock` | 2.9 |
|
||||
| `musicfs-cache/src/tree.rs` | `std::sync::RwLock` → `parking_lot::RwLock` | 2.9 |
|
||||
| `musicfs-core/src/metrics.rs` | `std::sync::RwLock` → `parking_lot::RwLock` | 2.9 |
|
||||
| `musicfs-core/src/lib.rs` | Add `install_panic_hook()` | 2.2 |
|
||||
| `musicfs-cli/src/main.rs` | Panic hook, signal handler, spawn_mount2, sd_notify, stale mount check | 2.1, 2.2, 2.7, 2.10 |
|
||||
| `musicfs-cli/Cargo.toml` | Add `sd-notify`, `tokio-util` deps | 2.10, 2.1 |
|
||||
| `dist/musicfs.service` | Add `ExecStopPost`, fix `ExecStop` | 3.7 |
|
||||
| `tests/resilience.rs` | Update/add tests for signal, panic hook, sd_notify | all |
|
||||
|
||||
---
|
||||
|
||||
## 9. Glossary / References
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **parking_lot** | Fast, poison-free lock implementation. Already a project dependency. |
|
||||
| **spawn_mount2** | `fuser` API that mounts FUSE in a background thread, returning a `BackgroundSession` handle |
|
||||
| **sd_notify** | systemd notification protocol. `READY=1` signals service started, `STOPPING` signals shutdown. |
|
||||
| **BackgroundSession** | Handle returned by `spawn_mount2`. Dropping it unmounts the filesystem. |
|
||||
|
||||
| Document | Path |
|
||||
|----------|------|
|
||||
| Resilience audit | [resilience-fault-tolerance.md](resilience-fault-tolerance.md) |
|
||||
| Resilience testing | [resilience-testing.md](resilience-testing.md) |
|
||||
| Architecture | [architecture.md](../architecture.md) |
|
||||
@@ -1,830 +0,0 @@
|
||||
# Phase B: Crash Recovery — Implementation Plan
|
||||
|
||||
**Authors:** AI-assisted
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2026-05-13
|
||||
**Reviewers:** TBD
|
||||
**Approvers:** TBD
|
||||
**Prerequisites:** [phase-a-stop-dying.md](phase-a-stop-dying.md) (completed), [resilience-fault-tolerance.md](resilience-fault-tolerance.md)
|
||||
**Estimated Effort:** ~5 days
|
||||
|
||||
---
|
||||
|
||||
[TOC]
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
|
||||
Phase A made the daemon survive signals and panics. Phase B makes it **recover from crashes** — startup integrity checks for all storage layers (SQLite, tantivy, sled), graceful shutdown with ordered teardown of background tasks, disk space pre-checks, and a task supervisor that restarts dead background tasks.
|
||||
|
||||
This covers issues 2.3, 2.4, 2.6, and 2.8 from the [resilience audit](resilience-fault-tolerance.md), deferred from Phase A.
|
||||
|
||||
Issue 2.5 (interrupted sync recovery) is deferred to after [persistent state](persistent-state.md) is wired up — checkpoint/resume requires the DB to be in the mount path.
|
||||
|
||||
**RED tests to turn GREEN** (from current `resilience.rs`):
|
||||
- `test_sqlite_integrity_check_detects_corruption` — currently `todo!()`
|
||||
- `test_tantivy_corruption_triggers_rebuild` — currently `todo!()`
|
||||
- `test_sled_corruption_triggers_repair` — currently `todo!()`
|
||||
- `test_cas_put_handles_enospc` — currently fails (no size pre-check)
|
||||
- `test_tantivy_survives_uncommitted_crash` — currently `todo!()`
|
||||
|
||||
**New tests to write:**
|
||||
- Shutdown orchestration: CancellationToken propagation, ordered teardown, tantivy flush
|
||||
- Task supervisor: panic detection, restart with backoff, status reporting
|
||||
|
||||
---
|
||||
|
||||
## 2. Background
|
||||
|
||||
### 2.1 What Phase A Delivered
|
||||
|
||||
- Signal handling via `spawn_mount2` + tokio signal loop ✅
|
||||
- Panic hook logging via `tracing::error!` ✅
|
||||
- RwLock → `parking_lot` (no more poison cascade) ✅
|
||||
- sd_notify READY/STOPPING ✅
|
||||
- ExecStopPost + stale mount detection ✅
|
||||
|
||||
### 2.2 What's Still Broken After Phase A
|
||||
|
||||
The daemon now **stops cleanly** on signals but:
|
||||
|
||||
1. **Shutdown is unordered** — `drop(session)` unmounts FUSE, but background tasks (health monitor, indexer, watcher, prefetcher) are killed mid-operation by runtime drop. No tantivy flush, no SQLite checkpoint.
|
||||
|
||||
2. **No startup integrity checks** — if the daemon was `kill -9`'d (or OOM-killed, power loss), SQLite/tantivy/sled may have partial writes. Currently these propagate as runtime errors or silent corruption.
|
||||
|
||||
3. **Background tasks are fire-and-forget** — health monitor, watcher, indexer, prefetcher use `tokio::spawn` with no `JoinHandle` stored. If a task panics, it's silently dead.
|
||||
|
||||
4. **CAS accepts oversized writes** — `put()` doesn't check `max_size` before writing. Cache grows unbounded.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
- Graceful shutdown flushes tantivy, checkpoints SQLite WAL, stops background tasks in order
|
||||
- Corrupted SQLite detected on open via `PRAGMA integrity_check`
|
||||
- Corrupted tantivy index detected and rebuilt from scratch
|
||||
- Corrupted sled index detected and repaired
|
||||
- CAS rejects writes that would exceed `max_size`
|
||||
- Background tasks are supervised — panics detected, critical tasks restarted
|
||||
- All 5 RED tests turn GREEN
|
||||
- All new tests for shutdown + supervisor are GREEN
|
||||
|
||||
### 3.2 Non-Goals
|
||||
|
||||
- Interrupted sync recovery (2.5) — depends on persistent state work
|
||||
- Disk space monitoring daemon (periodic `statvfs`) — Phase C
|
||||
- Connection pooling, config reload, watchdog — Phase C/D
|
||||
- Passthrough mode when cache dies — Phase F
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Design
|
||||
|
||||
### 4.1 Implementation Order
|
||||
|
||||
```
|
||||
4.2 CAS size pre-check (no deps, simplest fix)
|
||||
↓
|
||||
4.3 SQLite integrity check (no deps)
|
||||
↓
|
||||
4.4 tantivy corruption recovery (no deps)
|
||||
↓
|
||||
4.5 sled corruption recovery (no deps)
|
||||
↓
|
||||
4.6 Graceful shutdown orchestration (depends on: Phase A signal handler)
|
||||
↓
|
||||
4.7 Task supervisor (depends on: 4.6 CancellationToken)
|
||||
```
|
||||
|
||||
### 4.2 Issue 2.8: CAS Size Pre-Check
|
||||
|
||||
**Problem**: `CasStore::put()` writes data without checking if it would exceed `max_size`. The existing test `test_cas_put_handles_enospc` creates a store with `max_size: 100` and writes 1000 bytes — currently succeeds when it should fail.
|
||||
|
||||
#### Step 1: Stubs — none needed
|
||||
|
||||
#### Step 2: RED test — already exists
|
||||
|
||||
```rust
|
||||
// Currently FAILS — this is what we need to fix
|
||||
#[tokio::test]
|
||||
async fn test_cas_put_handles_enospc() {
|
||||
let store = CasStore::open(CasConfig { max_size: 100, ... }).await.unwrap();
|
||||
let large_data = vec![0u8; 1000];
|
||||
let result = store.put(&large_data).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cas/src/store.rs` — add size check at top of `put()`:
|
||||
|
||||
```rust
|
||||
pub async fn put(&self, data: &[u8]) -> Result<ChunkHash, CasError> {
|
||||
let hash = ChunkHash::from_bytes(data);
|
||||
let path = self.chunk_path(&hash);
|
||||
|
||||
if path.exists() {
|
||||
trace!(hash = %hash, size_bytes = data.len(), "dedup hit");
|
||||
return Ok(hash);
|
||||
}
|
||||
|
||||
// NEW: Pre-check size limit
|
||||
if self.config.max_size > 0 {
|
||||
let new_size = self.current_size.load(Ordering::SeqCst) + data.len() as u64;
|
||||
if new_size > self.config.max_size {
|
||||
warn!(
|
||||
current_size = self.current_size.load(Ordering::SeqCst),
|
||||
chunk_size = data.len(),
|
||||
max_size = self.config.max_size,
|
||||
"CAS store full, rejecting write"
|
||||
);
|
||||
return Err(CasError::StoreFull {
|
||||
current: self.current_size.load(Ordering::SeqCst),
|
||||
max: self.config.max_size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of put() unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Also add new error variant:
|
||||
|
||||
```rust
|
||||
pub enum CasError {
|
||||
// ... existing variants
|
||||
#[error("Store full: {current} / {max} bytes")]
|
||||
StoreFull { current: u64, max: u64 },
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_cas_put_handles_enospc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Issue 2.4 (part 1): SQLite Integrity Check
|
||||
|
||||
**Problem**: `Database::open()` runs schema but no integrity check. After crash, corrupt pages serve bad data silently.
|
||||
|
||||
#### Step 1: Stubs
|
||||
|
||||
Add to `musicfs-cache/src/db.rs`:
|
||||
|
||||
```rust
|
||||
pub fn open_with_integrity_check(path: &Path) -> Result<Self> {
|
||||
todo!()
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: RED test — already exists as `todo!()`
|
||||
|
||||
Replace the `todo!()` with a real test:
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_sqlite_integrity_check_detects_corruption() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
|
||||
// Create valid DB with data
|
||||
{
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.upsert_file(
|
||||
&OriginId::from("test"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Test.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Corrupt the file
|
||||
let mut data = std::fs::read(&db_path).unwrap();
|
||||
let mid = data.len() / 2;
|
||||
data[mid..mid+100].fill(0xFF);
|
||||
std::fs::write(&db_path, &data).unwrap();
|
||||
|
||||
// open_with_integrity_check should detect corruption
|
||||
let result = Database::open_with_integrity_check(&db_path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cache/src/db.rs`
|
||||
|
||||
```rust
|
||||
pub fn open_with_integrity_check(path: &Path) -> Result<Self> {
|
||||
debug!(?path, "Opening database with integrity check");
|
||||
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| Error::Database(format!("open failed: {}", e)))?;
|
||||
|
||||
// Quick integrity check — verifies page-level consistency
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check(1)", [], |row| row.get(0))
|
||||
.map_err(|e| Error::Database(format!("integrity check failed: {}", e)))?;
|
||||
|
||||
if integrity != "ok" {
|
||||
warn!(path = ?path, result = %integrity, "Database integrity check failed");
|
||||
return Err(Error::DatabaseCorrupted(format!(
|
||||
"integrity check failed: {}", integrity
|
||||
)));
|
||||
}
|
||||
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| Error::Database(format!("schema init failed: {}", e)))?;
|
||||
|
||||
let db = Self { conn: Arc::new(Mutex::new(conn)) };
|
||||
let count = db.file_count().unwrap_or(0);
|
||||
info!(path = ?path, file_count = count, "Database opened (integrity verified)");
|
||||
Ok(db)
|
||||
}
|
||||
```
|
||||
|
||||
Also add the error variant to `musicfs-core/src/error.rs`:
|
||||
|
||||
```rust
|
||||
pub enum Error {
|
||||
// ... existing
|
||||
#[error("Database corrupted: {0}")]
|
||||
DatabaseCorrupted(String),
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_sqlite_integrity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Issue 2.4 (part 2): tantivy Corruption Recovery
|
||||
|
||||
**Problem**: If tantivy `meta.json` or segment files are corrupted, `Index::open_in_dir()` panics or returns an error. No recovery path — daemon crashes.
|
||||
|
||||
#### Step 1: Stubs
|
||||
|
||||
Add to `musicfs-search/src/index.rs`:
|
||||
|
||||
```rust
|
||||
pub fn open_with_recovery(index_path: &Path) -> Result<Self, SearchError> {
|
||||
todo!()
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: RED test — replace `todo!()` with real test
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_tantivy_corruption_triggers_rebuild() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let index_path = dir.path().join("search_idx");
|
||||
|
||||
// Create valid index with data
|
||||
{
|
||||
let index = SearchIndex::open(&index_path).unwrap();
|
||||
index.index_file(&make_file_meta(1, "/a.flac", 1000)).unwrap();
|
||||
index.commit().unwrap();
|
||||
}
|
||||
|
||||
// Corrupt meta.json
|
||||
std::fs::write(index_path.join("meta.json"), b"corrupted").unwrap();
|
||||
|
||||
// open_with_recovery should detect corruption and rebuild empty
|
||||
let index = SearchIndex::open_with_recovery(&index_path).unwrap();
|
||||
let results = index.search("a", 10).unwrap();
|
||||
assert_eq!(results.len(), 0); // Rebuilt empty but functional
|
||||
}
|
||||
```
|
||||
|
||||
Also replace the tantivy crash test `todo!()`:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_tantivy_survives_uncommitted_crash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let index_path = dir.path().join("search_idx");
|
||||
|
||||
{
|
||||
let index = SearchIndex::open(&index_path).unwrap();
|
||||
index.index_file(&make_file_meta(1, "/a.flac", 1000)).unwrap();
|
||||
index.commit().unwrap();
|
||||
// Write without commit, then "crash" (drop without commit)
|
||||
index.index_file(&make_file_meta(2, "/b.flac", 1000)).unwrap();
|
||||
// mem::forget would leak, just drop naturally
|
||||
}
|
||||
|
||||
let index = SearchIndex::open(&index_path).unwrap();
|
||||
let results = index.search("a", 10).unwrap();
|
||||
assert_eq!(results.len(), 1); // Committed doc survives
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-search/src/index.rs`
|
||||
|
||||
```rust
|
||||
pub fn open_with_recovery(index_path: &Path) -> Result<Self, SearchError> {
|
||||
match Self::open(index_path) {
|
||||
Ok(index) => {
|
||||
// Verify index is functional with a simple search
|
||||
match index.reader.searcher().num_docs() {
|
||||
docs => {
|
||||
info!(docs, "Search index opened successfully");
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
error = %e,
|
||||
path = ?index_path,
|
||||
"Search index corrupted, rebuilding from scratch"
|
||||
);
|
||||
// Delete corrupted index
|
||||
if index_path.exists() {
|
||||
std::fs::remove_dir_all(index_path)
|
||||
.map_err(|e| SearchError::Io(e))?;
|
||||
}
|
||||
// Create fresh index
|
||||
Self::open(index_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_tantivy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Issue 3.5: sled Corruption Recovery
|
||||
|
||||
**Problem**: `sled::open()` on a corrupted DB returns `sled::Error::Corruption` which propagates as `CasError::Sled` and crashes the daemon on startup.
|
||||
|
||||
#### Step 1: Stubs — none needed, modify existing `open()`
|
||||
|
||||
#### Step 2: RED test — replace `todo!()`
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_sled_corruption_triggers_repair() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let chunks_dir = dir.path().join("chunks");
|
||||
let config = CasConfig { chunks_dir: chunks_dir.clone(), max_size: 10_000_000, shard_levels: 2 };
|
||||
|
||||
// Create valid store with data
|
||||
{
|
||||
let store = CasStore::open(config.clone()).await.unwrap();
|
||||
store.put(b"test data").await.unwrap();
|
||||
}
|
||||
|
||||
// Corrupt sled index files
|
||||
let sled_dir = chunks_dir.join("index.sled");
|
||||
if sled_dir.exists() {
|
||||
for entry in std::fs::read_dir(&sled_dir).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if entry.metadata().unwrap().is_file() {
|
||||
std::fs::write(entry.path(), b"corrupted").unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-open should recover (repair or recreate)
|
||||
let result = CasStore::open(config).await;
|
||||
assert!(result.is_ok(), "sled should recover from corruption");
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cas/src/store.rs` — modify `open()`:
|
||||
|
||||
```rust
|
||||
pub async fn open(config: CasConfig) -> Result<Self, CasError> {
|
||||
fs::create_dir_all(&config.chunks_dir).await?;
|
||||
|
||||
let index_path = config.chunks_dir.join("index.sled");
|
||||
let index = match sled::open(&index_path) {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
warn!(error = %e, path = ?index_path, "sled index corrupted, attempting recovery");
|
||||
|
||||
// Try repair
|
||||
match sled::Config::new().path(&index_path).repair(true).open() {
|
||||
Ok(db) => {
|
||||
info!("sled index repaired successfully");
|
||||
db
|
||||
}
|
||||
Err(repair_err) => {
|
||||
warn!(error = %repair_err, "sled repair failed, recreating index");
|
||||
// Delete and recreate
|
||||
if index_path.exists() {
|
||||
std::fs::remove_dir_all(&index_path)
|
||||
.map_err(|e| CasError::Io(e))?;
|
||||
}
|
||||
sled::open(&index_path)?
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let current_size = Self::calculate_size(&config.chunks_dir).await;
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
index,
|
||||
current_size: AtomicU64::new(current_size),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_sled_corruption
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Issue 2.3: Graceful Shutdown Orchestration
|
||||
|
||||
**Problem**: On signal, `drop(session)` unmounts FUSE, then `drop(runtime)` kills all tokio tasks abruptly. No tantivy flush, no SQLite WAL checkpoint, no ordered task shutdown.
|
||||
|
||||
**Approach**: `CancellationToken` from `tokio_util` propagated to all background tasks. Signal triggers token cancellation, then ordered shutdown.
|
||||
|
||||
#### Step 1: Add dependency
|
||||
|
||||
```toml
|
||||
# musicfs-cli/Cargo.toml
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
```
|
||||
|
||||
#### Step 2: Tests
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_shutdown_cancels_background_tasks() {
|
||||
let token = CancellationToken::new();
|
||||
let stopped = Arc::new(AtomicBool::new(false));
|
||||
let stopped_clone = stopped.clone();
|
||||
let token_clone = token.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
token_clone.cancelled().await;
|
||||
stopped_clone.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
assert!(!stopped.load(Ordering::SeqCst));
|
||||
token.cancel();
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(stopped.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shutdown_flushes_tantivy() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let index = SearchIndex::open(dir.path().join("idx")).unwrap();
|
||||
|
||||
index.index_file(&make_file_meta(1, "/a.flac", 1000)).unwrap();
|
||||
// Graceful shutdown should commit
|
||||
index.commit().unwrap();
|
||||
|
||||
let index2 = SearchIndex::open(dir.path().join("idx")).unwrap();
|
||||
assert_eq!(index2.search("a", 10).unwrap().len(), 1);
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs` — restructure the signal loop:
|
||||
|
||||
The current code:
|
||||
```rust
|
||||
// Wait for signal
|
||||
runtime.block_on(async { ... signal select ... })?;
|
||||
// Drop session, exit
|
||||
```
|
||||
|
||||
Change to:
|
||||
```rust
|
||||
let shutdown_token = CancellationToken::new();
|
||||
|
||||
// TODO: Pass token to health monitor, watcher, indexer, prefetcher
|
||||
// (requires their start() methods to accept CancellationToken)
|
||||
// For now, we just use it for the shutdown sequence
|
||||
|
||||
runtime.block_on(async {
|
||||
// ... signal select ...
|
||||
|
||||
// Ordered shutdown
|
||||
info!("Beginning ordered shutdown");
|
||||
shutdown_token.cancel();
|
||||
|
||||
// Wait briefly for tasks to notice cancellation
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Flush search index if available
|
||||
// (requires SearchIndex to be accessible — currently not wired in main.rs)
|
||||
|
||||
info!("Background tasks stopped");
|
||||
})?;
|
||||
```
|
||||
|
||||
**Note**: Full CancellationToken propagation through health monitor, watcher, indexer, and prefetcher `start()` methods requires changing their signatures. The current `mpsc::channel<()>` stop mechanism in each task should be replaced with or supplemented by the token. This can be done incrementally — start by adding the token to `run_mount()`, then wire it into each task as they're touched.
|
||||
|
||||
For this phase, the minimum viable change is:
|
||||
1. Create the token in `run_mount()`
|
||||
2. Cancel it on signal
|
||||
3. Add a brief sleep for tasks to notice
|
||||
4. The existing `drop(session)` and runtime drop handle cleanup
|
||||
|
||||
Full per-task CancellationToken wiring is tracked as follow-up work.
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Issue 2.6: Task Supervisor
|
||||
|
||||
**Problem**: 13 `tokio::spawn()` calls with no `JoinHandle` stored. Dead tasks go unnoticed.
|
||||
|
||||
**Approach**: New `TaskSupervisor` struct in `musicfs-core` that stores handles, checks liveness, and restarts critical tasks.
|
||||
|
||||
#### Step 1: Stubs
|
||||
|
||||
**File**: `musicfs-core/src/supervisor.rs` (new file)
|
||||
|
||||
```rust
|
||||
pub struct TaskSupervisor { ... }
|
||||
|
||||
pub enum TaskStatus {
|
||||
Running,
|
||||
Failed { error: String, at: Instant },
|
||||
Restarting { attempt: u32 },
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl TaskSupervisor {
|
||||
pub fn new() -> Self;
|
||||
pub fn spawn_supervised(&self, name: &str, future: impl Future) -> ();
|
||||
pub fn spawn_critical(&self, name: &str, factory: impl Fn() -> impl Future) -> ();
|
||||
pub fn task_status(&self, name: &str) -> TaskStatus;
|
||||
pub fn check_all(&self) -> Vec<(String, TaskStatus)>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: Tests
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_supervisor_detects_task_completion() {
|
||||
let supervisor = TaskSupervisor::new();
|
||||
supervisor.spawn_supervised("fast", async { /* returns immediately */ });
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
// Task completed normally — should be Stopped, not Failed
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_supervisor_detects_panic() {
|
||||
let supervisor = TaskSupervisor::new();
|
||||
supervisor.spawn_supervised("panicker", async {
|
||||
panic!("boom");
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
assert!(matches!(supervisor.task_status("panicker"), TaskStatus::Failed { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_supervisor_restarts_critical_task() {
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = count.clone();
|
||||
|
||||
let supervisor = TaskSupervisor::new();
|
||||
supervisor.spawn_critical("restartable", move || {
|
||||
let c = c.clone();
|
||||
async move {
|
||||
let n = c.fetch_add(1, Ordering::SeqCst);
|
||||
if n == 0 { panic!("first run fails"); }
|
||||
// Second run: stay alive
|
||||
loop { tokio::time::sleep(Duration::from_secs(60)).await; }
|
||||
}
|
||||
});
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
assert_eq!(count.load(Ordering::SeqCst), 2);
|
||||
assert!(matches!(supervisor.task_status("restartable"), TaskStatus::Running));
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-core/src/supervisor.rs`
|
||||
|
||||
```rust
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
pub struct TaskSupervisor {
|
||||
tasks: Arc<RwLock<HashMap<String, TaskEntry>>>,
|
||||
}
|
||||
|
||||
struct TaskEntry {
|
||||
handle: JoinHandle<()>,
|
||||
status: TaskStatus,
|
||||
restart_count: u32,
|
||||
last_restart: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TaskStatus {
|
||||
Running,
|
||||
Failed { error: String, at: Instant },
|
||||
Restarting { attempt: u32 },
|
||||
Stopped,
|
||||
}
|
||||
|
||||
impl TaskSupervisor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tasks: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_supervised<F>(&self, name: &str, future: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let tasks = self.tasks.clone();
|
||||
let name_owned = name.to_string();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
future.await;
|
||||
});
|
||||
|
||||
// Monitor the handle
|
||||
let tasks_monitor = self.tasks.clone();
|
||||
let name_monitor = name.to_string();
|
||||
let monitor_handle = handle;
|
||||
|
||||
self.tasks.write().insert(
|
||||
name_owned,
|
||||
TaskEntry {
|
||||
handle: monitor_handle,
|
||||
status: TaskStatus::Running,
|
||||
restart_count: 0,
|
||||
last_restart: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn task_status(&self, name: &str) -> TaskStatus {
|
||||
let mut tasks = self.tasks.write();
|
||||
if let Some(entry) = tasks.get_mut(name) {
|
||||
if entry.handle.is_finished() {
|
||||
entry.status = TaskStatus::Failed {
|
||||
error: "Task exited".into(),
|
||||
at: Instant::now(),
|
||||
};
|
||||
}
|
||||
entry.status.clone()
|
||||
} else {
|
||||
TaskStatus::Stopped
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The full `spawn_critical` with automatic restart requires a task factory (`Fn() -> Future`) pattern. The supervisor spawns a monitor task that awaits the `JoinHandle`, and on failure, calls the factory again with exponential backoff (1s→5s→30s, max 5 restarts). This is the most complex piece — the detailed implementation is in the test code above.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1 Security & Privacy
|
||||
|
||||
- `PRAGMA integrity_check` is read-only — no risk to data
|
||||
- sled repair may lose recently-written entries — acceptable for a cache
|
||||
- tantivy rebuild deletes index entirely — no sensitive data exposure (metadata only)
|
||||
|
||||
### 5.2 Observability
|
||||
|
||||
- SQLite integrity check result logged at INFO (ok) or WARN (failed)
|
||||
- sled repair attempts logged at WARN
|
||||
- tantivy rebuild logged at WARN with file count before/after
|
||||
- CAS `StoreFull` error logged at WARN with current/max sizes
|
||||
- Task supervisor logs all state transitions (started, failed, restarting, stopped)
|
||||
|
||||
### 5.3 Testing
|
||||
|
||||
| Test | Status Before | Status After | Issue |
|
||||
|------|---------------|--------------|-------|
|
||||
| `test_cas_put_handles_enospc` | ❌ FAILED | ✅ GREEN | 2.8 |
|
||||
| `test_sqlite_integrity_check_detects_corruption` | ❌ todo!() | ✅ GREEN | 2.4 |
|
||||
| `test_tantivy_corruption_triggers_rebuild` | ❌ todo!() | ✅ GREEN | 2.4 |
|
||||
| `test_tantivy_survives_uncommitted_crash` | ❌ todo!() | ✅ GREEN | 5.2 |
|
||||
| `test_sled_corruption_triggers_repair` | ❌ todo!() | ✅ GREEN | 3.5 |
|
||||
| `test_shutdown_cancels_background_tasks` | NEW | ✅ GREEN | 2.3 |
|
||||
| `test_shutdown_flushes_tantivy` | NEW | ✅ GREEN | 2.3 |
|
||||
| `test_supervisor_detects_panic` | NEW | ✅ GREEN | 2.6 |
|
||||
| `test_supervisor_restarts_critical_task` | NEW | ✅ GREEN | 2.6 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 6.1 Full `PRAGMA integrity_check` vs Quick Check
|
||||
|
||||
`PRAGMA integrity_check` scans every page — slow for large DBs (seconds for 1M rows). `PRAGMA integrity_check(1)` stops after the first error — fast enough for startup. We use the quick variant.
|
||||
|
||||
### 6.2 tantivy Repair vs Rebuild
|
||||
|
||||
tantivy has no built-in repair. If `meta.json` is corrupt or segments are missing, the only option is delete + recreate. This is acceptable because the search index can be rebuilt from SQLite metadata (once persistent state is wired up). For now, rebuild produces an empty index.
|
||||
|
||||
### 6.3 sled Repair vs Recreate
|
||||
|
||||
sled has `Config::repair(true)` which attempts to recover. If repair fails, we delete and recreate. After recreation, the index is empty but chunk files still exist on disk — a future reconciliation pass can rebuild the index from chunk files (Phase F).
|
||||
|
||||
### 6.4 Custom Supervisor vs `tokio-graceful` Crate
|
||||
|
||||
`tokio-graceful` provides shutdown coordination but not task restart. Our needs are specific (restart with backoff, status reporting, critical vs non-critical distinction). A custom `TaskSupervisor` is simpler and avoids a dependency for ~100 lines of code.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### 7.1 Task Sequence
|
||||
|
||||
| Day | Task | Issue | Effort | Test |
|
||||
|-----|------|-------|--------|------|
|
||||
| 1 (morning) | CAS size pre-check + `StoreFull` error variant | 2.8 | 1h | `test_cas_put_handles_enospc` → GREEN |
|
||||
| 1 (afternoon) | SQLite `open_with_integrity_check` + `DatabaseCorrupted` error | 2.4 | 2h | `test_sqlite_integrity_check` → GREEN |
|
||||
| 2 (morning) | tantivy `open_with_recovery` (detect + delete + recreate) | 2.4 | 2h | `test_tantivy_corruption` + `test_tantivy_survives_uncommitted_crash` → GREEN |
|
||||
| 2 (afternoon) | sled recovery in `CasStore::open` (repair + fallback recreate) | 3.5 | 2h | `test_sled_corruption` → GREEN |
|
||||
| 3 | Graceful shutdown with CancellationToken | 2.3 | 4h | `test_shutdown_cancels_background_tasks`, `test_shutdown_flushes_tantivy` → GREEN |
|
||||
| 4 | Task supervisor implementation | 2.6 | 4h | `test_supervisor_detects_panic`, `test_supervisor_restarts` → GREEN |
|
||||
| 5 | Integration + regression testing | — | 4h | Full `cargo test`, verify no regressions |
|
||||
|
||||
### 7.2 Verification Checklist
|
||||
|
||||
After all tasks:
|
||||
|
||||
- [ ] `cargo check` — zero errors, zero warnings
|
||||
- [ ] `cargo test --workspace --exclude musicfs-grpc` — all tests pass (exclude pre-existing grpc issue)
|
||||
- [ ] `cargo test -p musicfs-test-utils --test resilience` — 5 previously-RED tests now GREEN
|
||||
- [ ] `cargo clippy` — no new warnings
|
||||
- [ ] Remaining RED tests are only for Phases C-F (health timeout, parallel checks, fd exhaustion, chunk auto-repair, passthrough mode)
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Changed
|
||||
|
||||
| File | Change | Issue |
|
||||
|------|--------|-------|
|
||||
| `musicfs-cas/src/store.rs` | Size pre-check in `put()`, `StoreFull` error, sled recovery in `open()` | 2.8, 3.5 |
|
||||
| `musicfs-cache/src/db.rs` | `open_with_integrity_check()` with `PRAGMA integrity_check(1)` | 2.4 |
|
||||
| `musicfs-core/src/error.rs` | Add `DatabaseCorrupted(String)` variant | 2.4 |
|
||||
| `musicfs-search/src/index.rs` | `open_with_recovery()` — detect, delete, recreate | 2.4 |
|
||||
| `musicfs-core/src/supervisor.rs` | NEW — `TaskSupervisor`, `TaskStatus`, spawn/monitor/restart | 2.6 |
|
||||
| `musicfs-core/src/lib.rs` | Re-export supervisor module | 2.6 |
|
||||
| `musicfs-cli/src/main.rs` | CancellationToken creation, ordered shutdown sequence | 2.3 |
|
||||
| `musicfs-cli/Cargo.toml` | Add `tokio-util` dependency | 2.3 |
|
||||
| `musicfs-test-utils/tests/resilience.rs` | Replace `todo!()` stubs with real tests, add supervisor tests | all |
|
||||
|
||||
---
|
||||
|
||||
## 9. Glossary / References
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **CancellationToken** | `tokio_util::sync::CancellationToken` — cooperative cancellation signal for async tasks |
|
||||
| **PRAGMA integrity_check** | SQLite command that verifies page-level data consistency |
|
||||
| **sled repair** | sled's built-in recovery mode that attempts to reconstruct a corrupted database |
|
||||
| **TaskSupervisor** | New struct that monitors `JoinHandle`s and restarts failed tasks with backoff |
|
||||
| **StoreFull** | New `CasError` variant returned when a write would exceed `max_size` |
|
||||
|
||||
| Document | Path |
|
||||
|----------|------|
|
||||
| Phase A plan | [phase-a-stop-dying.md](phase-a-stop-dying.md) |
|
||||
| Resilience audit | [resilience-fault-tolerance.md](resilience-fault-tolerance.md) |
|
||||
| Resilience testing | [resilience-testing.md](resilience-testing.md) |
|
||||
| Persistent state | [persistent-state.md](persistent-state.md) |
|
||||
@@ -1,598 +0,0 @@
|
||||
# Phase C: Production Hardening — Implementation Plan
|
||||
|
||||
**Authors:** AI-assisted
|
||||
**Status:** Draft
|
||||
**Last Updated:** 2026-05-13
|
||||
**Reviewers:** TBD
|
||||
**Approvers:** TBD
|
||||
**Prerequisites:** [phase-b-crash-recovery.md](phase-b-crash-recovery.md) (completed), [resilience-fault-tolerance.md](resilience-fault-tolerance.md)
|
||||
**Estimated Effort:** ~4 days
|
||||
|
||||
---
|
||||
|
||||
[TOC]
|
||||
|
||||
---
|
||||
|
||||
## 1. Abstract
|
||||
|
||||
Phase C merges the practical items from Phases C and D of the resilience audit into a single implementation pass. It fixes the remaining 6 RED tests and addresses production-critical issues: health check hangs that block all origin monitoring, unbounded FUSE reads that can freeze the filesystem, broken CAS size accounting that disables eviction, and concurrent mount protection.
|
||||
|
||||
**Deferred items** (depend on unimplemented features or low urgency): interrupted sync recovery (needs persistent state), SIGHUP config reload, connection pooling (S3/SFTP are stubs), event bus backpressure, FUSE session recovery, offline mode state machine, DNS failure handling, stale-data awareness.
|
||||
|
||||
**RED tests to turn GREEN:**
|
||||
- `test_local_origin_health_check_has_timeout` (D1)
|
||||
- `test_health_checks_run_in_parallel` (D2)
|
||||
- `test_fd_exhaustion_handling` (E — 5.3)
|
||||
- `test_corrupt_chunk_auto_refetched` (F — 6.4)
|
||||
- `test_missing_chunk_triggers_origin_fetch` (F — 6.4)
|
||||
- `test_passthrough_mode_when_cache_disk_dead` (F — 6.6)
|
||||
|
||||
---
|
||||
|
||||
## 2. Background
|
||||
|
||||
After Phase A+B, the daemon survives signals, recovers from storage corruption on startup, supervises background tasks, and rejects oversized CAS writes. But:
|
||||
|
||||
1. **Health checks hang on dead origins** — `check_one()` calls `origin.health().await` with no timeout. A dead NAS (local origin pointing to network mount) blocks health monitoring for ALL origins because checks run sequentially.
|
||||
|
||||
2. **FUSE reads have no timeout** — `reader.read()` in the FUSE `read()` callback has no timeout. A slow or hung origin blocks the FUSE thread indefinitely.
|
||||
|
||||
3. **CAS size tracking is broken** — `calculate_size()` only scans top-level of `chunks_dir`, missing all chunks in shard subdirectories (`aa/bb/<hash>`). `current_size` is always ~0, eviction never triggers.
|
||||
|
||||
4. **Corrupt chunks return EIO** — when `verify_integrity()` detects a bad chunk, it returns `CasError::IntegrityError`. The reader propagates this as EIO to FUSE. It should auto-re-fetch from origin instead.
|
||||
|
||||
5. **No concurrent mount protection** — two `musicfs mount` commands can run simultaneously, corrupting SQLite and sled.
|
||||
|
||||
6. **fd exhaustion is unhandled** — no graceful behavior when file descriptors run out.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals & Non-Goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
- Health checks complete within 5 seconds regardless of origin responsiveness
|
||||
- Health checks run in parallel (3 origins checked in ~5s, not ~15s)
|
||||
- FUSE reads timeout after 30 seconds (returns EIO, doesn't hang)
|
||||
- CAS size accounting is correct (recursive shard scan)
|
||||
- Corrupt/missing chunks are auto-re-fetched from origin transparently
|
||||
- PID file prevents concurrent mounts
|
||||
- fd exhaustion produces clean errors, not panics
|
||||
- All 6 remaining RED tests turn GREEN
|
||||
|
||||
### 3.2 Non-Goals
|
||||
|
||||
- Interrupted sync recovery (C1) — blocked on persistent state
|
||||
- systemd watchdog (C3) — useful but not critical yet
|
||||
- SIGHUP config reload (C4) — nice-to-have
|
||||
- Connection pooling (C5) — S3/SFTP origins are stubs
|
||||
- Event bus backpressure (C8) — low urgency
|
||||
- FUSE session recovery (C10) — complex edge case
|
||||
- Offline mode state machine (D3) — needs broader design
|
||||
- DNS failure handling (D5) — depends on C5
|
||||
- Stale-data awareness (D6) — low severity for music FS
|
||||
|
||||
---
|
||||
|
||||
## 4. Proposed Design
|
||||
|
||||
### 4.1 Implementation Order
|
||||
|
||||
```
|
||||
4.2 Health check timeout + parallel checks (2 RED tests, independent)
|
||||
↓
|
||||
4.3 Fix CAS calculate_size() (independent, unblocks eviction)
|
||||
↓
|
||||
4.4 FUSE read timeout (independent)
|
||||
↓
|
||||
4.5 CAS chunk auto-re-fetch on corruption (2 RED tests)
|
||||
↓
|
||||
4.6 PID file / flock (independent)
|
||||
↓
|
||||
4.7 fd exhaustion handling (1 RED test)
|
||||
```
|
||||
|
||||
### 4.2 Issues D1+D2: Health Check Timeout + Parallel Checks
|
||||
|
||||
**Problem**: `check_one()` awaits `origin.health()` with no timeout. `check_all()` iterates sequentially. One hung origin blocks everything.
|
||||
|
||||
#### Step 1: No stubs needed
|
||||
|
||||
#### Step 2: RED tests already exist
|
||||
|
||||
`test_local_origin_health_check_has_timeout` — FaultyOrigin with `TimeoutMs(5000)`, asserts check completes in <2s.
|
||||
|
||||
`test_health_checks_run_in_parallel` — 3 origins each with `TimeoutMs(200)`, asserts `check_all()` completes in <350ms (parallel), not ~600ms (sequential).
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-origins/src/health.rs`
|
||||
|
||||
Wrap `origin.health()` in `check_one()` with timeout:
|
||||
|
||||
```rust
|
||||
async fn check_one(&self, id: &OriginId, origin: &Arc<dyn Origin>) {
|
||||
let start = Instant::now();
|
||||
let health_timeout = Duration::from_secs(5);
|
||||
|
||||
let status = match tokio::time::timeout(health_timeout, origin.health()).await {
|
||||
Ok(status) => status,
|
||||
Err(_) => {
|
||||
warn!(origin_id = %id, timeout_ms = health_timeout.as_millis() as u64,
|
||||
"Health check timed out");
|
||||
HealthStatus::Unhealthy
|
||||
}
|
||||
};
|
||||
|
||||
let latency_ms = start.elapsed().as_millis() as u64;
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Change `check_all()` to use `futures::future::join_all`:
|
||||
|
||||
```rust
|
||||
pub async fn check_all(&self) {
|
||||
let origins: Vec<_> = self.origins.iter()
|
||||
.map(|e| (e.key().clone(), e.value().clone()))
|
||||
.collect();
|
||||
|
||||
let checks: Vec<_> = origins.iter()
|
||||
.map(|(id, origin)| self.check_one(id, origin))
|
||||
.collect();
|
||||
|
||||
futures::future::join_all(checks).await;
|
||||
}
|
||||
```
|
||||
|
||||
Add `futures` to `musicfs-origins/Cargo.toml` (or use `tokio::join!` macro if count is small/known).
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_local_origin_health_check
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_health_checks_run_in_parallel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Issue C6: Fix CAS calculate_size()
|
||||
|
||||
**Problem**: `calculate_size()` only scans direct children of `chunks_dir`. Chunks live in shard subdirectories (`chunks/aa/bb/<hash>`). Size is always ~0, eviction never triggers.
|
||||
|
||||
#### Step 1: No stubs needed
|
||||
|
||||
#### Step 2: Test
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_cas_size_tracking_is_correct() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let config = CasConfig { chunks_dir: dir.path().join("chunks"), max_size: 10_000_000, shard_levels: 2 };
|
||||
let store = CasStore::open(config).await.unwrap();
|
||||
|
||||
let data = vec![0u8; 1000];
|
||||
store.put(&data).await.unwrap();
|
||||
|
||||
// Size should reflect the chunk we just wrote (~1000 bytes)
|
||||
assert!(store.current_size() >= 1000, "current_size should track chunk data, got {}", store.current_size());
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cas/src/store.rs` — make `calculate_size` recursive:
|
||||
|
||||
```rust
|
||||
async fn calculate_size(dir: &Path) -> u64 {
|
||||
Self::calculate_size_recursive(dir).await
|
||||
}
|
||||
|
||||
#[async recursion::async_recursion]
|
||||
async fn calculate_size_recursive(dir: &Path) -> u64 {
|
||||
let mut size = 0u64;
|
||||
if let Ok(mut entries) = fs::read_dir(dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if let Ok(meta) = entry.metadata().await {
|
||||
if meta.is_file() {
|
||||
size += meta.len();
|
||||
} else if meta.is_dir() {
|
||||
// Skip sled index directory
|
||||
let name = entry.file_name();
|
||||
if name != "index.sled" {
|
||||
size += Self::calculate_size_recursive(&entry.path()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
size
|
||||
}
|
||||
```
|
||||
|
||||
Alternative without `async_recursion` (use `Box::pin`):
|
||||
|
||||
```rust
|
||||
fn calculate_size_recursive(dir: &Path) -> Pin<Box<dyn Future<Output = u64> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let mut size = 0u64;
|
||||
if let Ok(mut entries) = fs::read_dir(dir).await {
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if let Ok(meta) = entry.metadata().await {
|
||||
if meta.is_file() {
|
||||
size += meta.len();
|
||||
} else if meta.is_dir() {
|
||||
let name = entry.file_name();
|
||||
if name != "index.sled" {
|
||||
size += Self::calculate_size_recursive(&entry.path()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
size
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Issue C7: FUSE Read Timeout
|
||||
|
||||
**Problem**: FUSE `read()` calls `handle.block_on(reader.read(...))` with no timeout. A slow origin blocks the entire FUSE thread.
|
||||
|
||||
#### Step 1: No stubs needed
|
||||
|
||||
#### Step 2: Test
|
||||
|
||||
```rust
|
||||
#[tokio::test]
|
||||
async fn test_fuse_read_timeout_returns_eio() {
|
||||
// Uses FaultyOrigin with TimeoutMs(60_000) — simulates hung read
|
||||
// FUSE read should timeout at 30s and return EIO, not hang forever
|
||||
// (This test validates the timeout wrapper, not actual FUSE mount)
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-fuse/src/filesystem.rs` — wrap the read with timeout:
|
||||
|
||||
```rust
|
||||
fn read(&mut self, _req: &Request, ino: u64, _fh: u64, offset: i64, size: u32, _flags: i32, _lock_owner: Option<u64>, reply: ReplyData) {
|
||||
// ... file_id lookup unchanged ...
|
||||
|
||||
let reader = reader.clone();
|
||||
let handle = self.runtime_handle.clone();
|
||||
let result = std::thread::scope(|_| {
|
||||
handle.block_on(async {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
reader.read(file_id, offset as u64, size),
|
||||
).await
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(Ok(data)) => {
|
||||
trace!(ino, bytes_read = data.len(), "read successful");
|
||||
reply.data(&data);
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!(ino, error = %e, "read failed");
|
||||
reply.error(libc::EIO);
|
||||
}
|
||||
Err(_timeout) => {
|
||||
warn!(ino, offset, size, "read timed out after 30s");
|
||||
reply.error(libc::EIO);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Issues 6.4: CAS Chunk Auto-Re-Fetch on Corruption/Missing
|
||||
|
||||
**Problem**: When `store.get()` finds a corrupt or missing chunk, it returns an error. The reader propagates this as EIO to FUSE. It should try to re-fetch the chunk from the origin instead.
|
||||
|
||||
#### Step 1: No stubs needed — modify `FileReader::read()`
|
||||
|
||||
#### Step 2: RED tests already exist
|
||||
|
||||
`test_corrupt_chunk_auto_refetched` — corrupts chunk file on disk, expects read to succeed (re-fetched from origin).
|
||||
|
||||
`test_missing_chunk_triggers_origin_fetch` — deletes chunk file, expects read to succeed.
|
||||
|
||||
Both currently fail because the reader doesn't attempt re-fetch on chunk errors.
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cas/src/reader.rs` — add retry-with-refetch in the chunk read loop:
|
||||
|
||||
```rust
|
||||
pub async fn read(&self, file_id: FileId, offset: u64, size: u32) -> Result<Bytes, ReaderError> {
|
||||
let manifest = self.get_or_fetch_manifest(file_id).await?;
|
||||
|
||||
// ... offset/end calculation unchanged ...
|
||||
|
||||
for chunk_ref in &manifest.chunks {
|
||||
// ... range check unchanged ...
|
||||
|
||||
let chunk_data = match self.store.get(&chunk_ref.hash).await {
|
||||
Ok(data) => data,
|
||||
Err(CasError::IntegrityError { .. }) | Err(CasError::NotFound(_)) => {
|
||||
// Chunk is corrupt or missing — try to re-fetch from origin
|
||||
warn!(hash = %chunk_ref.hash, "Chunk corrupt/missing, attempting re-fetch");
|
||||
if let Some(fetcher) = &self.fetcher {
|
||||
// Re-fetch the entire file (will re-chunk and store)
|
||||
let new_manifest = fetcher.fetch_file(file_id).await?;
|
||||
// Update cached manifest
|
||||
self.manifests.write().insert(file_id, new_manifest);
|
||||
// Retry the get
|
||||
self.store.get(&chunk_ref.hash).await?
|
||||
} else {
|
||||
return Err(ReaderError::Cas(CasError::NotFound(chunk_ref.hash.as_hex())));
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(ReaderError::Cas(e)),
|
||||
};
|
||||
|
||||
// ... slice extraction unchanged ...
|
||||
}
|
||||
|
||||
Ok(result.freeze())
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The re-fetch downloads the entire file from origin and re-chunks it. For a single corrupt chunk this is wasteful (fetches all chunks to fix one), but it's the simplest correct approach. Chunk-level re-fetch would require the origin to support byte-range reads mapped to chunk boundaries — possible but complex. The file-level approach reuses existing `fetch_file()` logic.
|
||||
|
||||
#### Step 4: Verify
|
||||
|
||||
```bash
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_corrupt_chunk
|
||||
cargo test -p musicfs-test-utils --test resilience -- test_missing_chunk
|
||||
```
|
||||
|
||||
**Note on test updates**: The existing RED tests reference `store.chunk_path()` which is private. The tests will need to either:
|
||||
- Make `chunk_path()` pub(crate) or add a test helper
|
||||
- Or construct the path manually using the sharding logic
|
||||
|
||||
The tests also need a `ContentFetcher` with a real `LocalOrigin` to re-fetch from. The current tests create a CAS store but no fetcher — they need to be updated to include the full pipeline.
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Issue C9: PID File / flock
|
||||
|
||||
**Problem**: Two `musicfs mount` commands can run simultaneously, both writing to the same SQLite/sled files.
|
||||
|
||||
#### Step 1: No stubs needed
|
||||
|
||||
#### Step 2: Test
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn test_pid_file_prevents_concurrent_mount() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let lock_path = dir.path().join("musicfs.lock");
|
||||
|
||||
// First lock succeeds
|
||||
let lock1 = try_acquire_lock(&lock_path);
|
||||
assert!(lock1.is_ok());
|
||||
|
||||
// Second lock fails
|
||||
let lock2 = try_acquire_lock(&lock_path);
|
||||
assert!(lock2.is_err());
|
||||
|
||||
// Release first, second succeeds
|
||||
drop(lock1);
|
||||
let lock3 = try_acquire_lock(&lock_path);
|
||||
assert!(lock3.is_ok());
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
**File**: `musicfs-cli/src/main.rs`
|
||||
|
||||
```rust
|
||||
use std::fs::File;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
struct LockFile {
|
||||
_file: File,
|
||||
}
|
||||
|
||||
fn try_acquire_lock(path: &Path) -> Result<LockFile> {
|
||||
let file = File::create(path).context("Failed to create lock file")?;
|
||||
let fd = file.as_raw_fd();
|
||||
|
||||
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if ret != 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::WouldBlock {
|
||||
anyhow::bail!("MusicFS is already running (lock file: {:?})", path);
|
||||
}
|
||||
return Err(err).context("Failed to acquire lock");
|
||||
}
|
||||
|
||||
// Write PID for debugging
|
||||
use std::io::Write;
|
||||
let mut f = &file;
|
||||
writeln!(f, "{}", std::process::id())?;
|
||||
|
||||
Ok(LockFile { _file: file })
|
||||
}
|
||||
```
|
||||
|
||||
Call in `run_mount()` before mounting:
|
||||
|
||||
```rust
|
||||
let lock_path = cache_dir.join("musicfs.lock");
|
||||
let _lock = try_acquire_lock(&lock_path)
|
||||
.context("Failed to acquire lock — is another instance running?")?;
|
||||
```
|
||||
|
||||
Lock is released automatically when `_lock` is dropped (process exit or scope end).
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Issue 5.3: fd Exhaustion Handling
|
||||
|
||||
**Problem**: When fd limit is hit, operations fail with EMFILE. Currently this propagates as panics or unhelpful errors.
|
||||
|
||||
#### Step 1: Replace the `todo!()` test
|
||||
|
||||
#### Step 2: Test
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn test_fd_exhaustion_handling() {
|
||||
use rlimit::{Resource, setrlimit, getrlimit};
|
||||
|
||||
let (orig_soft, orig_hard) = getrlimit(Resource::NOFILE).unwrap();
|
||||
|
||||
// Set very low limit
|
||||
setrlimit(Resource::NOFILE, 64, 64).unwrap();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
let result = rt.block_on(async {
|
||||
CasStore::open(CasConfig {
|
||||
chunks_dir: dir.path().join("chunks"),
|
||||
max_size: 1_000_000,
|
||||
shard_levels: 2,
|
||||
}).await
|
||||
});
|
||||
|
||||
// Should either succeed (sled uses fewer than 64 fds) or fail gracefully
|
||||
// Must NOT panic
|
||||
match result {
|
||||
Ok(_store) => { /* lucky — enough fds */ }
|
||||
Err(e) => {
|
||||
// Error message should be meaningful
|
||||
let msg = format!("{}", e);
|
||||
assert!(!msg.contains("panic"), "Should not panic on fd exhaustion");
|
||||
}
|
||||
}
|
||||
|
||||
setrlimit(Resource::NOFILE, orig_soft, orig_hard).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: Implementation
|
||||
|
||||
This is primarily a **test** — verifying that existing code handles fd exhaustion without panicking. The fix is ensuring all I/O paths return `Result` rather than `.unwrap()` on file operations. Phase A's RwLock migration already removed the biggest panic source. The remaining `.unwrap()` calls are in test code only.
|
||||
|
||||
No production code change required if existing error paths handle I/O errors correctly. The test validates this.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1 Observability
|
||||
|
||||
- Health check timeout logged at WARN with origin_id and timeout duration
|
||||
- FUSE read timeout logged at WARN with inode, offset, size
|
||||
- CAS chunk re-fetch logged at WARN with chunk hash
|
||||
- PID file path logged at INFO on lock acquisition
|
||||
|
||||
### 5.2 Performance
|
||||
|
||||
- Health checks now parallel: O(1) wall-clock time instead of O(N) per check cycle
|
||||
- FUSE read timeout: 30s cap prevents indefinite hangs but doesn't improve happy-path latency
|
||||
- `calculate_size()` recursive scan: runs once at startup, negligible cost
|
||||
|
||||
### 5.3 Testing
|
||||
|
||||
| Test | Status Before | Status After | Issue |
|
||||
|------|---------------|--------------|-------|
|
||||
| `test_local_origin_health_check_has_timeout` | ❌ FAILED | ✅ GREEN | D1 |
|
||||
| `test_health_checks_run_in_parallel` | ❌ FAILED | ✅ GREEN | D2 |
|
||||
| `test_fd_exhaustion_handling` | ❌ todo!() | ✅ GREEN | 5.3 |
|
||||
| `test_corrupt_chunk_auto_refetched` | ❌ FAILED | ✅ GREEN | 6.4 |
|
||||
| `test_missing_chunk_triggers_origin_fetch` | ❌ FAILED | ✅ GREEN | 6.4 |
|
||||
| `test_passthrough_mode_when_cache_disk_dead` | ❌ todo!() | ✅ GREEN | 6.6 |
|
||||
| `test_cas_size_tracking_is_correct` | NEW | ✅ GREEN | C6 |
|
||||
| `test_pid_file_prevents_concurrent_mount` | NEW | ✅ GREEN | C9 |
|
||||
|
||||
**Note on passthrough mode** (6.6): The test expects reads to succeed when the cache dir is read-only. With chunk auto-re-fetch (4.5), this partially works — if the origin is alive and the chunk isn't in cache, the fetcher reads from origin. But the fetcher tries to _write_ the chunk to CAS, which will fail on a read-only cache dir. The implementation needs a fallback path: if CAS write fails after origin fetch, return the data anyway without caching. This makes `test_passthrough_mode_when_cache_disk_dead` pass.
|
||||
|
||||
---
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 6.1 Per-Origin Configurable Timeout vs Universal 5s
|
||||
|
||||
Could allow `health_check_timeout_ms` per origin config. Rejected for Phase C — universal 5s is correct for all current origin types. Can be made configurable later.
|
||||
|
||||
### 6.2 Chunk-Level Re-Fetch vs File-Level Re-Fetch
|
||||
|
||||
When one chunk is corrupt, we could re-fetch just that chunk's byte range from origin. Requires the origin to support byte-range reads and the system to know which byte range maps to which chunk. Complex. File-level re-fetch reuses existing `fetch_file()` and is correct, just slightly wasteful. Good enough for Phase C.
|
||||
|
||||
### 6.3 `advisory-lock` Crate vs Raw `flock`
|
||||
|
||||
The `advisory-lock` crate wraps flock nicely but adds a dependency for 10 lines of code. Raw `libc::flock` is simple enough and avoids the dependency.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Plan
|
||||
|
||||
### 7.1 Task Sequence
|
||||
|
||||
| Day | Task | Issue | Effort | Tests |
|
||||
|-----|------|-------|--------|-------|
|
||||
| 1 (morning) | Health check timeout in `check_one()` | D1 | 1h | `test_local_origin_health_check_has_timeout` → GREEN |
|
||||
| 1 (morning) | Parallel `check_all()` with `join_all` | D2 | 1h | `test_health_checks_run_in_parallel` → GREEN |
|
||||
| 1 (afternoon) | Fix `calculate_size()` recursion | C6 | 1h | `test_cas_size_tracking_is_correct` → GREEN |
|
||||
| 1 (afternoon) | FUSE read timeout wrapper | C7 | 1h | New timeout test |
|
||||
| 2 (morning) | CAS chunk auto-re-fetch on corruption/missing | 6.4 | 3h | `test_corrupt_chunk_auto_refetched` + `test_missing_chunk_triggers_origin_fetch` → GREEN |
|
||||
| 2 (afternoon) | Passthrough fallback (CAS write fails → return data anyway) | 6.6 | 1h | `test_passthrough_mode_when_cache_disk_dead` → GREEN |
|
||||
| 3 (morning) | PID file / flock | C9 | 1h | `test_pid_file_prevents_concurrent_mount` → GREEN |
|
||||
| 3 (morning) | fd exhaustion test | 5.3 | 1h | `test_fd_exhaustion_handling` → GREEN |
|
||||
| 3 (afternoon) | Integration + regression testing | — | 2h | Full `cargo test` |
|
||||
| 4 | Buffer | — | 4h | — |
|
||||
|
||||
### 7.2 Verification Checklist
|
||||
|
||||
After all tasks:
|
||||
|
||||
- [ ] `cargo check` — zero errors, zero warnings
|
||||
- [ ] `cargo test --workspace --exclude musicfs-grpc` — all pass
|
||||
- [ ] `cargo test -p musicfs-test-utils --test resilience` — **25 passed, 0 failed** (all RED tests GREEN)
|
||||
- [ ] `cargo clippy` — no new warnings
|
||||
|
||||
---
|
||||
|
||||
## 8. Files Changed
|
||||
|
||||
| File | Change | Issue |
|
||||
|------|--------|-------|
|
||||
| `musicfs-origins/src/health.rs` | Timeout in `check_one()`, `join_all` in `check_all()` | D1, D2 |
|
||||
| `musicfs-origins/Cargo.toml` | Add `futures` dependency (for `join_all`) | D2 |
|
||||
| `musicfs-cas/src/store.rs` | Recursive `calculate_size()`, skip `index.sled` dir | C6 |
|
||||
| `musicfs-fuse/src/filesystem.rs` | `tokio::time::timeout(30s)` around reader.read() | C7 |
|
||||
| `musicfs-cas/src/reader.rs` | Auto-re-fetch on `IntegrityError` / `NotFound` | 6.4 |
|
||||
| `musicfs-cas/src/fetcher.rs` | Possible: make `fetch_file` return data even if CAS write fails | 6.6 |
|
||||
| `musicfs-cli/src/main.rs` | PID file with flock, fd exhaustion handling | C9, 5.3 |
|
||||
| `musicfs-test-utils/tests/resilience.rs` | Replace remaining todo!()s, add new tests, update chunk tests with fetcher pipeline | all |
|
||||
|
||||
---
|
||||
|
||||
## 9. Glossary / References
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **join_all** | `futures::future::join_all` — runs multiple futures concurrently, waits for all |
|
||||
| **flock** | Advisory file locking syscall — `LOCK_EX | LOCK_NB` for exclusive non-blocking |
|
||||
| **EMFILE** | "Too many open files" errno — returned when process fd limit is reached |
|
||||
| **Passthrough mode** | When CAS is unavailable, read directly from origin without caching |
|
||||
|
||||
| Document | Path |
|
||||
|----------|------|
|
||||
| Phase A plan | [phase-a-stop-dying.md](phase-a-stop-dying.md) |
|
||||
| Phase B plan | [phase-b-crash-recovery.md](phase-b-crash-recovery.md) |
|
||||
| Resilience audit | [resilience-fault-tolerance.md](resilience-fault-tolerance.md) |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,771 +0,0 @@
|
||||
# Week 2: Metadata Extraction
|
||||
|
||||
**Phase**: 1 (MVP)
|
||||
**Prerequisites**: Week 1 (Foundation)
|
||||
**Estimated effort**: 5 days
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Implement audio metadata extraction using symphonia and create SQLite schema for metadata cache.
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Done |
|
||||
|------|-------|-------|------|
|
||||
| Audio parsing | musicfs-metadata | `lib.rs`, `parser.rs` | [ ] |
|
||||
| Format handlers | musicfs-metadata | `formats/*.rs` | [ ] |
|
||||
| SQLite schema | musicfs-cache | `schema.sql`, `db.rs` | [ ] |
|
||||
| Metadata cache | musicfs-cache | `metadata.rs` | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Extend AudioMeta in `musicfs-core`
|
||||
|
||||
Add `lyrics` and `composer` fields to `AudioMeta` struct (FR-6.4):
|
||||
|
||||
```rust
|
||||
// In musicfs-core/src/types.rs, add to AudioMeta:
|
||||
pub struct AudioMeta {
|
||||
// ... existing fields ...
|
||||
pub lyrics: Option<String>,
|
||||
pub composer: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Metadata Parser (`musicfs-metadata`)
|
||||
|
||||
### 1.1 Create `Cargo.toml`
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "musicfs-metadata"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
```
|
||||
|
||||
### 1.2 Create `src/lib.rs`
|
||||
|
||||
```rust
|
||||
mod parser;
|
||||
|
||||
pub use parser::MetadataParser;
|
||||
```
|
||||
|
||||
### 1.3 Create `src/parser.rs`
|
||||
|
||||
```rust
|
||||
use musicfs_core::{AudioFormat, AudioMeta, Result, Error};
|
||||
use std::io::{Read, Seek};
|
||||
use std::path::Path;
|
||||
use symphonia::core::codecs::CODEC_TYPE_NULL;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
use tracing::debug;
|
||||
|
||||
/// Metadata extraction using symphonia (FR-6.1-6.5)
|
||||
pub struct MetadataParser;
|
||||
|
||||
impl MetadataParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Extract metadata from audio file
|
||||
pub fn parse_file(&self, path: &Path) -> Result<AudioMeta> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let ext = path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
self.parse_reader(file, ext)
|
||||
}
|
||||
|
||||
/// Extract metadata from reader
|
||||
pub fn parse_reader<R: Read + Seek + Send + Sync + 'static>(
|
||||
&self,
|
||||
reader: R,
|
||||
extension: &str,
|
||||
) -> Result<AudioMeta> {
|
||||
let mss = MediaSourceStream::new(Box::new(reader), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
if !extension.is_empty() {
|
||||
hint.with_extension(extension);
|
||||
}
|
||||
|
||||
let format_opts = FormatOptions {
|
||||
enable_gapless: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let metadata_opts = MetadataOptions::default();
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &format_opts, &metadata_opts)
|
||||
.map_err(|e| Error::Cache(format!("Failed to probe format: {}", e)))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
let mut audio_meta = AudioMeta {
|
||||
format: AudioFormat::from_extension(extension),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Extract metadata from container
|
||||
if let Some(metadata) = format.metadata().current() {
|
||||
self.extract_tags(&mut audio_meta, metadata);
|
||||
}
|
||||
|
||||
// Also check probed metadata
|
||||
if let Some(metadata) = probed.metadata.current() {
|
||||
self.extract_tags(&mut audio_meta, metadata);
|
||||
}
|
||||
|
||||
// Get duration and codec info from track
|
||||
if let Some(track) = format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL) {
|
||||
let params = &track.codec_params;
|
||||
|
||||
if let Some(n_frames) = params.n_frames {
|
||||
if let Some(sample_rate) = params.sample_rate {
|
||||
audio_meta.duration_ms = Some((n_frames as u64 * 1000) / sample_rate as u64);
|
||||
audio_meta.sample_rate = Some(sample_rate);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bits_per_sample) = params.bits_per_sample {
|
||||
if let Some(sample_rate) = params.sample_rate {
|
||||
if let Some(channels) = params.channels {
|
||||
audio_meta.bitrate = Some(
|
||||
bits_per_sample * sample_rate * channels.count() as u32 / 1000
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Parsed metadata: {:?}", audio_meta);
|
||||
Ok(audio_meta)
|
||||
}
|
||||
|
||||
fn extract_tags(&self, meta: &mut AudioMeta, metadata: &symphonia::core::meta::MetadataRevision) {
|
||||
use symphonia::core::meta::StandardTagKey;
|
||||
|
||||
for tag in metadata.tags() {
|
||||
if let Some(std_key) = tag.std_key {
|
||||
let value = tag.value.to_string();
|
||||
match std_key {
|
||||
StandardTagKey::TrackTitle => meta.title = Some(value),
|
||||
StandardTagKey::Artist => meta.artist = Some(value),
|
||||
StandardTagKey::Album => meta.album = Some(value),
|
||||
StandardTagKey::AlbumArtist => meta.album_artist = Some(value),
|
||||
StandardTagKey::Genre => meta.genre = Some(value),
|
||||
StandardTagKey::TrackNumber => {
|
||||
meta.track = value.split('/').next()
|
||||
.and_then(|s| s.parse().ok());
|
||||
}
|
||||
StandardTagKey::DiscNumber => {
|
||||
meta.disc = value.split('/').next()
|
||||
.and_then(|s| s.parse().ok());
|
||||
}
|
||||
StandardTagKey::Date | StandardTagKey::ReleaseDate => {
|
||||
meta.year = value.chars().take(4).collect::<String>()
|
||||
.parse().ok();
|
||||
}
|
||||
StandardTagKey::Lyrics => {
|
||||
meta.lyrics = Some(value);
|
||||
}
|
||||
StandardTagKey::Composer => {
|
||||
meta.composer = Some(value);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MetadataParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Cache Database (`musicfs-cache`)
|
||||
|
||||
### 2.1 Create `Cargo.toml`
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "musicfs-cache"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
rusqlite = { workspace = true, features = ["bundled"] }
|
||||
sled.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
serde.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
```
|
||||
|
||||
### 2.2 Create `src/lib.rs`
|
||||
|
||||
```rust
|
||||
mod db;
|
||||
mod metadata;
|
||||
|
||||
pub use db::Database;
|
||||
pub use metadata::MetadataCache;
|
||||
```
|
||||
|
||||
### 2.3 Create `src/schema.sql`
|
||||
|
||||
```sql
|
||||
-- MusicFS Metadata Cache Schema
|
||||
-- Per architecture.md section 4.3.6
|
||||
-- NOTE: Chunk index stored in sled (chunks.sled/), NOT SQLite
|
||||
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
origin_id TEXT NOT NULL,
|
||||
real_path TEXT NOT NULL,
|
||||
virtual_path TEXT NOT NULL,
|
||||
|
||||
-- Audio metadata (FR-6.1-6.5)
|
||||
title TEXT,
|
||||
artist TEXT,
|
||||
album TEXT,
|
||||
album_artist TEXT,
|
||||
genre TEXT,
|
||||
year INTEGER,
|
||||
track INTEGER,
|
||||
disc INTEGER,
|
||||
duration_ms INTEGER,
|
||||
bitrate INTEGER,
|
||||
sample_rate INTEGER,
|
||||
format TEXT,
|
||||
|
||||
-- Sync state
|
||||
origin_mtime INTEGER NOT NULL,
|
||||
origin_size INTEGER NOT NULL,
|
||||
content_hash TEXT, -- hex-encoded xxHash64
|
||||
chunk_manifest BLOB, -- msgpack: [(chunk_hash, offset, size)]
|
||||
last_sync INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
|
||||
UNIQUE(origin_id, real_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artwork (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
art_type TEXT NOT NULL, -- 'front', 'back', 'disc'
|
||||
chunk_hash TEXT NOT NULL, -- hex-encoded reference to CAS
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
mime_type TEXT,
|
||||
UNIQUE(file_id, art_type)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
query_json TEXT NOT NULL, -- smart collection query
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
-- Indexes for performance (NFR-1.1, NFR-1.2)
|
||||
CREATE INDEX IF NOT EXISTS idx_files_virtual ON files(virtual_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_artist_album ON files(artist, album);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_content_hash ON files(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_real ON files(origin_id, real_path); -- FR-7.3
|
||||
CREATE INDEX IF NOT EXISTS idx_files_origin ON files(origin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_files_last_sync ON files(last_sync);
|
||||
CREATE INDEX IF NOT EXISTS idx_artwork_file ON artwork(file_id);
|
||||
```
|
||||
|
||||
### 2.4 Create `src/db.rs`
|
||||
|
||||
```rust
|
||||
use musicfs_core::{AudioMeta, ContentHash, Error, FileId, FileMeta, OriginId, RealPath, Result, VirtualPath};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::{debug, info};
|
||||
|
||||
const SCHEMA: &str = include_str!("schema.sql");
|
||||
|
||||
/// SQLite database connection manager
|
||||
pub struct Database {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// Open or create database at path
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
info!("Opening database at {:?}", path);
|
||||
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
// Execute schema
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open in-memory database (for testing)
|
||||
pub fn open_memory() -> Result<Self> {
|
||||
let conn = Connection::open_in_memory()
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert or update file metadata
|
||||
pub fn upsert_file(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
virtual_path: &VirtualPath,
|
||||
audio_meta: &AudioMeta,
|
||||
origin_mtime: SystemTime,
|
||||
origin_size: u64,
|
||||
) -> Result<FileId> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let mtime_secs = origin_mtime
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
conn.execute(
|
||||
r#"
|
||||
INSERT INTO files (
|
||||
origin_id, real_path, virtual_path,
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
origin_mtime, origin_size
|
||||
) VALUES (
|
||||
?1, ?2, ?3,
|
||||
?4, ?5, ?6, ?7, ?8,
|
||||
?9, ?10, ?11,
|
||||
?12, ?13, ?14, ?15,
|
||||
?16, ?17
|
||||
)
|
||||
ON CONFLICT(origin_id, real_path) DO UPDATE SET
|
||||
virtual_path = excluded.virtual_path,
|
||||
title = excluded.title,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
album_artist = excluded.album_artist,
|
||||
genre = excluded.genre,
|
||||
year = excluded.year,
|
||||
track = excluded.track,
|
||||
disc = excluded.disc,
|
||||
duration_ms = excluded.duration_ms,
|
||||
bitrate = excluded.bitrate,
|
||||
sample_rate = excluded.sample_rate,
|
||||
format = excluded.format,
|
||||
origin_mtime = excluded.origin_mtime,
|
||||
origin_size = excluded.origin_size,
|
||||
last_sync = strftime('%s', 'now')
|
||||
"#,
|
||||
params![
|
||||
&origin_id.0,
|
||||
real_path.to_string_lossy(),
|
||||
virtual_path.as_str(),
|
||||
&audio_meta.title,
|
||||
&audio_meta.artist,
|
||||
&audio_meta.album,
|
||||
&audio_meta.album_artist,
|
||||
&audio_meta.genre,
|
||||
&audio_meta.year,
|
||||
&audio_meta.track,
|
||||
&audio_meta.disc,
|
||||
&audio_meta.duration_ms.map(|d| d as i64),
|
||||
&audio_meta.bitrate,
|
||||
&audio_meta.sample_rate,
|
||||
format!("{:?}", audio_meta.format),
|
||||
mtime_secs,
|
||||
origin_size as i64,
|
||||
],
|
||||
).map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
debug!("Upserted file {} with id {}", virtual_path.as_str(), id);
|
||||
|
||||
Ok(FileId(id))
|
||||
}
|
||||
|
||||
/// Get file by virtual path
|
||||
pub fn get_file_by_virtual_path(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.query_row(
|
||||
r#"
|
||||
SELECT id, origin_id, real_path, virtual_path,
|
||||
title, artist, album, album_artist, genre,
|
||||
year, track, disc,
|
||||
duration_ms, bitrate, sample_rate, format,
|
||||
origin_mtime, origin_size, content_hash
|
||||
FROM files
|
||||
WHERE virtual_path = ?1
|
||||
"#,
|
||||
params![path.as_str()],
|
||||
|row| {
|
||||
Ok(FileMeta {
|
||||
id: FileId(row.get(0)?),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId(row.get(1)?),
|
||||
path: PathBuf::from(row.get::<_, String>(2)?),
|
||||
},
|
||||
virtual_path: VirtualPath::new(row.get::<_, String>(3)?),
|
||||
audio: Some(AudioMeta {
|
||||
title: row.get(4)?,
|
||||
artist: row.get(5)?,
|
||||
album: row.get(6)?,
|
||||
album_artist: row.get(7)?,
|
||||
genre: row.get(8)?,
|
||||
year: row.get(9)?,
|
||||
track: row.get(10)?,
|
||||
disc: row.get(11)?,
|
||||
duration_ms: row.get::<_, Option<i64>>(12)?.map(|d| d as u64),
|
||||
bitrate: row.get(13)?,
|
||||
sample_rate: row.get(14)?,
|
||||
format: musicfs_core::AudioFormat::Unknown, // TODO: parse
|
||||
}),
|
||||
size: row.get::<_, i64>(17)? as u64,
|
||||
mtime: UNIX_EPOCH + std::time::Duration::from_secs(row.get::<_, i64>(16)? as u64),
|
||||
content_hash: row.get::<_, Option<Vec<u8>>>(18)?
|
||||
.map(|b| ContentHash(b.try_into().unwrap_or([0; 8]))),
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
|
||||
/// Get file by ID
|
||||
pub fn get_file_by_id(&self, id: FileId) -> Result<Option<FileMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.query_row(
|
||||
"SELECT virtual_path FROM files WHERE id = ?1",
|
||||
params![id.0],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| Error::Database(e.to_string()))?
|
||||
.map(|vp| self.get_file_by_virtual_path(&VirtualPath::new(vp)))
|
||||
.transpose()
|
||||
.map(|o| o.flatten())
|
||||
}
|
||||
|
||||
/// List all files for an origin
|
||||
pub fn list_files(&self, origin_id: &OriginId) -> Result<Vec<FileMeta>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT virtual_path FROM files WHERE origin_id = ?1"
|
||||
).map_err(|e| Error::Database(e.to_string()))?;
|
||||
|
||||
let paths: Vec<String> = stmt
|
||||
.query_map(params![&origin_id.0], |row| row.get(0))
|
||||
.map_err(|e| Error::Database(e.to_string()))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
drop(stmt);
|
||||
drop(conn);
|
||||
|
||||
paths
|
||||
.into_iter()
|
||||
.filter_map(|p| self.get_file_by_virtual_path(&VirtualPath::new(p)).ok().flatten())
|
||||
.collect::<Vec<_>>()
|
||||
.pipe(Ok)
|
||||
}
|
||||
|
||||
/// Delete file by ID
|
||||
pub fn delete_file(&self, id: FileId) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM files WHERE id = ?1", params![id.0])
|
||||
.map_err(|e| Error::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get file count
|
||||
pub fn file_count(&self) -> Result<u64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(*) FROM files", [], |row| row.get::<_, i64>(0))
|
||||
.map(|c| c as u64)
|
||||
.map_err(|e| Error::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
trait Pipe: Sized {
|
||||
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
|
||||
f(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Pipe for T {}
|
||||
```
|
||||
|
||||
### 2.5 Create `src/metadata.rs`
|
||||
|
||||
```rust
|
||||
use crate::db::Database;
|
||||
use musicfs_core::{AudioMeta, FileMeta, OriginId, Result, VirtualPath};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// High-level metadata cache interface
|
||||
pub struct MetadataCache {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl MetadataCache {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Store file metadata
|
||||
pub fn store(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
virtual_path: &VirtualPath,
|
||||
audio_meta: &AudioMeta,
|
||||
origin_mtime: SystemTime,
|
||||
origin_size: u64,
|
||||
) -> Result<()> {
|
||||
self.db.upsert_file(
|
||||
origin_id,
|
||||
real_path,
|
||||
virtual_path,
|
||||
audio_meta,
|
||||
origin_mtime,
|
||||
origin_size,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lookup by virtual path
|
||||
pub fn lookup(&self, path: &VirtualPath) -> Result<Option<FileMeta>> {
|
||||
self.db.get_file_by_virtual_path(path)
|
||||
}
|
||||
|
||||
/// Check if file exists and is fresh
|
||||
pub fn is_fresh(
|
||||
&self,
|
||||
origin_id: &OriginId,
|
||||
real_path: &Path,
|
||||
current_mtime: SystemTime,
|
||||
) -> Result<bool> {
|
||||
// TODO: Compare mtime with cached value
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Unit Tests (`musicfs-metadata`)
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Cursor;
|
||||
|
||||
#[test]
|
||||
fn test_parse_flac_metadata() {
|
||||
// Use a real FLAC file for testing
|
||||
// For CI, embed a small test file or use a fixture
|
||||
let parser = MetadataParser::new();
|
||||
|
||||
// This would need a real file path
|
||||
// let meta = parser.parse_file(Path::new("test.flac")).unwrap();
|
||||
// assert!(meta.title.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_audio_format_detection() {
|
||||
assert_eq!(AudioFormat::from_extension("flac"), AudioFormat::Flac);
|
||||
assert_eq!(AudioFormat::from_extension("mp3"), AudioFormat::Mp3);
|
||||
assert_eq!(AudioFormat::from_extension("opus"), AudioFormat::Opus);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unit Tests (`musicfs-cache`)
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use musicfs_core::{AudioFormat, AudioMeta, OriginId, VirtualPath};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
#[test]
|
||||
fn test_database_creation() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
assert_eq!(db.file_count().unwrap(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_and_retrieve() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let origin_id = OriginId::from("local");
|
||||
let real_path = Path::new("/music/test.flac");
|
||||
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
||||
let audio_meta = AudioMeta {
|
||||
title: Some("Track".to_string()),
|
||||
artist: Some("Artist".to_string()),
|
||||
album: Some("Album".to_string()),
|
||||
track: Some(1),
|
||||
format: AudioFormat::Flac,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let id = db.upsert_file(
|
||||
&origin_id,
|
||||
real_path,
|
||||
&virtual_path,
|
||||
&audio_meta,
|
||||
UNIX_EPOCH,
|
||||
1000,
|
||||
).unwrap();
|
||||
|
||||
let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap();
|
||||
assert_eq!(retrieved.id, id);
|
||||
assert_eq!(retrieved.audio.as_ref().unwrap().title, Some("Track".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing() {
|
||||
let db = Database::open_memory().unwrap();
|
||||
|
||||
let origin_id = OriginId::from("local");
|
||||
let real_path = Path::new("/music/test.flac");
|
||||
let virtual_path = VirtualPath::new("/Artist/Album/01 - Track.flac");
|
||||
|
||||
// First insert
|
||||
let meta1 = AudioMeta {
|
||||
title: Some("Original".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
db.upsert_file(&origin_id, real_path, &virtual_path, &meta1, UNIX_EPOCH, 1000).unwrap();
|
||||
|
||||
// Update
|
||||
let meta2 = AudioMeta {
|
||||
title: Some("Updated".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
db.upsert_file(&origin_id, real_path, &virtual_path, &meta2, UNIX_EPOCH, 1000).unwrap();
|
||||
|
||||
// Should still be 1 file
|
||||
assert_eq!(db.file_count().unwrap(), 1);
|
||||
|
||||
// Title should be updated
|
||||
let retrieved = db.get_file_by_virtual_path(&virtual_path).unwrap().unwrap();
|
||||
assert_eq!(retrieved.audio.as_ref().unwrap().title, Some("Updated".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
|
||||
// Create and populate
|
||||
{
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
db.upsert_file(
|
||||
&OriginId::from("local"),
|
||||
Path::new("/test.flac"),
|
||||
&VirtualPath::new("/Test.flac"),
|
||||
&AudioMeta::default(),
|
||||
UNIX_EPOCH,
|
||||
100,
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
// Reopen and verify
|
||||
{
|
||||
let db = Database::open(&db_path).unwrap();
|
||||
assert_eq!(db.file_count().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] Parse FLAC metadata (title, artist, album, track, duration)
|
||||
- [ ] Parse MP3 metadata (ID3v2 and ID3v1 fallback)
|
||||
- [ ] Parse Opus/Vorbis comments
|
||||
- [ ] Parse M4A/AAC metadata
|
||||
- [ ] Handle missing metadata gracefully (FR-6.5)
|
||||
- [ ] SQLite schema creates all tables
|
||||
- [ ] Metadata persists across daemon restarts (FR-7.4)
|
||||
- [ ] Upsert correctly updates existing records
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Run metadata tests
|
||||
cargo test -p musicfs-metadata
|
||||
|
||||
# Run cache tests
|
||||
cargo test -p musicfs-cache
|
||||
|
||||
# Test with real audio file
|
||||
cargo run --example parse_metadata -- /path/to/test.flac
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Week
|
||||
|
||||
Week 3 will implement the virtual path resolver and tree cache, connecting metadata to the FUSE operations.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,501 +0,0 @@
|
||||
# Week 4b: Origin-CAS Connector
|
||||
|
||||
**Phase**: 1 (MVP)
|
||||
**Prerequisites**: Week 4 (CAS & Chunk Caching)
|
||||
**Estimated effort**: 1 day
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Bridge the gap between Origin (source files) and CAS (chunk cache) to enable actual file reads through FUSE. This implements the "cache miss" flow from architecture section 4.3.5.
|
||||
|
||||
**Problem**: Week 4 implemented CAS storage and FileReader, but there's no code that:
|
||||
1. Detects when requested chunks aren't cached
|
||||
2. Fetches data from Origin
|
||||
3. Stores chunks in CAS
|
||||
4. Creates ChunkManifest for the file
|
||||
|
||||
**Solution**: Create `ContentFetcher` that orchestrates Origin → CAS data flow on cache miss.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Reference
|
||||
|
||||
From architecture.md section 4.3.5 (Read Operation Activity):
|
||||
|
||||
```
|
||||
|CAS|
|
||||
:compute chunk range for [offset, offset+size];
|
||||
if (all chunks cached?) then (yes)
|
||||
:read from local chunk files;
|
||||
else (no)
|
||||
|OriginFederation|
|
||||
:select healthy origin by priority;
|
||||
:fetch missing byte range;
|
||||
|CAS|
|
||||
:chunk fetched data (CDC);
|
||||
:store chunks by hash;
|
||||
:update chunk manifest;
|
||||
endif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Done |
|
||||
|------|-------|-------|------|
|
||||
| ContentFetcher implementation | musicfs-cas | `fetcher.rs` | [ ] |
|
||||
| FileId → FileMeta resolver | musicfs-cas | `fetcher.rs` | [ ] |
|
||||
| Update FileReader for cache-miss | musicfs-cas | `reader.rs` | [ ] |
|
||||
| Update FUSE with fetcher | musicfs-fuse | `filesystem.rs` | [ ] |
|
||||
| E2E test: cat file through FUSE | tests | `integration.rs` | [ ] |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ContentFetcher
|
||||
|
||||
### 1.1 Create `musicfs-cas/src/fetcher.rs`
|
||||
|
||||
```rust
|
||||
use crate::{CasStore, ChunkManifest, ChunkRef};
|
||||
use musicfs_core::{Event, EventBus, FileId, FileMeta, OriginId, RealPath};
|
||||
use musicfs_origins::Origin;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tracing::{debug, info};
|
||||
|
||||
pub struct ContentFetcher {
|
||||
store: Arc<CasStore>,
|
||||
origins: RwLock<HashMap<OriginId, Arc<dyn Origin>>>,
|
||||
file_meta: RwLock<HashMap<FileId, FileMeta>>,
|
||||
event_bus: Option<Arc<EventBus>>,
|
||||
}
|
||||
|
||||
impl ContentFetcher {
|
||||
pub fn new(store: Arc<CasStore>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
origins: RwLock::new(HashMap::new()),
|
||||
file_meta: RwLock::new(HashMap::new()),
|
||||
event_bus: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_event_bus(store: Arc<CasStore>, event_bus: Arc<EventBus>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
origins: RwLock::new(HashMap::new()),
|
||||
file_meta: RwLock::new(HashMap::new()),
|
||||
event_bus: Some(event_bus),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_origin(&self, origin: Arc<dyn Origin>) {
|
||||
let id = origin.id().clone();
|
||||
self.origins.write().unwrap().insert(id, origin);
|
||||
}
|
||||
|
||||
pub fn register_file(&self, meta: FileMeta) {
|
||||
self.file_meta.write().unwrap().insert(meta.id, meta);
|
||||
}
|
||||
|
||||
pub fn register_files(&self, files: impl IntoIterator<Item = FileMeta>) {
|
||||
let mut map = self.file_meta.write().unwrap();
|
||||
for meta in files {
|
||||
map.insert(meta.id, meta);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_file(&self, file_id: FileId) -> Result<ChunkManifest, FetchError> {
|
||||
let meta = {
|
||||
let files = self.file_meta.read().unwrap();
|
||||
files.get(&file_id).cloned()
|
||||
.ok_or(FetchError::FileNotFound(file_id))?
|
||||
};
|
||||
|
||||
let origin = {
|
||||
let origins = self.origins.read().unwrap();
|
||||
origins.get(&meta.real_path.origin_id).cloned()
|
||||
.ok_or_else(|| FetchError::OriginNotFound(meta.real_path.origin_id.clone()))?
|
||||
};
|
||||
|
||||
info!("Fetching file {:?} from origin {}", file_id, origin.id());
|
||||
|
||||
let data = origin.read(&meta.real_path.path, 0, meta.size as u32).await
|
||||
.map_err(|e| FetchError::OriginRead(e.to_string()))?;
|
||||
|
||||
let hash = self.store.put(&data).await
|
||||
.map_err(FetchError::Store)?;
|
||||
|
||||
let manifest = ChunkManifest {
|
||||
file_id,
|
||||
total_size: meta.size,
|
||||
chunks: vec![ChunkRef {
|
||||
hash,
|
||||
offset: 0,
|
||||
size: data.len() as u32,
|
||||
}],
|
||||
};
|
||||
|
||||
debug!("Created manifest for {:?}: {} bytes, 1 chunk", file_id, meta.size);
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn emit_access_event(&self, meta: &FileMeta, offset: u64, size: u32) {
|
||||
if let Some(bus) = &self.event_bus {
|
||||
bus.publish(Event::FileAccessed {
|
||||
path: meta.virtual_path.clone(),
|
||||
origin_id: meta.real_path.origin_id.clone(),
|
||||
offset,
|
||||
size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_cached(&self, file_id: FileId) -> Result<ChunkManifest, FetchError> {
|
||||
self.fetch_file(file_id).await
|
||||
}
|
||||
|
||||
pub fn get_file_meta(&self, file_id: FileId) -> Option<FileMeta> {
|
||||
self.file_meta.read().unwrap().get(&file_id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FetchError {
|
||||
#[error("File not found: {0:?}")]
|
||||
FileNotFound(FileId),
|
||||
|
||||
#[error("Origin not found: {0}")]
|
||||
OriginNotFound(OriginId),
|
||||
|
||||
#[error("Origin read error: {0}")]
|
||||
OriginRead(String),
|
||||
|
||||
#[error("Store error: {0}")]
|
||||
Store(#[from] crate::CasError),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::CasConfig;
|
||||
use musicfs_core::VirtualPath;
|
||||
use musicfs_origins::LocalOrigin;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_file() {
|
||||
let cas_dir = TempDir::new().unwrap();
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
|
||||
std::fs::write(origin_dir.path().join("test.flac"), b"fake audio data").unwrap();
|
||||
|
||||
let config = CasConfig {
|
||||
chunks_dir: cas_dir.path().join("chunks"),
|
||||
..Default::default()
|
||||
};
|
||||
let store = Arc::new(CasStore::open(config).await.unwrap());
|
||||
let fetcher = ContentFetcher::new(store.clone());
|
||||
|
||||
let origin = Arc::new(LocalOrigin::new("local", origin_dir.path()));
|
||||
fetcher.register_origin(origin);
|
||||
|
||||
let meta = FileMeta {
|
||||
id: FileId(1),
|
||||
virtual_path: VirtualPath::new("/Artist/Album/test.flac"),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId::from("local"),
|
||||
path: PathBuf::from("/test.flac"),
|
||||
},
|
||||
size: 15,
|
||||
mtime: SystemTime::now(),
|
||||
content_hash: None,
|
||||
audio: None,
|
||||
};
|
||||
fetcher.register_file(meta);
|
||||
|
||||
let manifest = fetcher.fetch_file(FileId(1)).await.unwrap();
|
||||
assert_eq!(manifest.total_size, 15);
|
||||
assert_eq!(manifest.chunks.len(), 1);
|
||||
|
||||
let data = store.get(&manifest.chunks[0].hash).await.unwrap();
|
||||
assert_eq!(&data[..], b"fake audio data");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_file_not_found() {
|
||||
let cas_dir = TempDir::new().unwrap();
|
||||
let config = CasConfig {
|
||||
chunks_dir: cas_dir.path().join("chunks"),
|
||||
..Default::default()
|
||||
};
|
||||
let store = Arc::new(CasStore::open(config).await.unwrap());
|
||||
let fetcher = ContentFetcher::new(store);
|
||||
|
||||
let result = fetcher.fetch_file(FileId(999)).await;
|
||||
assert!(matches!(result, Err(FetchError::FileNotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_emits_event() {
|
||||
let cas_dir = TempDir::new().unwrap();
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
std::fs::write(origin_dir.path().join("test.flac"), b"audio").unwrap();
|
||||
|
||||
let config = CasConfig {
|
||||
chunks_dir: cas_dir.path().join("chunks"),
|
||||
..Default::default()
|
||||
};
|
||||
let store = Arc::new(CasStore::open(config).await.unwrap());
|
||||
let event_bus = Arc::new(EventBus::default());
|
||||
let mut rx = event_bus.subscribe();
|
||||
|
||||
let fetcher = ContentFetcher::with_event_bus(store, event_bus);
|
||||
let origin = Arc::new(LocalOrigin::new("local", origin_dir.path()));
|
||||
fetcher.register_origin(origin);
|
||||
|
||||
let meta = FileMeta {
|
||||
id: FileId(1),
|
||||
virtual_path: VirtualPath::new("/Artist/test.flac"),
|
||||
real_path: RealPath {
|
||||
origin_id: OriginId::from("local"),
|
||||
path: PathBuf::from("/test.flac"),
|
||||
},
|
||||
size: 5,
|
||||
mtime: SystemTime::now(),
|
||||
content_hash: None,
|
||||
audio: None,
|
||||
};
|
||||
fetcher.register_file(meta.clone());
|
||||
|
||||
fetcher.emit_access_event(&meta, 0, 5);
|
||||
|
||||
let event = rx.try_recv().unwrap();
|
||||
assert!(matches!(event, Event::FileAccessed { .. }));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update FileReader
|
||||
|
||||
### 2.1 Update `musicfs-cas/src/reader.rs`
|
||||
|
||||
Add fetcher integration for cache-miss handling:
|
||||
|
||||
```rust
|
||||
use crate::fetcher::{ContentFetcher, FetchError};
|
||||
|
||||
pub struct FileReader {
|
||||
store: Arc<CasStore>,
|
||||
fetcher: Option<Arc<ContentFetcher>>,
|
||||
manifests: RwLock<HashMap<FileId, ChunkManifest>>,
|
||||
}
|
||||
|
||||
impl FileReader {
|
||||
pub fn new(store: Arc<CasStore>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fetcher: None,
|
||||
manifests: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_fetcher(store: Arc<CasStore>, fetcher: Arc<ContentFetcher>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
fetcher: Some(fetcher),
|
||||
manifests: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(
|
||||
&self,
|
||||
file_id: FileId,
|
||||
offset: u64,
|
||||
size: u32,
|
||||
) -> Result<Bytes, ReaderError> {
|
||||
let manifest = self.get_or_fetch_manifest(file_id).await?;
|
||||
|
||||
if let Some(fetcher) = &self.fetcher {
|
||||
if let Some(meta) = fetcher.get_file_meta(file_id) {
|
||||
fetcher.emit_access_event(&meta, offset, size);
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of read logic unchanged
|
||||
}
|
||||
|
||||
async fn get_or_fetch_manifest(&self, file_id: FileId) -> Result<ChunkManifest, ReaderError> {
|
||||
{
|
||||
let manifests = self.manifests.read().unwrap();
|
||||
if let Some(m) = manifests.get(&file_id) {
|
||||
return Ok(m.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let Some(fetcher) = &self.fetcher else {
|
||||
return Err(ReaderError::ManifestNotFound(file_id));
|
||||
};
|
||||
|
||||
let manifest = fetcher.ensure_cached(file_id).await
|
||||
.map_err(ReaderError::Fetch)?;
|
||||
|
||||
self.manifests.write().unwrap().insert(file_id, manifest.clone());
|
||||
Ok(manifest)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ReaderError {
|
||||
#[error("Manifest not found for file {0:?}")]
|
||||
ManifestNotFound(FileId),
|
||||
|
||||
#[error("Fetch error: {0}")]
|
||||
Fetch(#[from] FetchError),
|
||||
|
||||
#[error("CAS error: {0}")]
|
||||
Cas(#[from] crate::CasError),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update lib.rs
|
||||
|
||||
### 3.1 Update `musicfs-cas/src/lib.rs`
|
||||
|
||||
```rust
|
||||
mod chunks;
|
||||
mod fetcher;
|
||||
mod reader;
|
||||
mod store;
|
||||
|
||||
pub use chunks::{ChunkLocation, ChunkRef};
|
||||
pub use fetcher::{ContentFetcher, FetchError};
|
||||
pub use reader::{ChunkManifest, FileReader, ReaderError};
|
||||
pub use store::{CasConfig, CasError, CasStore, DedupStats};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update Cargo.toml
|
||||
|
||||
### 4.1 Update `musicfs-cas/Cargo.toml`
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
musicfs-origins = { path = "../musicfs-origins" }
|
||||
# ... rest unchanged
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Update FUSE Integration
|
||||
|
||||
### 5.1 Update `musicfs-fuse/src/filesystem.rs`
|
||||
|
||||
```rust
|
||||
use musicfs_cas::{ContentFetcher, FileReader};
|
||||
|
||||
pub struct MusicFs {
|
||||
tree: Arc<RwLock<VirtualTree>>,
|
||||
reader: Option<Arc<FileReader>>,
|
||||
fetcher: Option<Arc<ContentFetcher>>,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
}
|
||||
|
||||
impl MusicFs {
|
||||
pub fn with_content_access(
|
||||
tree: Arc<RwLock<VirtualTree>>,
|
||||
reader: Arc<FileReader>,
|
||||
fetcher: Arc<ContentFetcher>,
|
||||
) -> Self {
|
||||
Self {
|
||||
tree,
|
||||
reader: Some(reader),
|
||||
fetcher: Some(fetcher),
|
||||
uid: unsafe { libc::getuid() },
|
||||
gid: unsafe { libc::getgid() },
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_fetch_file` | Unit | Origin → CAS fetch works |
|
||||
| `test_fetch_file_not_found` | Unit | Missing file error |
|
||||
| `test_fetch_emits_event` | Unit | FileAccessed event emitted (FR-18.1) |
|
||||
| `test_reader_with_fetcher` | Unit | Cache-miss triggers fetch |
|
||||
| `test_e2e_cat_file` | Integration | `cat` returns file content |
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] `ContentFetcher` fetches from Origin and stores in CAS
|
||||
- [ ] `FileReader` calls fetcher on cache miss
|
||||
- [ ] File metadata (FileId → FileMeta) is resolvable
|
||||
- [ ] `cat /mnt/musicfs/Artist/Album/track.flac` returns actual audio data
|
||||
- [ ] All existing tests still pass
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### `musicfs-cas/Cargo.toml`
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
musicfs-origins = { path = "../musicfs-origins" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Week 4 treated whole files as single chunks** - this continues that approach
|
||||
2. **CDC chunking deferred to Week 5** - fetcher will be updated then
|
||||
3. **No OriginFederation yet** - single origin lookup for MVP
|
||||
4. **FileMeta registration** - caller must register files before they can be fetched
|
||||
5. **EventBus integration** - emits `FileAccessed` event per FR-18.1 (P0)
|
||||
6. **Full file fetch** - currently fetches entire file on cache miss; byte-range optimization deferred
|
||||
|
||||
## Architecture Compliance
|
||||
|
||||
| Architecture Section | Requirement | Status |
|
||||
|---------------------|-------------|--------|
|
||||
| 4.3.5 | Cache miss → fetch from origin | ✅ |
|
||||
| 4.3.5 | Store chunks by hash | ✅ |
|
||||
| 4.3.5 | Update chunk manifest | ✅ |
|
||||
| 4.3.5 | Emit FileAccessed event | ✅ |
|
||||
| 4.3.3 | OriginFederation (multi-origin) | ⏳ Deferred |
|
||||
| 4.3.5 | Byte-range fetch | ⏳ Deferred |
|
||||
| 4.3.5 | CDC chunking | ⏳ Week 5 |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
After this, the MVP is complete:
|
||||
- Mount filesystem
|
||||
- Browse virtual tree (Artist/Album/Track)
|
||||
- Read actual file content through FUSE
|
||||
- Audio playback works
|
||||
|
||||
Week 5 adds CDC chunking for efficient delta sync.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,179 +0,0 @@
|
||||
# Week 10: Plugin System
|
||||
|
||||
**Phase**: 4 - Plugin System & Polish
|
||||
**Goal**: Extensibility via native and WASM plugins
|
||||
**Requirements**: FR-23.1-23.5, FR-24.1-24.3
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Requirements |
|
||||
|------|-------|-------|--------------|
|
||||
| Plugin traits | musicfs-plugins | `traits.rs` | FR-23.1-23.4 |
|
||||
| Native host | musicfs-plugins | `native.rs` | FR-23.2 |
|
||||
| WASM host | musicfs-plugins | `wasm.rs` | FR-23.3 |
|
||||
| Plugin lifecycle | musicfs-plugins | `manager.rs` | FR-23.5 |
|
||||
| Example plugins | plugins/ | `example-origin/`, `example-format/` | FR-23.5 |
|
||||
|
||||
---
|
||||
|
||||
## Plugin Traits (`musicfs-plugins/src/traits.rs`)
|
||||
|
||||
```rust
|
||||
/// Base plugin interface
|
||||
pub trait Plugin: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn version(&self) -> Version;
|
||||
fn init(&mut self, config: Value) -> Result<(), PluginError>;
|
||||
fn shutdown(&mut self) -> Result<(), PluginError>;
|
||||
}
|
||||
|
||||
/// Origin plugin interface (per architecture 4.3.4)
|
||||
pub trait OriginPlugin: Plugin {
|
||||
fn origin_type(&self) -> &str;
|
||||
fn create(&self, config: Value) -> Result<Box<dyn Origin>, PluginError>;
|
||||
}
|
||||
|
||||
/// Metadata source plugin
|
||||
pub trait MetadataPlugin: Plugin {
|
||||
fn lookup(&self, query: &MetadataQuery) -> Result<Option<ExternalMetadata>, PluginError>;
|
||||
}
|
||||
|
||||
/// Format plugin for custom audio formats (FR-24.1)
|
||||
pub trait FormatPlugin: Plugin {
|
||||
fn extensions(&self) -> &[&str];
|
||||
fn can_handle(&self, extension: &str) -> bool;
|
||||
fn parse(&self, reader: &mut dyn Read) -> Result<AudioMeta, PluginError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Native Plugin Host (`musicfs-plugins/src/native.rs`)
|
||||
|
||||
```rust
|
||||
pub struct NativePluginHost {
|
||||
plugins: HashMap<String, LoadedPlugin>,
|
||||
search_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
struct LoadedPlugin {
|
||||
library: libloading::Library,
|
||||
instance: Box<dyn Plugin>,
|
||||
}
|
||||
|
||||
impl NativePluginHost {
|
||||
pub fn new() -> Self;
|
||||
|
||||
/// Load plugin from shared library (.so/.dylib)
|
||||
pub fn load(&mut self, path: &Path) -> Result<PluginId, PluginError>;
|
||||
|
||||
/// Unload plugin (FR-23.5)
|
||||
pub fn unload(&mut self, id: PluginId) -> Result<(), PluginError>;
|
||||
|
||||
/// Hot reload plugin without restart (FR-23.4)
|
||||
pub fn reload(&mut self, id: PluginId) -> Result<(), PluginError>;
|
||||
|
||||
/// List loaded plugins
|
||||
pub fn list(&self) -> Vec<PluginInfo>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WASM Plugin Host (`musicfs-plugins/src/wasm.rs`)
|
||||
|
||||
```rust
|
||||
pub struct WasmPluginHost {
|
||||
engine: wasmtime::Engine,
|
||||
linker: wasmtime::Linker<PluginState>,
|
||||
}
|
||||
|
||||
impl WasmPluginHost {
|
||||
pub fn new() -> Result<Self, PluginError>;
|
||||
|
||||
/// Load WASM plugin with sandboxing (FR-23.3)
|
||||
pub fn load(&mut self, wasm_bytes: &[u8]) -> Result<WasmPlugin, PluginError>;
|
||||
|
||||
/// Resource limits for sandboxed execution
|
||||
pub fn set_limits(&mut self, limits: ResourceLimits);
|
||||
}
|
||||
|
||||
pub struct ResourceLimits {
|
||||
pub max_memory_mb: u32,
|
||||
pub max_cpu_time_ms: u32,
|
||||
pub allow_network: bool,
|
||||
pub allow_filesystem: bool,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Manager (`musicfs-plugins/src/manager.rs`)
|
||||
|
||||
```rust
|
||||
pub struct PluginManager {
|
||||
native_host: NativePluginHost,
|
||||
wasm_host: WasmPluginHost,
|
||||
registry: PluginRegistry,
|
||||
}
|
||||
|
||||
impl PluginManager {
|
||||
/// Initialize and load plugins from config
|
||||
pub fn init(config: &PluginConfig) -> Result<Self, PluginError>;
|
||||
|
||||
/// Get all origin plugins
|
||||
pub fn origin_plugins(&self) -> Vec<&dyn OriginPlugin>;
|
||||
|
||||
/// Get all format plugins
|
||||
pub fn format_plugins(&self) -> Vec<&dyn FormatPlugin>;
|
||||
|
||||
/// Get all metadata plugins
|
||||
pub fn metadata_plugins(&self) -> Vec<&dyn MetadataPlugin>;
|
||||
|
||||
/// Reload all plugins (hot reload)
|
||||
pub fn reload_all(&mut self) -> Result<(), PluginError>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_native_plugin_load` | Unit | Native plugin loading (FR-23.2) |
|
||||
| `test_native_plugin_unload` | Unit | Clean unload |
|
||||
| `test_wasm_plugin_sandbox` | Unit | WASM isolation (FR-23.3) |
|
||||
| `test_wasm_resource_limits` | Unit | Memory/CPU limits enforced |
|
||||
| `test_plugin_hot_reload` | Integration | Reload without restart (FR-23.4) |
|
||||
| `test_example_origin_plugin` | Integration | Custom origin works |
|
||||
| `test_example_format_plugin` | Integration | Custom format works |
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] Native plugins loadable at runtime
|
||||
- [ ] WASM plugins sandboxed with resource limits
|
||||
- [ ] Example plugins functional
|
||||
- [ ] Plugins hot-reloadable without daemon restart
|
||||
- [ ] Plugin lifecycle management (load, unload, reload)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Alignment
|
||||
|
||||
Per architecture.md section 4.3.4:
|
||||
- Plugin loading: Built-in → Native (.so) → WASM
|
||||
- Origin plugins create `Box<dyn Origin>`
|
||||
- Format plugins register file extensions
|
||||
- WASM runs in wasmtime sandbox
|
||||
|
||||
Per requirements.md:
|
||||
- FR-23.1: Loadable plugins ✓
|
||||
- FR-23.2: Stable plugin API ✓
|
||||
- FR-23.3: Plugins for origins, metadata, formats ✓
|
||||
- FR-23.4: WASM sandbox ✓
|
||||
- FR-23.5: Plugin lifecycle ✓
|
||||
@@ -1,539 +0,0 @@
|
||||
# Week 11: Control API & Production
|
||||
|
||||
**Phase**: 4 - Plugin System & Polish
|
||||
**Goal**: gRPC control API, metrics, and production readiness
|
||||
**Requirements**: FR-17.1-17.5, FR-18.1-18.4, NFR-6.1-6.4, NFR-10.1-10.4
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Requirements |
|
||||
|------|-------|-------|--------------|
|
||||
| gRPC server | musicfs-grpc | `server.rs` | FR-17.1-17.5 |
|
||||
| Proto codegen | proto/ | `musicfs.proto`, `build.rs` | FR-17.2 |
|
||||
| Event streaming | musicfs-grpc | `events.rs` | FR-18.1-18.3 |
|
||||
| Webhook handler | musicfs-grpc | `webhook.rs` | FR-18.2 |
|
||||
| Metrics export | musicfs-core | `metrics.rs` | NFR-6.1-6.4, NFR-10.2-10.4 |
|
||||
| CLI completion | musicfs-cli | `main.rs` | FR-17 |
|
||||
| systemd unit | dist/ | `musicfs.service` | Production |
|
||||
| Packaging | dist/ | `PKGBUILD`, `musicfs.spec` | Production |
|
||||
| E2E compatibility | tests/ | `e2e_players.rs` | NFR-12.1-12.3 |
|
||||
|
||||
---
|
||||
|
||||
## Proto Definitions (`proto/musicfs.proto`)
|
||||
|
||||
Per architecture.md section 4.3.7, implement full gRPC API:
|
||||
|
||||
```protobuf
|
||||
syntax = "proto3";
|
||||
package musicfs.v1;
|
||||
|
||||
service MusicFS {
|
||||
// Daemon lifecycle
|
||||
rpc GetStatus(Empty) returns (StatusResponse);
|
||||
rpc Shutdown(ShutdownRequest) returns (Empty);
|
||||
|
||||
// Cache management
|
||||
rpc GetCacheStats(Empty) returns (CacheStats);
|
||||
rpc ClearCache(ClearCacheRequest) returns (ClearCacheResponse);
|
||||
rpc Prefetch(PrefetchRequest) returns (stream PrefetchProgress);
|
||||
|
||||
// Origin management
|
||||
rpc ListOrigins(Empty) returns (OriginsResponse);
|
||||
rpc GetOriginHealth(OriginRequest) returns (OriginHealth);
|
||||
rpc RescanOrigin(OriginRequest) returns (stream SyncProgress);
|
||||
|
||||
// Search (already implemented in Week 8)
|
||||
rpc Search(SearchRequest) returns (SearchResponse);
|
||||
rpc SearchStream(SearchRequest) returns (stream SearchResult);
|
||||
|
||||
// Events (server-streaming)
|
||||
rpc SubscribeEvents(EventFilter) returns (stream Event);
|
||||
}
|
||||
```
|
||||
|
||||
Full message definitions in architecture.md section 4.3.7.
|
||||
|
||||
---
|
||||
|
||||
## gRPC Server (`musicfs-grpc/src/server.rs`)
|
||||
|
||||
```rust
|
||||
pub struct MusicFsService {
|
||||
core: Arc<MusicFsCore>,
|
||||
events: broadcast::Sender<Event>,
|
||||
metrics: Arc<MetricsCollector>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl musicfs::v1::music_fs_server::MusicFs for MusicFsService {
|
||||
// Daemon lifecycle
|
||||
async fn get_status(&self, _: Request<Empty>) -> Result<Response<StatusResponse>, Status>;
|
||||
async fn shutdown(&self, req: Request<ShutdownRequest>) -> Result<Response<Empty>, Status>;
|
||||
|
||||
// Cache management
|
||||
async fn get_cache_stats(&self, _: Request<Empty>) -> Result<Response<CacheStats>, Status>;
|
||||
async fn clear_cache(&self, req: Request<ClearCacheRequest>) -> Result<Response<ClearCacheResponse>, Status>;
|
||||
|
||||
type PrefetchStream = ReceiverStream<Result<PrefetchProgress, Status>>;
|
||||
async fn prefetch(&self, req: Request<PrefetchRequest>) -> Result<Response<Self::PrefetchStream>, Status>;
|
||||
|
||||
// Origin management
|
||||
async fn list_origins(&self, _: Request<Empty>) -> Result<Response<OriginsResponse>, Status>;
|
||||
async fn get_origin_health(&self, req: Request<OriginRequest>) -> Result<Response<OriginHealth>, Status>;
|
||||
|
||||
type RescanOriginStream = ReceiverStream<Result<SyncProgress, Status>>;
|
||||
async fn rescan_origin(&self, req: Request<OriginRequest>) -> Result<Response<Self::RescanOriginStream>, Status>;
|
||||
|
||||
// Events
|
||||
type SubscribeEventsStream = ReceiverStream<Result<Event, Status>>;
|
||||
async fn subscribe_events(&self, req: Request<EventFilter>) -> Result<Response<Self::SubscribeEventsStream>, Status>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Streaming (`musicfs-grpc/src/events.rs`)
|
||||
|
||||
```rust
|
||||
pub struct EventStreamer {
|
||||
bus: Arc<EventBus>,
|
||||
}
|
||||
|
||||
impl EventStreamer {
|
||||
/// Convert internal events to gRPC Event messages
|
||||
pub fn subscribe(&self, filter: EventFilter) -> impl Stream<Item = Event>;
|
||||
|
||||
/// Filter events by type and origin
|
||||
fn matches(event: &Event, filter: &EventFilter) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Handler (`musicfs-grpc/src/webhook.rs`)
|
||||
|
||||
HTTP webhook notifications for external integrations (FR-18.2):
|
||||
|
||||
```rust
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WebhookPayload {
|
||||
pub event_type: String,
|
||||
pub timestamp: i64,
|
||||
pub data: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct WebhookConfig {
|
||||
pub url: String,
|
||||
pub secret: Option<String>,
|
||||
pub events: Vec<String>, // Filter: ["file_accessed", "sync_completed", ...]
|
||||
pub retry_count: u32,
|
||||
pub timeout_ms: u64,
|
||||
}
|
||||
|
||||
pub struct WebhookHandler {
|
||||
client: Client,
|
||||
configs: Vec<WebhookConfig>,
|
||||
}
|
||||
|
||||
impl WebhookHandler {
|
||||
pub fn new(configs: Vec<WebhookConfig>) -> Self;
|
||||
|
||||
/// Start listening to event bus and dispatch webhooks
|
||||
pub async fn run(&self, mut rx: broadcast::Receiver<Event>) {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
for config in &self.configs {
|
||||
if self.matches_filter(&event, config) {
|
||||
self.dispatch(config, &event).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch webhook with retry logic
|
||||
async fn dispatch(&self, config: &WebhookConfig, event: &Event) {
|
||||
let payload = WebhookPayload {
|
||||
event_type: event.event_type(),
|
||||
timestamp: event.timestamp(),
|
||||
data: event.to_json(),
|
||||
};
|
||||
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
let result = self.client
|
||||
.post(&config.url)
|
||||
.timeout(Duration::from_millis(config.timeout_ms))
|
||||
.header("X-MusicFS-Signature", self.sign(&payload, config))
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => break,
|
||||
_ if attempts < config.retry_count => {
|
||||
attempts += 1;
|
||||
tokio::time::sleep(Duration::from_millis(100 * 2u64.pow(attempts))).await;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Webhook delivery failed after {} attempts", attempts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HMAC-SHA256 signature if secret configured
|
||||
fn sign(&self, payload: &WebhookPayload, config: &WebhookConfig) -> String;
|
||||
|
||||
fn matches_filter(&self, event: &Event, config: &WebhookConfig) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
Configuration in `config.toml`:
|
||||
|
||||
```toml
|
||||
[[webhooks]]
|
||||
url = "https://example.com/musicfs/events"
|
||||
secret = "your-webhook-secret"
|
||||
events = ["file_accessed", "sync_completed", "origin_health_changed"]
|
||||
retry_count = 3
|
||||
timeout_ms = 5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E Compatibility Tests (`tests/e2e_players.rs`)
|
||||
|
||||
Verify MusicFS works with common media players (NFR-12.1-12.3):
|
||||
|
||||
```rust
|
||||
//! E2E tests for media player compatibility
|
||||
//! Requires: mpv, vlc, file manager (nautilus/dolphin) installed
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Test mpv can play files from MusicFS (NFR-12.1)
|
||||
#[test]
|
||||
#[ignore] // Run manually: cargo test --ignored
|
||||
fn test_mpv_playback() {
|
||||
let mountpoint = setup_test_mount();
|
||||
|
||||
// mpv should be able to:
|
||||
// 1. Open file without hanging
|
||||
// 2. Read metadata (duration, format)
|
||||
// 3. Play first few seconds
|
||||
// 4. Seek forward
|
||||
// 5. Exit cleanly
|
||||
|
||||
let output = Command::new("mpv")
|
||||
.args([
|
||||
"--no-video",
|
||||
"--no-audio", // Silent playback
|
||||
"--length=2", // Play 2 seconds only
|
||||
"--msg-level=all=debug",
|
||||
&format!("{}/Artist/Album/01 - Track.flac", mountpoint),
|
||||
])
|
||||
.output()
|
||||
.expect("mpv must be installed");
|
||||
|
||||
assert!(output.status.success(), "mpv playback failed: {:?}", output);
|
||||
}
|
||||
|
||||
/// Test VLC can browse and play (NFR-12.2)
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_vlc_playback() {
|
||||
let mountpoint = setup_test_mount();
|
||||
|
||||
// VLC should handle:
|
||||
// 1. Directory browsing
|
||||
// 2. Playlist creation from folder
|
||||
// 3. Metadata display
|
||||
// 4. Gapless playback (if supported)
|
||||
|
||||
let output = Command::new("cvlc") // Command-line VLC
|
||||
.args([
|
||||
"--play-and-exit",
|
||||
"--run-time=2",
|
||||
&format!("{}/Artist/Album/", mountpoint),
|
||||
])
|
||||
.output()
|
||||
.expect("vlc must be installed");
|
||||
|
||||
assert!(output.status.success(), "VLC playback failed");
|
||||
}
|
||||
|
||||
/// Test file manager operations (NFR-12.3)
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_file_manager_operations() {
|
||||
let mountpoint = setup_test_mount();
|
||||
|
||||
// File managers should be able to:
|
||||
// 1. List directories without timeout
|
||||
// 2. Show file previews/thumbnails
|
||||
// 3. Display file properties
|
||||
// 4. Copy files to local disk
|
||||
|
||||
// Test basic stat operations that file managers use
|
||||
let entries: Vec<_> = std::fs::read_dir(&mountpoint)
|
||||
.expect("read_dir failed")
|
||||
.collect();
|
||||
|
||||
assert!(!entries.is_empty(), "mountpoint should have entries");
|
||||
|
||||
// Test stat on each entry (file managers do this for icons)
|
||||
for entry in entries {
|
||||
let entry = entry.expect("entry should be valid");
|
||||
let metadata = entry.metadata().expect("metadata should work");
|
||||
assert!(metadata.is_dir() || metadata.is_file());
|
||||
}
|
||||
}
|
||||
|
||||
/// Test concurrent access from multiple players
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_concurrent_player_access() {
|
||||
let mountpoint = setup_test_mount();
|
||||
|
||||
// Spawn multiple players accessing different files
|
||||
let handles: Vec<_> = (0..3)
|
||||
.map(|i| {
|
||||
let mp = mountpoint.clone();
|
||||
std::thread::spawn(move || {
|
||||
Command::new("mpv")
|
||||
.args([
|
||||
"--no-video", "--no-audio", "--length=1",
|
||||
&format!("{}/Artist/Album/0{} - Track.flac", mp, i + 1),
|
||||
])
|
||||
.output()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for handle in handles {
|
||||
let output = handle.join().unwrap().expect("mpv should run");
|
||||
assert!(output.status.success());
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_test_mount() -> String {
|
||||
// Returns path to test mount with sample files
|
||||
std::env::var("MUSICFS_TEST_MOUNT")
|
||||
.unwrap_or_else(|_| "/tmp/musicfs-test".to_string())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Metrics (`musicfs-core/src/metrics.rs`)
|
||||
|
||||
Per architecture.md section 5.2:
|
||||
|
||||
```rust
|
||||
use prometheus::{IntCounterVec, HistogramVec, IntGauge, register_*};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref FUSE_OPS: IntCounterVec = register_int_counter_vec!(
|
||||
"musicfs_fuse_ops_total",
|
||||
"Total FUSE operations",
|
||||
&["op"]
|
||||
).unwrap();
|
||||
|
||||
pub static ref FUSE_LATENCY: HistogramVec = register_histogram_vec!(
|
||||
"musicfs_fuse_latency_seconds",
|
||||
"FUSE operation latency",
|
||||
&["op"],
|
||||
vec![0.0001, 0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0]
|
||||
).unwrap();
|
||||
|
||||
pub static ref CACHE_HITS: IntCounter = register_int_counter!(
|
||||
"musicfs_cache_hits_total",
|
||||
"Cache hits"
|
||||
).unwrap();
|
||||
|
||||
pub static ref CACHE_MISSES: IntCounter = register_int_counter!(
|
||||
"musicfs_cache_misses_total",
|
||||
"Cache misses"
|
||||
).unwrap();
|
||||
|
||||
pub static ref CACHE_SIZE_BYTES: IntGauge = register_int_gauge!(
|
||||
"musicfs_cache_size_bytes",
|
||||
"Current cache size in bytes"
|
||||
).unwrap();
|
||||
|
||||
pub static ref ORIGIN_HEALTH: IntGaugeVec = register_int_gauge_vec!(
|
||||
"musicfs_origin_health",
|
||||
"Origin health status (1=healthy, 0=unhealthy)",
|
||||
&["origin"]
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
/// Expose metrics on HTTP endpoint
|
||||
pub async fn serve_metrics(addr: SocketAddr) -> Result<(), MetricsError>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Commands (`musicfs-cli/src/main.rs`)
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
enum Command {
|
||||
/// Mount filesystem
|
||||
Mount {
|
||||
#[arg(short, long)]
|
||||
config: PathBuf,
|
||||
mountpoint: PathBuf,
|
||||
},
|
||||
|
||||
/// Get daemon status
|
||||
Status,
|
||||
|
||||
/// Cache management
|
||||
Cache {
|
||||
#[command(subcommand)]
|
||||
command: CacheCommand,
|
||||
},
|
||||
|
||||
/// Search library
|
||||
Search {
|
||||
query: String,
|
||||
#[arg(short, long, default_value = "100")]
|
||||
limit: u32,
|
||||
},
|
||||
|
||||
/// Origin management
|
||||
Origin {
|
||||
#[command(subcommand)]
|
||||
command: OriginCommand,
|
||||
},
|
||||
|
||||
/// Subscribe to events
|
||||
Events {
|
||||
#[arg(short, long)]
|
||||
r#type: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CacheCommand {
|
||||
Stats,
|
||||
Clear { origin: Option<String> },
|
||||
Prefetch { paths: Vec<String> },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum OriginCommand {
|
||||
List,
|
||||
Health { origin_id: String },
|
||||
Rescan { origin_id: String },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## systemd Service (`dist/musicfs.service`)
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=MusicFS - Metadata-Organized Music Filesystem
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/musicfs mount --config /etc/musicfs/config.toml /mnt/music
|
||||
ExecStop=/usr/bin/musicfs shutdown
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
User=musicfs
|
||||
Group=musicfs
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/var/cache/musicfs /mnt/music
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_grpc_status` | Unit | GetStatus RPC (FR-17.1) |
|
||||
| `test_grpc_cache_stats` | Unit | GetCacheStats RPC |
|
||||
| `test_grpc_cache_clear` | Unit | ClearCache RPC (FR-17.3) |
|
||||
| `test_grpc_origins_list` | Unit | ListOrigins RPC |
|
||||
| `test_grpc_origin_rescan` | Integration | RescanOrigin streaming |
|
||||
| `test_grpc_events_stream` | Integration | Event streaming (FR-18.1) |
|
||||
| `test_grpc_prefetch_stream` | Integration | Prefetch progress |
|
||||
| `test_webhook_dispatch` | Unit | Webhook delivery (FR-18.2) |
|
||||
| `test_webhook_retry` | Unit | Webhook retry on failure |
|
||||
| `test_webhook_hmac_signature` | Unit | HMAC-SHA256 signing |
|
||||
| `test_metrics_prometheus` | Unit | Prometheus format (NFR-6.1) |
|
||||
| `test_metrics_http_endpoint` | Integration | HTTP metrics endpoint |
|
||||
| `test_cli_commands` | Integration | CLI works |
|
||||
| `test_systemd_service` | E2E | Service lifecycle |
|
||||
| `test_mpv_playback` | E2E | mpv compatibility (NFR-12.1) |
|
||||
| `test_vlc_playback` | E2E | VLC compatibility (NFR-12.2) |
|
||||
| `test_file_manager_operations` | E2E | File manager browsing (NFR-12.3) |
|
||||
| `test_concurrent_player_access` | E2E | Multiple players concurrently |
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] gRPC API fully functional (all RPCs from architecture.md 4.3.7)
|
||||
- [ ] Event streaming works with filtering
|
||||
- [ ] Webhook notifications delivered with HMAC signing
|
||||
- [ ] Prometheus metrics exported on HTTP endpoint
|
||||
- [ ] CLI feature-complete with all commands
|
||||
- [ ] systemd service works (start, stop, restart)
|
||||
- [ ] mpv, VLC playback verified (E2E tests)
|
||||
- [ ] File manager browsing verified
|
||||
- [ ] All acceptance tests pass
|
||||
|
||||
---
|
||||
|
||||
## Architecture Alignment
|
||||
|
||||
Per architecture.md section 4.3.7:
|
||||
- gRPC over Unix socket ✓
|
||||
- Protocol Buffers for type safety ✓
|
||||
- Server-streaming for events, sync progress, prefetch ✓
|
||||
- CLI wraps gRPC client ✓
|
||||
|
||||
Per architecture.md section 5.2:
|
||||
- Prometheus metrics format ✓
|
||||
- Golden signals: latency, traffic, errors, saturation ✓
|
||||
|
||||
Per requirements.md:
|
||||
- FR-17.1: Unix socket control ✓
|
||||
- FR-17.2: gRPC with Protocol Buffers ✓
|
||||
- FR-17.3: Cache management commands ✓
|
||||
- FR-17.4: Runtime configuration ✓
|
||||
- FR-17.5: Graceful shutdown ✓
|
||||
- FR-18.1: File access events ✓
|
||||
- FR-18.2: Webhook notifications ✓ (HTTP webhooks with HMAC)
|
||||
- FR-18.3: Event streaming ✓
|
||||
- FR-18.4: Access pattern logging ✓
|
||||
- NFR-10.1: Configurable logging ✓
|
||||
- NFR-10.2: Metrics exposure ✓
|
||||
- NFR-10.3: Health check ✓
|
||||
- NFR-10.4: Prometheus integration ✓
|
||||
- NFR-12.1: mpv compatibility ✓ (E2E tests)
|
||||
- NFR-12.2: VLC compatibility ✓ (E2E tests)
|
||||
- NFR-12.3: File manager compatibility ✓ (E2E tests)
|
||||
@@ -1,624 +0,0 @@
|
||||
# Week 12: External Metadata Integration
|
||||
|
||||
**Phase**: 5 - P1 Feature Completion
|
||||
**Goal**: Integrate external metadata sources for automatic tagging and artwork
|
||||
**Requirements**: FR-21.1-21.4, FR-16.5
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Requirements |
|
||||
|------|-------|-------|--------------|
|
||||
| MusicBrainz client | musicfs-external | `musicbrainz.rs` | FR-21.1 |
|
||||
| Discogs client | musicfs-external | `discogs.rs` | FR-21.2 |
|
||||
| Last.fm client | musicfs-external | `lastfm.rs` | FR-21.3 |
|
||||
| AcoustID/Chromaprint | musicfs-external | `acoustid.rs` | FR-21.4 |
|
||||
| Online artwork fetch | musicfs-external | `artwork_fetch.rs` | FR-16.5 |
|
||||
| Metadata enrichment | musicfs-external | `enrichment.rs` | All |
|
||||
| Plugin integration | musicfs-plugins | `metadata_plugin.rs` | FR-21.5 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `musicfs-external` Crate
|
||||
|
||||
### 1.1 `Cargo.toml`
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "musicfs-external"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
chromaprint = "0.6" # Audio fingerprinting
|
||||
base64 = "0.21"
|
||||
|
||||
[dev-dependencies]
|
||||
wiremock = "0.5" # Mock HTTP responses
|
||||
tokio-test = "0.4"
|
||||
```
|
||||
|
||||
### 1.2 `src/lib.rs`
|
||||
|
||||
```rust
|
||||
pub mod musicbrainz;
|
||||
pub mod discogs;
|
||||
pub mod lastfm;
|
||||
pub mod acoustid;
|
||||
pub mod artwork_fetch;
|
||||
pub mod enrichment;
|
||||
|
||||
pub use enrichment::MetadataEnricher;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: MusicBrainz Client (`musicfs-external/src/musicbrainz.rs`)
|
||||
|
||||
```rust
|
||||
use serde::Deserialize;
|
||||
|
||||
const MB_API: &str = "https://musicbrainz.org/ws/2";
|
||||
const USER_AGENT: &str = "MusicFS/0.1.0 (https://github.com/user/musicfs)";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MbRecording {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub length: Option<u64>,
|
||||
#[serde(rename = "artist-credit")]
|
||||
pub artist_credit: Vec<ArtistCredit>,
|
||||
pub releases: Option<Vec<MbRelease>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MbRelease {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub date: Option<String>,
|
||||
#[serde(rename = "release-group")]
|
||||
pub release_group: Option<MbReleaseGroup>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MbReleaseGroup {
|
||||
pub id: String,
|
||||
#[serde(rename = "primary-type")]
|
||||
pub primary_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ArtistCredit {
|
||||
pub artist: MbArtist,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MbArtist {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(rename = "sort-name")]
|
||||
pub sort_name: String,
|
||||
}
|
||||
|
||||
pub struct MusicBrainzClient {
|
||||
client: reqwest::Client,
|
||||
rate_limiter: RateLimiter, // 1 req/sec per MB guidelines
|
||||
}
|
||||
|
||||
impl MusicBrainzClient {
|
||||
pub fn new() -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("client build");
|
||||
|
||||
Self {
|
||||
client,
|
||||
rate_limiter: RateLimiter::new(Duration::from_secs(1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Search by recording title + artist (FR-21.1)
|
||||
pub async fn search_recording(
|
||||
&self,
|
||||
title: &str,
|
||||
artist: Option<&str>,
|
||||
) -> Result<Vec<MbRecording>, ExternalError> {
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let mut query = format!("recording:{}", title);
|
||||
if let Some(artist) = artist {
|
||||
query.push_str(&format!(" AND artist:{}", artist));
|
||||
}
|
||||
|
||||
let resp = self.client
|
||||
.get(format!("{}/recording", MB_API))
|
||||
.query(&[
|
||||
("query", query.as_str()),
|
||||
("fmt", "json"),
|
||||
("limit", "5"),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: SearchResponse<MbRecording> = resp.json().await?;
|
||||
Ok(body.recordings)
|
||||
}
|
||||
|
||||
/// Get release artwork from Cover Art Archive
|
||||
pub async fn get_cover_art(&self, release_id: &str) -> Result<Option<Vec<u8>>, ExternalError> {
|
||||
let url = format!("https://coverartarchive.org/release/{}/front-500", release_id);
|
||||
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
if resp.status() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let bytes = resp.bytes().await?;
|
||||
Ok(Some(bytes.to_vec()))
|
||||
}
|
||||
|
||||
/// Lookup recording by MusicBrainz ID
|
||||
pub async fn get_recording(&self, mbid: &str) -> Result<MbRecording, ExternalError> {
|
||||
self.rate_limiter.wait().await;
|
||||
|
||||
let resp = self.client
|
||||
.get(format!("{}/recording/{}", MB_API, mbid))
|
||||
.query(&[
|
||||
("inc", "artist-credits+releases+release-groups"),
|
||||
("fmt", "json"),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
}
|
||||
|
||||
struct RateLimiter {
|
||||
interval: Duration,
|
||||
last_request: Mutex<Instant>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new(interval: Duration) -> Self {
|
||||
Self {
|
||||
interval,
|
||||
last_request: Mutex::new(Instant::now() - interval),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
let mut last = self.last_request.lock().await;
|
||||
let elapsed = last.elapsed();
|
||||
if elapsed < self.interval {
|
||||
tokio::time::sleep(self.interval - elapsed).await;
|
||||
}
|
||||
*last = Instant::now();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Discogs Client (`musicfs-external/src/discogs.rs`)
|
||||
|
||||
```rust
|
||||
const DISCOGS_API: &str = "https://api.discogs.com";
|
||||
|
||||
pub struct DiscogsClient {
|
||||
client: reqwest::Client,
|
||||
token: Option<String>,
|
||||
rate_limiter: RateLimiter, // 60 req/min authenticated
|
||||
}
|
||||
|
||||
impl DiscogsClient {
|
||||
pub fn new(token: Option<String>) -> Self;
|
||||
|
||||
/// Search releases (FR-21.2)
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
artist: Option<&str>,
|
||||
) -> Result<Vec<DiscogsRelease>, ExternalError>;
|
||||
|
||||
/// Get master release details
|
||||
pub async fn get_master(&self, id: u64) -> Result<DiscogsMaster, ExternalError>;
|
||||
|
||||
/// Get release images
|
||||
pub async fn get_images(&self, release_id: u64) -> Result<Vec<DiscogsImage>, ExternalError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DiscogsRelease {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub year: Option<u16>,
|
||||
pub thumb: Option<String>,
|
||||
pub master_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DiscogsImage {
|
||||
pub uri: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(rename = "type")]
|
||||
pub image_type: String, // "primary" or "secondary"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Last.fm Client (`musicfs-external/src/lastfm.rs`)
|
||||
|
||||
```rust
|
||||
const LASTFM_API: &str = "https://ws.audioscrobbler.com/2.0";
|
||||
|
||||
pub struct LastFmClient {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl LastFmClient {
|
||||
pub fn new(api_key: String) -> Self;
|
||||
|
||||
/// Get track info with play counts, tags (FR-21.3)
|
||||
pub async fn get_track_info(
|
||||
&self,
|
||||
track: &str,
|
||||
artist: &str,
|
||||
) -> Result<LastFmTrack, ExternalError>;
|
||||
|
||||
/// Get album info with artwork
|
||||
pub async fn get_album_info(
|
||||
&self,
|
||||
album: &str,
|
||||
artist: &str,
|
||||
) -> Result<LastFmAlbum, ExternalError>;
|
||||
|
||||
/// Get artist info
|
||||
pub async fn get_artist_info(&self, artist: &str) -> Result<LastFmArtist, ExternalError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LastFmTrack {
|
||||
pub name: String,
|
||||
pub playcount: Option<u64>,
|
||||
pub listeners: Option<u64>,
|
||||
pub duration: Option<u64>,
|
||||
pub toptags: Option<Tags>,
|
||||
pub album: Option<LastFmAlbumRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LastFmAlbum {
|
||||
pub name: String,
|
||||
pub artist: String,
|
||||
pub image: Vec<LastFmImage>,
|
||||
pub tracks: Option<Tracks>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LastFmImage {
|
||||
#[serde(rename = "#text")]
|
||||
pub url: String,
|
||||
pub size: String, // "small", "medium", "large", "extralarge", "mega"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: AcoustID/Chromaprint (`musicfs-external/src/acoustid.rs`)
|
||||
|
||||
```rust
|
||||
use chromaprint::{Fingerprinter, Configuration};
|
||||
|
||||
const ACOUSTID_API: &str = "https://api.acoustid.org/v2/lookup";
|
||||
|
||||
pub struct AcoustIdClient {
|
||||
client: reqwest::Client,
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl AcoustIdClient {
|
||||
pub fn new(api_key: String) -> Self;
|
||||
|
||||
/// Generate fingerprint from audio data (FR-21.4)
|
||||
pub fn fingerprint(&self, samples: &[i16], sample_rate: u32) -> Result<String, ExternalError> {
|
||||
let config = Configuration::preset_test1();
|
||||
let mut fp = Fingerprinter::new(&config);
|
||||
|
||||
fp.start(sample_rate, 1)?; // mono
|
||||
fp.feed(samples)?;
|
||||
fp.finish()?;
|
||||
|
||||
Ok(fp.fingerprint().to_string())
|
||||
}
|
||||
|
||||
/// Lookup fingerprint on AcoustID database
|
||||
pub async fn lookup(
|
||||
&self,
|
||||
fingerprint: &str,
|
||||
duration: u32,
|
||||
) -> Result<Vec<AcoustIdResult>, ExternalError> {
|
||||
let resp = self.client
|
||||
.get(ACOUSTID_API)
|
||||
.query(&[
|
||||
("client", self.api_key.as_str()),
|
||||
("fingerprint", fingerprint),
|
||||
("duration", &duration.to_string()),
|
||||
("meta", "recordings+releasegroups"),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body: AcoustIdResponse = resp.json().await?;
|
||||
Ok(body.results)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AcoustIdResult {
|
||||
pub id: String,
|
||||
pub score: f32,
|
||||
pub recordings: Option<Vec<AcoustIdRecording>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AcoustIdRecording {
|
||||
pub id: String, // MusicBrainz recording ID
|
||||
pub title: Option<String>,
|
||||
pub artists: Option<Vec<AcoustIdArtist>>,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Online Artwork Fetch (`musicfs-external/src/artwork_fetch.rs`)
|
||||
|
||||
```rust
|
||||
pub struct ArtworkFetcher {
|
||||
musicbrainz: MusicBrainzClient,
|
||||
discogs: Option<DiscogsClient>,
|
||||
lastfm: Option<LastFmClient>,
|
||||
}
|
||||
|
||||
impl ArtworkFetcher {
|
||||
/// Fetch missing artwork from online sources (FR-16.5)
|
||||
/// Tries sources in order: MusicBrainz Cover Art Archive → Discogs → Last.fm
|
||||
pub async fn fetch_artwork(
|
||||
&self,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
size: ArtworkSize,
|
||||
) -> Result<Option<ArtworkData>, ExternalError> {
|
||||
// 1. Try MusicBrainz release search → Cover Art Archive
|
||||
if let Some(art) = self.try_musicbrainz(artist, album, size).await? {
|
||||
return Ok(Some(art));
|
||||
}
|
||||
|
||||
// 2. Try Discogs
|
||||
if let Some(discogs) = &self.discogs {
|
||||
if let Some(art) = self.try_discogs(discogs, artist, album, size).await? {
|
||||
return Ok(Some(art));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try Last.fm
|
||||
if let Some(lastfm) = &self.lastfm {
|
||||
if let Some(art) = self.try_lastfm(lastfm, artist, album, size).await? {
|
||||
return Ok(Some(art));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn try_musicbrainz(
|
||||
&self,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
size: ArtworkSize,
|
||||
) -> Result<Option<ArtworkData>, ExternalError> {
|
||||
// Search for release, get cover art from Cover Art Archive
|
||||
let releases = self.musicbrainz.search_release(album, Some(artist)).await?;
|
||||
|
||||
for release in releases.iter().take(3) {
|
||||
if let Some(art) = self.musicbrainz.get_cover_art(&release.id).await? {
|
||||
return Ok(Some(ArtworkData {
|
||||
data: art,
|
||||
source: ArtworkSource::MusicBrainz,
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArtworkData {
|
||||
pub data: Vec<u8>,
|
||||
pub source: ArtworkSource,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ArtworkSource {
|
||||
MusicBrainz,
|
||||
Discogs,
|
||||
LastFm,
|
||||
Embedded,
|
||||
}
|
||||
|
||||
pub enum ArtworkSize {
|
||||
Small, // 150px
|
||||
Medium, // 300px
|
||||
Large, // 500px
|
||||
Original,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Metadata Enrichment (`musicfs-external/src/enrichment.rs`)
|
||||
|
||||
```rust
|
||||
pub struct MetadataEnricher {
|
||||
musicbrainz: MusicBrainzClient,
|
||||
acoustid: Option<AcoustIdClient>,
|
||||
artwork_fetcher: ArtworkFetcher,
|
||||
}
|
||||
|
||||
impl MetadataEnricher {
|
||||
/// Enrich metadata from external sources
|
||||
pub async fn enrich(&self, meta: &AudioMeta) -> Result<EnrichedMetadata, ExternalError> {
|
||||
let mut enriched = EnrichedMetadata::from(meta);
|
||||
|
||||
// If we have title + artist, search MusicBrainz
|
||||
if let (Some(title), Some(artist)) = (&meta.title, &meta.artist) {
|
||||
let recordings = self.musicbrainz.search_recording(title, Some(artist)).await?;
|
||||
|
||||
if let Some(best) = recordings.first() {
|
||||
enriched.musicbrainz_recording_id = Some(best.id.clone());
|
||||
|
||||
// Enrich with release info
|
||||
if let Some(releases) = &best.releases {
|
||||
if let Some(release) = releases.first() {
|
||||
enriched.musicbrainz_release_id = Some(release.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
/// Identify unknown track by audio fingerprint
|
||||
pub async fn identify_by_fingerprint(
|
||||
&self,
|
||||
samples: &[i16],
|
||||
sample_rate: u32,
|
||||
duration: u32,
|
||||
) -> Result<Option<IdentifiedTrack>, ExternalError> {
|
||||
let acoustid = self.acoustid.as_ref()
|
||||
.ok_or(ExternalError::ServiceNotConfigured("AcoustID"))?;
|
||||
|
||||
let fingerprint = acoustid.fingerprint(samples, sample_rate)?;
|
||||
let results = acoustid.lookup(&fingerprint, duration).await?;
|
||||
|
||||
// Return best match above threshold
|
||||
results.into_iter()
|
||||
.filter(|r| r.score > 0.8)
|
||||
.flat_map(|r| r.recordings)
|
||||
.flatten()
|
||||
.next()
|
||||
.map(|rec| IdentifiedTrack {
|
||||
title: rec.title,
|
||||
musicbrainz_id: Some(rec.id),
|
||||
artists: rec.artists.map(|a| a.into_iter().map(|x| x.name).collect()),
|
||||
})
|
||||
.pipe(Ok)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct EnrichedMetadata {
|
||||
pub original: AudioMeta,
|
||||
pub musicbrainz_recording_id: Option<String>,
|
||||
pub musicbrainz_release_id: Option<String>,
|
||||
pub musicbrainz_artist_id: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
pub play_count: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct IdentifiedTrack {
|
||||
pub title: Option<String>,
|
||||
pub musicbrainz_id: Option<String>,
|
||||
pub artists: Option<Vec<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
```toml
|
||||
[external]
|
||||
# MusicBrainz (no auth required, rate limited to 1 req/sec)
|
||||
musicbrainz.enabled = true
|
||||
|
||||
# Discogs (optional, requires token for higher rate limits)
|
||||
discogs.enabled = true
|
||||
discogs.token = "your_discogs_token"
|
||||
|
||||
# Last.fm (requires API key)
|
||||
lastfm.enabled = true
|
||||
lastfm.api_key = "your_lastfm_api_key"
|
||||
|
||||
# AcoustID (requires API key)
|
||||
acoustid.enabled = true
|
||||
acoustid.api_key = "your_acoustid_api_key"
|
||||
|
||||
# Artwork fetching behavior
|
||||
artwork.fetch_missing = true
|
||||
artwork.cache_fetched = true
|
||||
artwork.preferred_size = "large" # small, medium, large, original
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_musicbrainz_search` | Integration | Recording search (FR-21.1) |
|
||||
| `test_musicbrainz_cover_art` | Integration | Cover Art Archive |
|
||||
| `test_discogs_search` | Integration | Release search (FR-21.2) |
|
||||
| `test_lastfm_track_info` | Integration | Track metadata (FR-21.3) |
|
||||
| `test_acoustid_fingerprint` | Unit | Chromaprint generation |
|
||||
| `test_acoustid_lookup` | Integration | Fingerprint lookup (FR-21.4) |
|
||||
| `test_artwork_fetch_cascade` | Integration | Multi-source artwork (FR-16.5) |
|
||||
| `test_metadata_enrichment` | Integration | Full enrichment flow |
|
||||
| `test_rate_limiting` | Unit | Rate limiter works |
|
||||
| `test_mock_responses` | Unit | Offline testing with mocks |
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] MusicBrainz search returns relevant recordings
|
||||
- [ ] Cover Art Archive artwork downloads work
|
||||
- [ ] Discogs integration retrieves release info
|
||||
- [ ] Last.fm integration retrieves track/artist info
|
||||
- [ ] AcoustID fingerprinting identifies tracks
|
||||
- [ ] Artwork fetcher tries all sources in cascade
|
||||
- [ ] Metadata enricher adds external IDs
|
||||
- [ ] Rate limiting prevents API abuse
|
||||
- [ ] All tests pass with mock HTTP responses
|
||||
|
||||
---
|
||||
|
||||
## Architecture Alignment
|
||||
|
||||
Per requirements.md:
|
||||
- FR-21.1: MusicBrainz for canonical metadata ✓
|
||||
- FR-21.2: Discogs for release info, artwork ✓
|
||||
- FR-21.3: Last.fm for play counts, tags ✓
|
||||
- FR-21.4: AcoustID for audio fingerprinting ✓
|
||||
- FR-16.5: Fetch missing artwork from online ✓
|
||||
|
||||
Per architecture.md section 4.3.4:
|
||||
- External metadata via `MetadataPlugin` trait ✓
|
||||
- Plugin architecture allows adding more sources ✓
|
||||
@@ -1,699 +0,0 @@
|
||||
# Week 13: Import & Export
|
||||
|
||||
**Phase**: 5 - P1 Feature Completion
|
||||
**Goal**: Import metadata from existing library managers, export library data
|
||||
**Requirements**: FR-22.1-22.3
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Requirements |
|
||||
|------|-------|-------|--------------|
|
||||
| Beets database import | musicfs-import | `beets.rs` | FR-22.1 |
|
||||
| iTunes/Apple Music import | musicfs-import | `itunes.rs` | FR-22.2 |
|
||||
| Library export | musicfs-import | `export.rs` | FR-22.3 |
|
||||
| Import CLI | musicfs-cli | `import.rs` | All |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `musicfs-import` Crate
|
||||
|
||||
### 1.1 `Cargo.toml`
|
||||
|
||||
```toml
|
||||
[package]
|
||||
name = "musicfs-import"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
musicfs-core = { path = "../musicfs-core" }
|
||||
musicfs-cache = { path = "../musicfs-cache" }
|
||||
rusqlite = { workspace = true, features = ["bundled"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
plist = "1.5" # For iTunes XML parsing
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
thiserror.workspace = true
|
||||
csv = "1.3"
|
||||
url = "2.4"
|
||||
percent-encoding = "2.3"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
```
|
||||
|
||||
### 1.2 `src/lib.rs`
|
||||
|
||||
```rust
|
||||
pub mod beets;
|
||||
pub mod itunes;
|
||||
pub mod export;
|
||||
|
||||
use musicfs_core::Result;
|
||||
|
||||
/// Common import result
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ImportResult {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub errors: Vec<ImportError>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ImportError {
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// Import progress callback
|
||||
pub type ProgressCallback = Box<dyn Fn(ImportProgress) + Send>;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImportProgress {
|
||||
pub current: usize,
|
||||
pub total: usize,
|
||||
pub current_file: String,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Beets Database Import (`musicfs-import/src/beets.rs`)
|
||||
|
||||
```rust
|
||||
use rusqlite::{Connection, params};
|
||||
use std::path::Path;
|
||||
|
||||
/// Beets database schema (simplified)
|
||||
/// Full schema: https://beets.readthedocs.io/en/stable/dev/db.html
|
||||
#[derive(Debug)]
|
||||
pub struct BeetsItem {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub title: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
pub track: Option<i32>,
|
||||
pub disc: Option<i32>,
|
||||
pub length: Option<f64>,
|
||||
pub bitrate: Option<i32>,
|
||||
pub sample_rate: Option<i32>,
|
||||
pub format: Option<String>,
|
||||
pub mb_trackid: Option<String>,
|
||||
pub mb_albumid: Option<String>,
|
||||
pub mb_artistid: Option<String>,
|
||||
pub mtime: f64,
|
||||
}
|
||||
|
||||
pub struct BeetsImporter {
|
||||
beets_db: Connection,
|
||||
target_db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl BeetsImporter {
|
||||
/// Open beets database for import (FR-22.1)
|
||||
pub fn new(beets_db_path: &Path, target_db: Arc<Database>) -> Result<Self, ImportError> {
|
||||
let conn = Connection::open_with_flags(
|
||||
beets_db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)?;
|
||||
|
||||
// Verify this is a beets database
|
||||
let tables: Vec<String> = conn
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")?
|
||||
.query_map([], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if !tables.contains(&"items".to_string()) {
|
||||
return Err(ImportError::InvalidDatabase("Not a beets database"));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
beets_db: conn,
|
||||
target_db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count items to import
|
||||
pub fn count_items(&self) -> Result<usize, ImportError> {
|
||||
self.beets_db
|
||||
.query_row("SELECT COUNT(*) FROM items", [], |row| row.get(0))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Import all items with progress callback
|
||||
pub fn import_all(&self, progress: Option<ProgressCallback>) -> Result<ImportResult, ImportError> {
|
||||
let total = self.count_items()?;
|
||||
let mut result = ImportResult::default();
|
||||
|
||||
let mut stmt = self.beets_db.prepare(r#"
|
||||
SELECT id, path, title, artist, album, albumartist, genre,
|
||||
year, track, disc, length, bitrate, samplerate, format,
|
||||
mb_trackid, mb_albumid, mb_artistid, mtime
|
||||
FROM items
|
||||
"#)?;
|
||||
|
||||
let items = stmt.query_map([], |row| {
|
||||
Ok(BeetsItem {
|
||||
id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
artist: row.get(3)?,
|
||||
album: row.get(4)?,
|
||||
album_artist: row.get(5)?,
|
||||
genre: row.get(6)?,
|
||||
year: row.get(7)?,
|
||||
track: row.get(8)?,
|
||||
disc: row.get(9)?,
|
||||
length: row.get(10)?,
|
||||
bitrate: row.get(11)?,
|
||||
sample_rate: row.get(12)?,
|
||||
format: row.get(13)?,
|
||||
mb_trackid: row.get(14)?,
|
||||
mb_albumid: row.get(15)?,
|
||||
mb_artistid: row.get(16)?,
|
||||
mtime: row.get(17)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
for (idx, item) in items.enumerate() {
|
||||
match item {
|
||||
Ok(item) => {
|
||||
if let Some(ref cb) = progress {
|
||||
cb(ImportProgress {
|
||||
current: idx + 1,
|
||||
total,
|
||||
current_file: item.path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
match self.import_item(&item) {
|
||||
Ok(_) => result.imported += 1,
|
||||
Err(e) => {
|
||||
result.errors.push(ImportError {
|
||||
path: item.path,
|
||||
reason: e.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.skipped += 1;
|
||||
result.errors.push(ImportError {
|
||||
path: format!("item_{}", idx),
|
||||
reason: e.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn import_item(&self, item: &BeetsItem) -> Result<(), ImportError> {
|
||||
let path = Path::new(&item.path);
|
||||
|
||||
// Convert to our AudioMeta
|
||||
let audio_meta = AudioMeta {
|
||||
title: item.title.clone(),
|
||||
artist: item.artist.clone(),
|
||||
album: item.album.clone(),
|
||||
album_artist: item.album_artist.clone(),
|
||||
genre: item.genre.clone(),
|
||||
year: item.year.map(|y| y as u32),
|
||||
track: item.track.map(|t| t as u32),
|
||||
disc: item.disc.map(|d| d as u32),
|
||||
duration_ms: item.length.map(|l| (l * 1000.0) as u64),
|
||||
bitrate: item.bitrate.map(|b| b as u32),
|
||||
sample_rate: item.sample_rate.map(|s| s as u32),
|
||||
format: AudioFormat::from_extension(
|
||||
path.extension().and_then(|e| e.to_str()).unwrap_or("")
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Generate virtual path using our resolver
|
||||
let virtual_path = VirtualPath::from_metadata(&audio_meta, path);
|
||||
|
||||
// Import to our database
|
||||
self.target_db.upsert_file(
|
||||
&OriginId::from("beets-import"),
|
||||
path,
|
||||
&virtual_path,
|
||||
&audio_meta,
|
||||
std::time::UNIX_EPOCH + std::time::Duration::from_secs_f64(item.mtime),
|
||||
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: iTunes/Apple Music Import (`musicfs-import/src/itunes.rs`)
|
||||
|
||||
```rust
|
||||
use plist::Value;
|
||||
use std::collections::HashMap;
|
||||
use url::Url;
|
||||
|
||||
/// iTunes Library XML format
|
||||
#[derive(Debug)]
|
||||
pub struct ItunesTrack {
|
||||
pub track_id: u64,
|
||||
pub name: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub album_artist: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub track_number: Option<u32>,
|
||||
pub disc_number: Option<u32>,
|
||||
pub total_time: Option<u64>, // milliseconds
|
||||
pub bit_rate: Option<u32>,
|
||||
pub sample_rate: Option<u32>,
|
||||
pub location: Option<String>, // file:// URL
|
||||
pub date_added: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ItunesImporter {
|
||||
tracks: Vec<ItunesTrack>,
|
||||
target_db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl ItunesImporter {
|
||||
/// Parse iTunes Library.xml (FR-22.2)
|
||||
pub fn from_xml(xml_path: &Path, target_db: Arc<Database>) -> Result<Self, ImportError> {
|
||||
let file = std::fs::File::open(xml_path)?;
|
||||
let plist: Value = plist::from_reader(file)?;
|
||||
|
||||
let dict = plist.as_dictionary()
|
||||
.ok_or(ImportError::InvalidFormat("Expected dictionary at root"))?;
|
||||
|
||||
let tracks_dict = dict.get("Tracks")
|
||||
.and_then(|v| v.as_dictionary())
|
||||
.ok_or(ImportError::InvalidFormat("Missing Tracks dictionary"))?;
|
||||
|
||||
let mut tracks = Vec::new();
|
||||
|
||||
for (_, track_value) in tracks_dict {
|
||||
if let Some(track_dict) = track_value.as_dictionary() {
|
||||
tracks.push(Self::parse_track(track_dict)?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self { tracks, target_db })
|
||||
}
|
||||
|
||||
fn parse_track(dict: &plist::Dictionary) -> Result<ItunesTrack, ImportError> {
|
||||
Ok(ItunesTrack {
|
||||
track_id: dict.get("Track ID")
|
||||
.and_then(|v| v.as_unsigned_integer())
|
||||
.unwrap_or(0),
|
||||
name: dict.get("Name").and_then(|v| v.as_string()).map(String::from),
|
||||
artist: dict.get("Artist").and_then(|v| v.as_string()).map(String::from),
|
||||
album: dict.get("Album").and_then(|v| v.as_string()).map(String::from),
|
||||
album_artist: dict.get("Album Artist").and_then(|v| v.as_string()).map(String::from),
|
||||
genre: dict.get("Genre").and_then(|v| v.as_string()).map(String::from),
|
||||
year: dict.get("Year").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32),
|
||||
track_number: dict.get("Track Number").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32),
|
||||
disc_number: dict.get("Disc Number").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32),
|
||||
total_time: dict.get("Total Time").and_then(|v| v.as_unsigned_integer()),
|
||||
bit_rate: dict.get("Bit Rate").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32),
|
||||
sample_rate: dict.get("Sample Rate").and_then(|v| v.as_unsigned_integer()).map(|v| v as u32),
|
||||
location: dict.get("Location").and_then(|v| v.as_string()).map(String::from),
|
||||
date_added: dict.get("Date Added").and_then(|v| v.as_string()).map(String::from),
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert file:// URL to path
|
||||
fn url_to_path(url_str: &str) -> Option<PathBuf> {
|
||||
Url::parse(url_str).ok()
|
||||
.filter(|u| u.scheme() == "file")
|
||||
.and_then(|u| u.to_file_path().ok())
|
||||
}
|
||||
|
||||
pub fn count_tracks(&self) -> usize {
|
||||
self.tracks.len()
|
||||
}
|
||||
|
||||
/// Import all tracks
|
||||
pub fn import_all(&self, progress: Option<ProgressCallback>) -> Result<ImportResult, ImportError> {
|
||||
let total = self.tracks.len();
|
||||
let mut result = ImportResult::default();
|
||||
|
||||
for (idx, track) in self.tracks.iter().enumerate() {
|
||||
if let Some(ref cb) = progress {
|
||||
cb(ImportProgress {
|
||||
current: idx + 1,
|
||||
total,
|
||||
current_file: track.name.clone().unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
|
||||
// Skip tracks without location
|
||||
let Some(ref location) = track.location else {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(path) = Self::url_to_path(location) else {
|
||||
result.skipped += 1;
|
||||
result.errors.push(ImportError {
|
||||
path: location.clone(),
|
||||
reason: "Invalid file URL".to_string(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
match self.import_track(track, &path) {
|
||||
Ok(_) => result.imported += 1,
|
||||
Err(e) => {
|
||||
result.errors.push(ImportError {
|
||||
path: path.display().to_string(),
|
||||
reason: e.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn import_track(&self, track: &ItunesTrack, path: &Path) -> Result<(), ImportError> {
|
||||
let audio_meta = AudioMeta {
|
||||
title: track.name.clone(),
|
||||
artist: track.artist.clone(),
|
||||
album: track.album.clone(),
|
||||
album_artist: track.album_artist.clone(),
|
||||
genre: track.genre.clone(),
|
||||
year: track.year,
|
||||
track: track.track_number,
|
||||
disc: track.disc_number,
|
||||
duration_ms: track.total_time,
|
||||
bitrate: track.bit_rate,
|
||||
sample_rate: track.sample_rate,
|
||||
format: AudioFormat::from_extension(
|
||||
path.extension().and_then(|e| e.to_str()).unwrap_or("")
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let virtual_path = VirtualPath::from_metadata(&audio_meta, path);
|
||||
|
||||
let mtime = std::fs::metadata(path)
|
||||
.map(|m| m.modified().unwrap_or(std::time::UNIX_EPOCH))
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
|
||||
let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
|
||||
self.target_db.upsert_file(
|
||||
&OriginId::from("itunes-import"),
|
||||
path,
|
||||
&virtual_path,
|
||||
&audio_meta,
|
||||
mtime,
|
||||
size,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Library Export (`musicfs-import/src/export.rs`)
|
||||
|
||||
```rust
|
||||
use csv::Writer;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExportedTrack {
|
||||
pub virtual_path: String,
|
||||
pub real_path: String,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub album_artist: String,
|
||||
pub genre: String,
|
||||
pub year: Option<u32>,
|
||||
pub track: Option<u32>,
|
||||
pub disc: Option<u32>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub format: String,
|
||||
pub musicbrainz_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct LibraryExporter {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl LibraryExporter {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Export library to CSV (FR-22.3)
|
||||
pub fn export_csv(&self, output: &Path) -> Result<usize, ExportError> {
|
||||
let files = self.db.list_all_files()?;
|
||||
let mut writer = Writer::from_path(output)?;
|
||||
|
||||
let mut count = 0;
|
||||
for file in files {
|
||||
let audio = file.audio.as_ref();
|
||||
|
||||
writer.serialize(ExportedTrack {
|
||||
virtual_path: file.virtual_path.as_str().to_string(),
|
||||
real_path: file.real_path.path.display().to_string(),
|
||||
title: audio.and_then(|a| a.title.clone()).unwrap_or_default(),
|
||||
artist: audio.and_then(|a| a.artist.clone()).unwrap_or_default(),
|
||||
album: audio.and_then(|a| a.album.clone()).unwrap_or_default(),
|
||||
album_artist: audio.and_then(|a| a.album_artist.clone()).unwrap_or_default(),
|
||||
genre: audio.and_then(|a| a.genre.clone()).unwrap_or_default(),
|
||||
year: audio.and_then(|a| a.year),
|
||||
track: audio.and_then(|a| a.track),
|
||||
disc: audio.and_then(|a| a.disc),
|
||||
duration_ms: audio.and_then(|a| a.duration_ms),
|
||||
format: audio.map(|a| format!("{:?}", a.format)).unwrap_or_default(),
|
||||
musicbrainz_id: None, // TODO: Include if enriched
|
||||
})?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
writer.flush()?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Export library to JSON
|
||||
pub fn export_json(&self, output: &Path) -> Result<usize, ExportError> {
|
||||
let files = self.db.list_all_files()?;
|
||||
|
||||
let tracks: Vec<ExportedTrack> = files.iter()
|
||||
.map(|file| {
|
||||
let audio = file.audio.as_ref();
|
||||
ExportedTrack {
|
||||
virtual_path: file.virtual_path.as_str().to_string(),
|
||||
real_path: file.real_path.path.display().to_string(),
|
||||
title: audio.and_then(|a| a.title.clone()).unwrap_or_default(),
|
||||
artist: audio.and_then(|a| a.artist.clone()).unwrap_or_default(),
|
||||
album: audio.and_then(|a| a.album.clone()).unwrap_or_default(),
|
||||
album_artist: audio.and_then(|a| a.album_artist.clone()).unwrap_or_default(),
|
||||
genre: audio.and_then(|a| a.genre.clone()).unwrap_or_default(),
|
||||
year: audio.and_then(|a| a.year),
|
||||
track: audio.and_then(|a| a.track),
|
||||
disc: audio.and_then(|a| a.disc),
|
||||
duration_ms: audio.and_then(|a| a.duration_ms),
|
||||
format: audio.map(|a| format!("{:?}", a.format)).unwrap_or_default(),
|
||||
musicbrainz_id: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let json = serde_json::to_string_pretty(&tracks)?;
|
||||
std::fs::write(output, json)?;
|
||||
|
||||
Ok(tracks.len())
|
||||
}
|
||||
|
||||
/// Export to M3U playlist format
|
||||
pub fn export_m3u(&self, output: &Path, base_path: Option<&Path>) -> Result<usize, ExportError> {
|
||||
let files = self.db.list_all_files()?;
|
||||
|
||||
let mut content = String::from("#EXTM3U\n");
|
||||
|
||||
for file in &files {
|
||||
let duration = file.audio.as_ref()
|
||||
.and_then(|a| a.duration_ms)
|
||||
.map(|d| d / 1000)
|
||||
.unwrap_or(0);
|
||||
|
||||
let title = file.audio.as_ref()
|
||||
.and_then(|a| a.title.clone())
|
||||
.unwrap_or_else(|| file.virtual_path.as_str().to_string());
|
||||
|
||||
let artist = file.audio.as_ref()
|
||||
.and_then(|a| a.artist.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
content.push_str(&format!(
|
||||
"#EXTINF:{},{} - {}\n",
|
||||
duration, artist, title
|
||||
));
|
||||
|
||||
// Use virtual path relative to base, or absolute real path
|
||||
let path = if let Some(base) = base_path {
|
||||
base.join(file.virtual_path.as_str().trim_start_matches('/'))
|
||||
.display().to_string()
|
||||
} else {
|
||||
file.real_path.path.display().to_string()
|
||||
};
|
||||
|
||||
content.push_str(&path);
|
||||
content.push('\n');
|
||||
}
|
||||
|
||||
std::fs::write(output, content)?;
|
||||
Ok(files.len())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Import CLI Commands (`musicfs-cli/src/import.rs`)
|
||||
|
||||
```rust
|
||||
#[derive(Subcommand)]
|
||||
pub enum ImportCommand {
|
||||
/// Import from beets database
|
||||
Beets {
|
||||
/// Path to beets library.db
|
||||
#[arg(short, long)]
|
||||
db: PathBuf,
|
||||
},
|
||||
|
||||
/// Import from iTunes Library.xml
|
||||
Itunes {
|
||||
/// Path to iTunes Library.xml
|
||||
#[arg(short, long)]
|
||||
xml: PathBuf,
|
||||
},
|
||||
|
||||
/// Export library
|
||||
Export {
|
||||
/// Output file path
|
||||
#[arg(short, long)]
|
||||
output: PathBuf,
|
||||
|
||||
/// Format: csv, json, m3u
|
||||
#[arg(short, long, default_value = "csv")]
|
||||
format: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn handle_import(cmd: ImportCommand, db: Arc<Database>) -> Result<()> {
|
||||
match cmd {
|
||||
ImportCommand::Beets { db: beets_path } => {
|
||||
println!("Importing from beets database: {:?}", beets_path);
|
||||
|
||||
let importer = BeetsImporter::new(&beets_path, db)?;
|
||||
let total = importer.count_items()?;
|
||||
println!("Found {} items to import", total);
|
||||
|
||||
let pb = ProgressBar::new(total as u64);
|
||||
let result = importer.import_all(Some(Box::new(move |p| {
|
||||
pb.set_position(p.current as u64);
|
||||
})))?;
|
||||
|
||||
println!("\nImport complete:");
|
||||
println!(" Imported: {}", result.imported);
|
||||
println!(" Skipped: {}", result.skipped);
|
||||
println!(" Errors: {}", result.errors.len());
|
||||
}
|
||||
|
||||
ImportCommand::Itunes { xml } => {
|
||||
println!("Importing from iTunes Library: {:?}", xml);
|
||||
|
||||
let importer = ItunesImporter::from_xml(&xml, db)?;
|
||||
let total = importer.count_tracks();
|
||||
println!("Found {} tracks to import", total);
|
||||
|
||||
let pb = ProgressBar::new(total as u64);
|
||||
let result = importer.import_all(Some(Box::new(move |p| {
|
||||
pb.set_position(p.current as u64);
|
||||
})))?;
|
||||
|
||||
println!("\nImport complete:");
|
||||
println!(" Imported: {}", result.imported);
|
||||
println!(" Skipped: {}", result.skipped);
|
||||
println!(" Errors: {}", result.errors.len());
|
||||
}
|
||||
|
||||
ImportCommand::Export { output, format } => {
|
||||
let exporter = LibraryExporter::new(db);
|
||||
|
||||
let count = match format.as_str() {
|
||||
"csv" => exporter.export_csv(&output)?,
|
||||
"json" => exporter.export_json(&output)?,
|
||||
"m3u" => exporter.export_m3u(&output, None)?,
|
||||
_ => return Err(anyhow::anyhow!("Unknown format: {}", format)),
|
||||
};
|
||||
|
||||
println!("Exported {} tracks to {:?}", count, output);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_beets_import_valid` | Integration | Beets database parsing (FR-22.1) |
|
||||
| `test_beets_import_missing_fields` | Unit | Handle incomplete metadata |
|
||||
| `test_itunes_xml_parsing` | Unit | iTunes XML parsing (FR-22.2) |
|
||||
| `test_itunes_url_to_path` | Unit | file:// URL conversion |
|
||||
| `test_itunes_import_tracks` | Integration | Full iTunes import |
|
||||
| `test_export_csv` | Unit | CSV export (FR-22.3) |
|
||||
| `test_export_json` | Unit | JSON export |
|
||||
| `test_export_m3u` | Unit | M3U playlist export |
|
||||
| `test_import_preserves_musicbrainz_ids` | Integration | External IDs preserved |
|
||||
| `test_import_deduplication` | Integration | No duplicates on re-import |
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] Beets database import works with real beets.db
|
||||
- [ ] iTunes Library.xml import parses all tracks
|
||||
- [ ] CSV/JSON/M3U export generates valid files
|
||||
- [ ] Progress reporting works during import
|
||||
- [ ] Errors are reported without crashing
|
||||
- [ ] Import is idempotent (re-import updates, doesn't duplicate)
|
||||
- [ ] MusicBrainz IDs from beets are preserved
|
||||
|
||||
---
|
||||
|
||||
## Architecture Alignment
|
||||
|
||||
Per requirements.md:
|
||||
- FR-22.1: Import from beets database ✓
|
||||
- FR-22.2: Import from iTunes/Apple Music ✓
|
||||
- FR-22.3: Export library metadata ✓
|
||||
@@ -1,633 +0,0 @@
|
||||
# Week 14: Extended Formats & Audio Fingerprinting
|
||||
|
||||
**Phase**: 5 - P1 Feature Completion
|
||||
**Goal**: Audio fingerprint search and audiobook format support
|
||||
**Requirements**: FR-14.4, FR-24.2
|
||||
|
||||
---
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Task | Crate | Files | Requirements |
|
||||
|------|-------|-------|--------------|
|
||||
| Fingerprint indexing | musicfs-search | `fingerprint.rs` | FR-14.4 |
|
||||
| Fingerprint search | musicfs-search | `fingerprint_search.rs` | FR-14.4 |
|
||||
| M4B audiobook support | musicfs-metadata | `formats/m4b.rs` | FR-24.2 |
|
||||
| Chapter extraction | musicfs-metadata | `chapters.rs` | FR-24.2 |
|
||||
| Virtual chapter files | musicfs-fuse | `ops/chapters.rs` | FR-24.2 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Audio Fingerprint Generation
|
||||
|
||||
### 1.1 Add Dependencies
|
||||
|
||||
```toml
|
||||
# In musicfs-search/Cargo.toml
|
||||
[dependencies]
|
||||
chromaprint = "0.6"
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
```
|
||||
|
||||
### 1.2 Fingerprint Generation (`musicfs-search/src/fingerprint.rs`)
|
||||
|
||||
```rust
|
||||
use chromaprint::{Configuration, Fingerprinter};
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use std::path::Path;
|
||||
|
||||
/// Audio fingerprint using Chromaprint algorithm
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFingerprint {
|
||||
pub raw: Vec<u32>,
|
||||
pub duration_secs: u32,
|
||||
}
|
||||
|
||||
impl AudioFingerprint {
|
||||
/// Generate fingerprint from audio file (FR-14.4)
|
||||
pub fn from_file(path: &Path) -> Result<Self, FingerprintError> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&Hint::new(), mss, &FormatOptions::default(), &MetadataOptions::default())?;
|
||||
|
||||
let mut format = probed.format;
|
||||
let track = format.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or(FingerprintError::NoAudioTrack)?;
|
||||
|
||||
let sample_rate = track.codec_params.sample_rate
|
||||
.ok_or(FingerprintError::NoSampleRate)?;
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())?;
|
||||
|
||||
// Chromaprint configuration
|
||||
let config = Configuration::preset_test1();
|
||||
let mut fingerprinter = Fingerprinter::new(&config);
|
||||
fingerprinter.start(sample_rate, 1)?; // Mono
|
||||
|
||||
let mut samples: Vec<i16> = Vec::new();
|
||||
let mut duration_samples = 0u64;
|
||||
|
||||
// Decode and collect samples (first 120 seconds max)
|
||||
let max_samples = sample_rate as u64 * 120;
|
||||
|
||||
loop {
|
||||
match format.next_packet() {
|
||||
Ok(packet) => {
|
||||
let decoded = decoder.decode(&packet)?;
|
||||
let mut sample_buf = SampleBuffer::<i16>::new(
|
||||
decoded.capacity() as u64,
|
||||
*decoded.spec(),
|
||||
);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
|
||||
// Convert to mono if stereo
|
||||
let mono: Vec<i16> = if decoded.spec().channels.count() > 1 {
|
||||
sample_buf.samples()
|
||||
.chunks(decoded.spec().channels.count())
|
||||
.map(|chunk| (chunk.iter().map(|&s| s as i32).sum::<i32>() / chunk.len() as i32) as i16)
|
||||
.collect()
|
||||
} else {
|
||||
sample_buf.samples().to_vec()
|
||||
};
|
||||
|
||||
samples.extend(&mono);
|
||||
duration_samples += mono.len() as u64;
|
||||
|
||||
if duration_samples >= max_samples {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(symphonia::core::errors::Error::IoError(e))
|
||||
if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
// Feed samples to fingerprinter
|
||||
fingerprinter.feed(&samples)?;
|
||||
fingerprinter.finish()?;
|
||||
|
||||
let raw = fingerprinter.fingerprint().to_vec();
|
||||
let duration_secs = (duration_samples / sample_rate as u64) as u32;
|
||||
|
||||
Ok(Self { raw, duration_secs })
|
||||
}
|
||||
|
||||
/// Compress fingerprint for storage
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
// Use chromaprint's compressed format
|
||||
chromaprint::encode_fingerprint(&self.raw, chromaprint::Algorithm::Test1)
|
||||
}
|
||||
|
||||
/// Decompress fingerprint
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, FingerprintError> {
|
||||
let (raw, _) = chromaprint::decode_fingerprint(bytes)?;
|
||||
Ok(Self { raw, duration_secs: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FingerprintError {
|
||||
#[error("No audio track found")]
|
||||
NoAudioTrack,
|
||||
#[error("No sample rate")]
|
||||
NoSampleRate,
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("Decode error: {0}")]
|
||||
Decode(String),
|
||||
#[error("Chromaprint error: {0}")]
|
||||
Chromaprint(String),
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Fingerprint Search (`musicfs-search/src/fingerprint_search.rs`)
|
||||
|
||||
```rust
|
||||
use crate::fingerprint::AudioFingerprint;
|
||||
|
||||
/// Fingerprint similarity search using bit-level comparison
|
||||
pub struct FingerprintIndex {
|
||||
db: Arc<Database>,
|
||||
}
|
||||
|
||||
impl FingerprintIndex {
|
||||
pub fn new(db: Arc<Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
/// Index a file's fingerprint
|
||||
pub fn index(&self, file_id: FileId, fingerprint: &AudioFingerprint) -> Result<(), SearchError> {
|
||||
let bytes = fingerprint.to_bytes();
|
||||
self.db.store_fingerprint(file_id, &bytes, fingerprint.duration_secs)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Search by fingerprint similarity (FR-14.4)
|
||||
pub fn search(
|
||||
&self,
|
||||
query: &AudioFingerprint,
|
||||
threshold: f32, // 0.0-1.0, higher = more similar
|
||||
limit: usize,
|
||||
) -> Result<Vec<FingerprintMatch>, SearchError> {
|
||||
let candidates = self.db.get_fingerprints_by_duration(
|
||||
query.duration_secs.saturating_sub(10),
|
||||
query.duration_secs + 10,
|
||||
)?;
|
||||
|
||||
let mut matches: Vec<FingerprintMatch> = candidates
|
||||
.into_iter()
|
||||
.filter_map(|(file_id, fp_bytes, duration)| {
|
||||
let fp = AudioFingerprint::from_bytes(&fp_bytes).ok()?;
|
||||
let similarity = self.compare(&query.raw, &fp.raw);
|
||||
|
||||
if similarity >= threshold {
|
||||
Some(FingerprintMatch { file_id, similarity, duration })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by similarity descending
|
||||
matches.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap());
|
||||
matches.truncate(limit);
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Compare two fingerprints using bit error rate
|
||||
fn compare(&self, a: &[u32], b: &[u32]) -> f32 {
|
||||
let len = a.len().min(b.len());
|
||||
if len == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut matching_bits = 0u32;
|
||||
let mut total_bits = 0u32;
|
||||
|
||||
for i in 0..len {
|
||||
let xor = a[i] ^ b[i];
|
||||
matching_bits += 32 - xor.count_ones();
|
||||
total_bits += 32;
|
||||
}
|
||||
|
||||
matching_bits as f32 / total_bits as f32
|
||||
}
|
||||
|
||||
/// Find duplicates by fingerprint
|
||||
pub fn find_duplicates(&self, threshold: f32) -> Result<Vec<DuplicateGroup>, SearchError> {
|
||||
let all_fps = self.db.get_all_fingerprints()?;
|
||||
let mut groups: Vec<DuplicateGroup> = Vec::new();
|
||||
let mut processed: HashSet<FileId> = HashSet::new();
|
||||
|
||||
for (file_id, fp_bytes, duration) in &all_fps {
|
||||
if processed.contains(file_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fp = AudioFingerprint::from_bytes(fp_bytes)?;
|
||||
let matches = self.search(&fp, threshold, 100)?;
|
||||
|
||||
if matches.len() > 1 {
|
||||
let group = DuplicateGroup {
|
||||
files: matches.iter().map(|m| m.file_id).collect(),
|
||||
similarity: matches.iter().map(|m| m.similarity).sum::<f32>() / matches.len() as f32,
|
||||
};
|
||||
|
||||
for m in &matches {
|
||||
processed.insert(m.file_id);
|
||||
}
|
||||
|
||||
groups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FingerprintMatch {
|
||||
pub file_id: FileId,
|
||||
pub similarity: f32,
|
||||
pub duration: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DuplicateGroup {
|
||||
pub files: Vec<FileId>,
|
||||
pub similarity: f32,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: M4B Audiobook Support (`musicfs-metadata/src/formats/m4b.rs`)
|
||||
|
||||
```rust
|
||||
use symphonia::core::meta::StandardTagKey;
|
||||
|
||||
/// M4B audiobook metadata (FR-24.2)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AudiobookMeta {
|
||||
pub title: Option<String>,
|
||||
pub author: Option<String>, // Maps to "artist" in audio
|
||||
pub narrator: Option<String>,
|
||||
pub series: Option<String>,
|
||||
pub series_part: Option<u32>,
|
||||
pub description: Option<String>,
|
||||
pub publisher: Option<String>,
|
||||
pub year: Option<u32>,
|
||||
pub duration_ms: Option<u64>,
|
||||
pub chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Chapter {
|
||||
pub index: u32,
|
||||
pub title: String,
|
||||
pub start_ms: u64,
|
||||
pub end_ms: u64,
|
||||
}
|
||||
|
||||
impl Chapter {
|
||||
pub fn duration_ms(&self) -> u64 {
|
||||
self.end_ms - self.start_ms
|
||||
}
|
||||
}
|
||||
|
||||
pub struct M4bParser;
|
||||
|
||||
impl M4bParser {
|
||||
/// Parse M4B audiobook with chapters
|
||||
pub fn parse(&self, path: &Path) -> Result<AudiobookMeta, MetadataError> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
let mut hint = Hint::new();
|
||||
hint.with_extension("m4b");
|
||||
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())?;
|
||||
|
||||
let mut meta = AudiobookMeta::default();
|
||||
let format = probed.format;
|
||||
|
||||
// Extract metadata
|
||||
if let Some(metadata) = format.metadata().current() {
|
||||
for tag in metadata.tags() {
|
||||
if let Some(std_key) = tag.std_key {
|
||||
let value = tag.value.to_string();
|
||||
match std_key {
|
||||
StandardTagKey::TrackTitle | StandardTagKey::Album => {
|
||||
meta.title = Some(value);
|
||||
}
|
||||
StandardTagKey::Artist => {
|
||||
meta.author = Some(value);
|
||||
}
|
||||
StandardTagKey::Composer => {
|
||||
meta.narrator = Some(value);
|
||||
}
|
||||
StandardTagKey::Description => {
|
||||
meta.description = Some(value);
|
||||
}
|
||||
StandardTagKey::Label => {
|
||||
meta.publisher = Some(value);
|
||||
}
|
||||
StandardTagKey::Date => {
|
||||
meta.year = value.chars().take(4).collect::<String>().parse().ok();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract chapters from MP4 chpl atom
|
||||
meta.chapters = self.extract_chapters(&format)?;
|
||||
|
||||
// Get total duration
|
||||
if let Some(track) = format.tracks().first() {
|
||||
if let (Some(n_frames), Some(sample_rate)) =
|
||||
(track.codec_params.n_frames, track.codec_params.sample_rate)
|
||||
{
|
||||
meta.duration_ms = Some((n_frames as u64 * 1000) / sample_rate as u64);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn extract_chapters(&self, format: &dyn FormatReader) -> Result<Vec<Chapter>, MetadataError> {
|
||||
let mut chapters = Vec::new();
|
||||
|
||||
// Symphonia exposes chapters via cues
|
||||
if let Some(cues) = format.cues() {
|
||||
for (idx, cue) in cues.iter().enumerate() {
|
||||
let start_ms = (cue.start_ts as f64 / cue.start_offset_ts.unwrap_or(1) as f64 * 1000.0) as u64;
|
||||
|
||||
// End time is start of next chapter or track end
|
||||
let end_ms = cues.get(idx + 1)
|
||||
.map(|next| (next.start_ts as f64 / next.start_offset_ts.unwrap_or(1) as f64 * 1000.0) as u64)
|
||||
.unwrap_or(u64::MAX); // Will be clamped to duration
|
||||
|
||||
chapters.push(Chapter {
|
||||
index: idx as u32,
|
||||
title: cue.tags.iter()
|
||||
.find(|t| t.std_key == Some(StandardTagKey::TrackTitle))
|
||||
.map(|t| t.value.to_string())
|
||||
.unwrap_or_else(|| format!("Chapter {}", idx + 1)),
|
||||
start_ms,
|
||||
end_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chapters)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Chapter Extraction (`musicfs-metadata/src/chapters.rs`)
|
||||
|
||||
```rust
|
||||
/// Generic chapter support for various formats
|
||||
pub trait ChapterSource {
|
||||
fn chapters(&self) -> &[Chapter];
|
||||
fn chapter_at(&self, position_ms: u64) -> Option<&Chapter>;
|
||||
}
|
||||
|
||||
impl ChapterSource for AudiobookMeta {
|
||||
fn chapters(&self) -> &[Chapter] {
|
||||
&self.chapters
|
||||
}
|
||||
|
||||
fn chapter_at(&self, position_ms: u64) -> Option<&Chapter> {
|
||||
self.chapters.iter()
|
||||
.find(|c| position_ms >= c.start_ms && position_ms < c.end_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual chapter file generator
|
||||
pub struct ChapterFileGenerator;
|
||||
|
||||
impl ChapterFileGenerator {
|
||||
/// Generate virtual files for each chapter
|
||||
/// Example: book.m4b -> book/01 - Introduction.m4b.chapter
|
||||
pub fn generate_virtual_files(&self, meta: &AudiobookMeta, base_path: &VirtualPath) -> Vec<VirtualChapterFile> {
|
||||
meta.chapters.iter()
|
||||
.map(|chapter| {
|
||||
let filename = format!(
|
||||
"{:02} - {}.chapter",
|
||||
chapter.index + 1,
|
||||
sanitize_filename(&chapter.title)
|
||||
);
|
||||
|
||||
VirtualChapterFile {
|
||||
path: base_path.join(&filename),
|
||||
chapter_index: chapter.index,
|
||||
start_ms: chapter.start_ms,
|
||||
end_ms: chapter.end_ms,
|
||||
title: chapter.title.clone(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct VirtualChapterFile {
|
||||
pub path: VirtualPath,
|
||||
pub chapter_index: u32,
|
||||
pub start_ms: u64,
|
||||
pub end_ms: u64,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
fn sanitize_filename(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| match c {
|
||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Virtual Chapter Files (`musicfs-fuse/src/ops/chapters.rs`)
|
||||
|
||||
```rust
|
||||
use crate::VirtualFs;
|
||||
|
||||
impl VirtualFs {
|
||||
/// Handle reads from virtual chapter files
|
||||
/// These return a byte-range reference to the parent M4B file
|
||||
pub async fn read_chapter(
|
||||
&self,
|
||||
chapter_file: &VirtualChapterFile,
|
||||
offset: u64,
|
||||
size: usize,
|
||||
) -> Result<Vec<u8>, FuseError> {
|
||||
// Get the parent audiobook file
|
||||
let parent = self.get_parent_audiobook(&chapter_file.path)?;
|
||||
|
||||
// Calculate byte range for this chapter
|
||||
// This requires knowing the audio bitrate to convert ms -> bytes
|
||||
let meta = self.get_audiobook_meta(&parent)?;
|
||||
let bitrate_bps = meta.bitrate.unwrap_or(128_000); // Default 128kbps
|
||||
let bytes_per_ms = bitrate_bps / 8 / 1000;
|
||||
|
||||
let chapter_start_bytes = chapter_file.start_ms * bytes_per_ms;
|
||||
let chapter_end_bytes = chapter_file.end_ms * bytes_per_ms;
|
||||
|
||||
// Adjust offset to be within chapter
|
||||
let actual_offset = chapter_start_bytes + offset;
|
||||
let max_size = (chapter_end_bytes - actual_offset) as usize;
|
||||
let read_size = size.min(max_size);
|
||||
|
||||
// Read from the actual file
|
||||
self.read_file(&parent, actual_offset, read_size).await
|
||||
}
|
||||
|
||||
/// List chapter files for an audiobook
|
||||
pub fn list_chapters(&self, audiobook_path: &VirtualPath) -> Result<Vec<DirEntry>, FuseError> {
|
||||
let meta = self.get_audiobook_meta(audiobook_path)?;
|
||||
let generator = ChapterFileGenerator;
|
||||
|
||||
let chapters = generator.generate_virtual_files(&meta, audiobook_path);
|
||||
|
||||
Ok(chapters.into_iter()
|
||||
.map(|c| DirEntry {
|
||||
name: c.path.filename().to_string(),
|
||||
kind: FileType::RegularFile,
|
||||
size: self.estimate_chapter_size(&c),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn estimate_chapter_size(&self, chapter: &VirtualChapterFile) -> u64 {
|
||||
// Estimate based on duration and typical bitrate
|
||||
let duration_secs = (chapter.end_ms - chapter.start_ms) / 1000;
|
||||
duration_secs * 128_000 / 8 // 128kbps assumption
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Fingerprint Search Virtual Directory
|
||||
|
||||
```rust
|
||||
/// Virtual directory for fingerprint search
|
||||
/// /.search/fingerprint/{base64_fingerprint} -> matching files
|
||||
|
||||
impl SearchOps {
|
||||
pub async fn search_by_fingerprint(
|
||||
&self,
|
||||
fingerprint_path: &str,
|
||||
) -> Result<Vec<SearchResult>, SearchError> {
|
||||
// Path format: /.search/fingerprint/{base64_encoded_fingerprint}
|
||||
let fp_bytes = base64::decode(fingerprint_path)
|
||||
.map_err(|_| SearchError::InvalidQuery)?;
|
||||
|
||||
let fingerprint = AudioFingerprint::from_bytes(&fp_bytes)?;
|
||||
let matches = self.fingerprint_index.search(&fingerprint, 0.8, 20)?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
for m in matches {
|
||||
if let Some(file) = self.db.get_file_by_id(m.file_id)? {
|
||||
results.push(SearchResult {
|
||||
path: file.virtual_path,
|
||||
score: m.similarity,
|
||||
snippet: format!("Similarity: {:.1}%", m.similarity * 100.0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Schema Additions
|
||||
|
||||
```sql
|
||||
-- Fingerprint storage
|
||||
CREATE TABLE IF NOT EXISTS fingerprints (
|
||||
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
|
||||
fingerprint BLOB NOT NULL, -- Compressed chromaprint
|
||||
duration INTEGER NOT NULL, -- Duration in seconds
|
||||
indexed_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fingerprints_duration ON fingerprints(duration);
|
||||
|
||||
-- Audiobook chapters
|
||||
CREATE TABLE IF NOT EXISTS chapters (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
|
||||
chapter_idx INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER NOT NULL,
|
||||
UNIQUE(file_id, chapter_idx)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chapters_file ON chapters(file_id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
| Test | Type | Validates |
|
||||
|------|------|-----------|
|
||||
| `test_fingerprint_generation` | Unit | Chromaprint from audio (FR-14.4) |
|
||||
| `test_fingerprint_similarity` | Unit | Bit comparison algorithm |
|
||||
| `test_fingerprint_search` | Integration | Find similar tracks |
|
||||
| `test_fingerprint_duplicates` | Integration | Detect duplicate audio |
|
||||
| `test_m4b_parsing` | Unit | M4B metadata extraction (FR-24.2) |
|
||||
| `test_chapter_extraction` | Unit | Chapter list from M4B |
|
||||
| `test_virtual_chapter_files` | Integration | Chapter files appear in listing |
|
||||
| `test_chapter_read` | Integration | Read chapter content |
|
||||
| `test_audiobook_navigation` | E2E | Browse audiobook chapters |
|
||||
|
||||
---
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
- [ ] Audio fingerprints generated from audio files
|
||||
- [ ] Fingerprint similarity search finds matching tracks
|
||||
- [ ] Duplicate detection works across library
|
||||
- [ ] M4B files parsed with full metadata
|
||||
- [ ] Chapters extracted and stored
|
||||
- [ ] Virtual chapter files appear in directory listing
|
||||
- [ ] Chapter files are readable (return correct byte range)
|
||||
- [ ] All tests pass
|
||||
|
||||
---
|
||||
|
||||
## Architecture Alignment
|
||||
|
||||
Per requirements.md:
|
||||
- FR-14.4: Audio fingerprint search ✓
|
||||
- FR-24.2: Audiobook formats with chapters ✓
|
||||
|
||||
Per architecture.md section 4.3.4:
|
||||
- FormatPlugin trait for M4B support ✓
|
||||
- Chapter extraction via symphonia ✓
|
||||
@@ -1,649 +0,0 @@
|
||||
# Music Library FUSE Filesystem - Requirements Specification
|
||||
|
||||
**Version**: 1.0
|
||||
**Date**: 2026-05-12
|
||||
**Status**: Draft
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
### 1.1 Purpose
|
||||
|
||||
This document specifies the requirements for a FUSE-based virtual filesystem that presents a music library organized by metadata. The system overlays metadata onto audio files without modifying originals and operates as a read-only client against the origin storage.
|
||||
|
||||
### 1.2 Scope
|
||||
|
||||
The system provides:
|
||||
- Virtual filesystem accessible via standard POSIX operations
|
||||
- Metadata-based directory structure (artist/album/track)
|
||||
- Local caching with delta synchronization
|
||||
- Support for local and remote origin storage
|
||||
|
||||
### 1.3 Definitions
|
||||
|
||||
| Term | Definition |
|
||||
|------|------------|
|
||||
| **Origin** | The source storage containing original audio files (local FS, NFS, S3, etc.) |
|
||||
| **Virtual path** | The metadata-derived path shown to users (e.g., `/Artist/Album/Track.flac`) |
|
||||
| **Real path** | The actual path on origin storage |
|
||||
| **Metadata overlay** | Serving synthesized file headers from cached metadata |
|
||||
| **CDC** | Content-Defined Chunking - algorithm for stable file segmentation |
|
||||
|
||||
---
|
||||
|
||||
## 2. System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ User Applications │
|
||||
│ (mpv, Rhythmbox, Plex, etc.) │
|
||||
└─────────────────────────────┬───────────────────────────────────┘
|
||||
│ POSIX (read-only)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FUSE Interface │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Plugin Host │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Origin │ │ Metadata │ │ Format │ │
|
||||
│ │ Plugins │ │ Plugins │ │ Plugins │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Core Services │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
|
||||
│ │ Virtual │ │ Event │ │ Search │ │ Control │ │
|
||||
│ │ Path │ │ Bus │ │ Index │ │ API │ │
|
||||
│ │ Resolver │ │ │ │ │ │ │ │
|
||||
│ └───────────┘ └───────────┘ └───────────┘ └───────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Storage Layer │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Content-Addressable Chunk Store │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │ Metadata │ │ Content │ │ Tree │ │ │
|
||||
│ │ │ Cache │ │ Chunks │ │ Cache │ │ │
|
||||
│ │ │ (SQLite) │ │ (CAS) │ │ │ │ │
|
||||
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ Origin Federation │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Local │ │ NFS │ │ S3 │ │ SFTP │ │
|
||||
│ │ FS │ │ │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ read-only
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Origin Storage(s) │
|
||||
│ (original audio files) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Functional Requirements
|
||||
|
||||
### 3.1 Filesystem Operations
|
||||
|
||||
#### FR-1: Mount/Unmount
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-1.1 | The system SHALL mount as a FUSE filesystem at a user-specified mountpoint |
|
||||
| FR-1.2 | The system SHALL return control to the caller within 500ms of mount initiation |
|
||||
| FR-1.3 | The system SHALL unmount cleanly via `fusermount -u` |
|
||||
| FR-1.4 | The system SHALL release all resources (file handles, connections) on unmount |
|
||||
|
||||
#### FR-2: Directory Operations
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-2.1 | The system SHALL present files organized by metadata path format |
|
||||
| FR-2.2 | The system SHALL support configurable path templates (e.g., `$artist/$album/$track - $title.$format`) |
|
||||
| FR-2.3 | The system SHALL return directory listings via `readdir()` |
|
||||
| FR-2.4 | The system SHALL support nested directory traversal to arbitrary depth |
|
||||
| FR-2.5 | The system SHALL handle directories with 100,000+ entries |
|
||||
|
||||
#### FR-3: File Operations (Read)
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-3.1 | The system SHALL support `open()` for reading |
|
||||
| FR-3.2 | The system SHALL support `read()` with arbitrary offset and size |
|
||||
| FR-3.3 | The system SHALL support `seek()` operations for random access |
|
||||
| FR-3.4 | The system SHALL return file attributes via `stat()` / `fstat()` |
|
||||
| FR-3.5 | The system SHALL support concurrent reads from multiple processes |
|
||||
|
||||
#### FR-4: Read-Only Constraint
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-4.1 | The system SHALL NOT modify original files on the origin storage |
|
||||
| FR-4.2 | The system SHALL NOT push any changes to the origin server |
|
||||
| FR-4.3 | The system SHALL return `EROFS` (Read-only filesystem) for write operations |
|
||||
| FR-4.4 | The system SHALL return `EROFS` for `create()`, `mkdir()`, `unlink()`, `rmdir()` |
|
||||
| FR-4.5 | The system SHALL return `EROFS` for `rename()`, `chmod()`, `chown()`, `truncate()` |
|
||||
|
||||
### 3.2 Metadata Handling
|
||||
|
||||
#### FR-5: Metadata Overlay
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-5.1 | The system SHALL extract metadata from audio files on first access |
|
||||
| FR-5.2 | The system SHALL cache extracted metadata in a local database |
|
||||
| FR-5.3 | The system SHALL serve file headers with metadata from cache |
|
||||
| FR-5.4 | The system SHALL support FLAC Vorbis comments |
|
||||
| FR-5.5 | The system SHALL support MP3 ID3v2 tags |
|
||||
| FR-5.6 | The system SHOULD support additional formats (OGG, M4A, OPUS) |
|
||||
|
||||
#### FR-6: Metadata Fields
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-6.1 | The system SHALL extract and cache: title, artist, album, genre |
|
||||
| FR-6.2 | The system SHALL extract and cache: year, track number, disc number |
|
||||
| FR-6.3 | The system SHALL extract and cache: duration, bitrate, sample rate |
|
||||
| FR-6.4 | The system SHOULD extract: composer, album artist, lyrics |
|
||||
| FR-6.5 | The system SHALL handle missing metadata gracefully with defaults |
|
||||
|
||||
### 3.3 Caching
|
||||
|
||||
#### FR-7: Metadata Cache
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-7.1 | The system SHALL persist metadata cache across restarts |
|
||||
| FR-7.2 | The system SHALL store metadata in SQLite database |
|
||||
| FR-7.3 | The system SHALL index by both virtual path and real path |
|
||||
| FR-7.4 | The system SHALL invalidate cache entries when origin file changes |
|
||||
|
||||
#### FR-8: Content Cache
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-8.1 | The system SHALL cache file content in fixed-size chunks |
|
||||
| FR-8.2 | The system SHALL use content-defined chunking for cache efficiency |
|
||||
| FR-8.3 | The system SHALL store chunk hashes for delta detection |
|
||||
| FR-8.4 | The system SHALL evict chunks under memory/disk pressure |
|
||||
|
||||
#### FR-9: Directory Tree Cache
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-9.1 | The system SHALL cache directory listings locally |
|
||||
| FR-9.2 | The system SHALL serve `readdir()` from cache without origin access |
|
||||
| FR-9.3 | The system SHALL refresh tree cache based on configurable policy |
|
||||
| FR-9.4 | The system SHALL support forced refresh via signal or special file |
|
||||
|
||||
### 3.4 Synchronization
|
||||
|
||||
#### FR-10: Change Detection
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-10.1 | The system SHALL detect changes to origin files |
|
||||
| FR-10.2 | The system SHALL use inotify for local filesystem origins |
|
||||
| FR-10.3 | The system SHALL use polling for remote origins without push support |
|
||||
| FR-10.4 | The system SHALL compare mtime and size for change detection |
|
||||
| FR-10.5 | The system SHALL support content-hash verification on demand |
|
||||
|
||||
#### FR-11: Delta Sync
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-11.1 | The system SHALL download only changed portions of files |
|
||||
| FR-11.2 | The system SHALL use CDC to identify changed chunks |
|
||||
| FR-11.3 | The system SHALL preserve unchanged chunks in cache |
|
||||
| FR-11.4 | The system SHALL handle file additions and deletions |
|
||||
|
||||
### 3.5 Origin Support
|
||||
|
||||
#### FR-12: Origin Types
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-12.1 | The system SHALL support local filesystem as origin |
|
||||
| FR-12.2 | The system SHOULD support NFS mounted filesystems |
|
||||
| FR-12.3 | The system SHOULD support SMB/CIFS shares |
|
||||
| FR-12.4 | The system SHOULD support S3-compatible object storage |
|
||||
| FR-12.5 | The system SHOULD support SFTP servers |
|
||||
| FR-12.6 | The system SHALL provide pluggable origin interface |
|
||||
|
||||
#### FR-13: Multiple Origins [P0]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-13.1 | The system SHALL support multiple simultaneous origins |
|
||||
| FR-13.2 | The system SHALL present unified virtual tree across origins |
|
||||
| FR-13.3 | The system SHALL support origin priority/preference ordering |
|
||||
| FR-13.4 | The system SHALL handle duplicate files across origins |
|
||||
| FR-13.5 | The system SHALL support per-origin configuration |
|
||||
|
||||
### 3.6 Search & Discovery
|
||||
|
||||
#### FR-14: Full-Text Search [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-14.1 | The system SHALL index metadata for full-text search |
|
||||
| FR-14.2 | The system SHALL expose search via virtual directory (`/.search/query/`) |
|
||||
| FR-14.3 | The system SHALL support fuzzy matching |
|
||||
| FR-14.4 | The system SHOULD support search by audio fingerprint |
|
||||
|
||||
#### FR-15: Smart Collections [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-15.1 | The system SHALL support query-based virtual folders |
|
||||
| FR-15.2 | The system SHALL support saved searches as directories |
|
||||
| FR-15.3 | The system SHALL support dynamic playlists (recently played, most played) |
|
||||
| FR-15.4 | The system SHOULD support user-defined metadata fields |
|
||||
|
||||
### 3.7 Album Art
|
||||
|
||||
#### FR-16: Cover Art Handling [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-16.1 | The system SHALL extract embedded album art |
|
||||
| FR-16.2 | The system SHALL expose art as virtual files (`/Artist/Album/cover.jpg`) |
|
||||
| FR-16.3 | The system SHALL cache artwork separately from audio |
|
||||
| FR-16.4 | The system SHALL support multiple art sizes (thumbnail, medium, full) |
|
||||
| FR-16.5 | The system SHOULD fetch missing art from online sources |
|
||||
|
||||
### 3.8 Control & API
|
||||
|
||||
#### FR-17: Control Interface [P0]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-17.1 | The system SHALL expose control via Unix socket (gRPC) |
|
||||
| FR-17.2 | The system SHALL use gRPC with Protocol Buffers for all control APIs |
|
||||
| FR-17.3 | The system SHALL support cache management commands (clear, refresh, stats) |
|
||||
| FR-17.4 | The system SHALL support runtime configuration changes |
|
||||
| FR-17.5 | The system SHALL support graceful shutdown with drain |
|
||||
|
||||
#### FR-18: Event System [P0]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-18.1 | The system SHALL emit events for file access |
|
||||
| FR-18.2 | The system SHALL support webhook notifications |
|
||||
| FR-18.3 | The system SHOULD support event streaming (SSE/WebSocket) |
|
||||
| FR-18.4 | The system SHALL log access patterns for analysis |
|
||||
|
||||
### 3.9 Caching Enhancements
|
||||
|
||||
#### FR-19: Intelligent Prefetching [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-19.1 | The system SHALL learn access patterns |
|
||||
| FR-19.2 | The system SHALL support playlist-aware prefetching |
|
||||
| FR-19.3 | The system SHOULD support time-based prefetching |
|
||||
| FR-19.4 | The system SHALL support manual prefetch hints (`/.prefetch/path/`) |
|
||||
|
||||
#### FR-20: Content-Addressable Storage [P0]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-20.1 | The system SHALL store chunks by content hash |
|
||||
| FR-20.2 | The system SHALL detect identical files across library |
|
||||
| FR-20.3 | The system SHALL report deduplication statistics |
|
||||
| FR-20.4 | The system SHALL enable cache sharing via content addressing |
|
||||
|
||||
### 3.10 Integration
|
||||
|
||||
#### FR-21: Metadata Sources [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-21.1 | The system SHOULD integrate with MusicBrainz |
|
||||
| FR-21.2 | The system SHOULD integrate with Discogs |
|
||||
| FR-21.3 | The system SHOULD integrate with Last.fm |
|
||||
| FR-21.4 | The system SHOULD support AcoustID fingerprinting |
|
||||
| FR-21.5 | The system SHALL support custom metadata plugins |
|
||||
|
||||
#### FR-22: Import & Migration [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-22.1 | The system SHALL import from beets database |
|
||||
| FR-22.2 | The system SHOULD import from iTunes/Apple Music library |
|
||||
| FR-22.3 | The system SHALL export library metadata |
|
||||
|
||||
### 3.11 Extensibility
|
||||
|
||||
#### FR-23: Plugin System [P0]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-23.1 | The system SHALL support loadable plugins |
|
||||
| FR-23.2 | The system SHALL define stable plugin API |
|
||||
| FR-23.3 | The system SHALL support plugins for: origins, metadata extractors, formats |
|
||||
| FR-23.4 | The system SHOULD support WASM plugins for sandboxed execution |
|
||||
| FR-23.5 | The system SHALL provide plugin lifecycle management (load, unload, reload) |
|
||||
|
||||
#### FR-24: Format Extensibility [P1]
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-24.1 | The system SHALL support pluggable codec modules |
|
||||
| FR-24.2 | The system SHOULD support audiobook formats (M4B, chapters) |
|
||||
| FR-24.3 | The system SHALL allow format plugins to register file extensions |
|
||||
|
||||
### 3.12 High Availability [P3]
|
||||
|
||||
#### FR-25: Resilience
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| FR-25.1 | The system SHOULD support active-passive failover |
|
||||
| FR-25.2 | The system SHOULD support read replicas |
|
||||
| FR-25.3 | The system SHALL support zero-downtime upgrades |
|
||||
| FR-25.4 | The system SHALL support cache backup/restore |
|
||||
| FR-25.5 | The system SHALL validate cache integrity on startup |
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Functional Requirements
|
||||
|
||||
### 4.1 Performance
|
||||
|
||||
#### NFR-1: Latency
|
||||
|
||||
| ID | Requirement | Target | Maximum |
|
||||
|----|-------------|--------|---------|
|
||||
| NFR-1.1 | `stat()` on cached file | <1ms | 5ms |
|
||||
| NFR-1.2 | `readdir()` on cached directory | <10ms | 50ms |
|
||||
| NFR-1.3 | `open()` on cached file | <5ms | 20ms |
|
||||
| NFR-1.4 | `read()` from cache | <1ms | 5ms |
|
||||
| NFR-1.5 | `read()` cache miss (local origin) | <50ms | 200ms |
|
||||
| NFR-1.6 | `read()` cache miss (remote origin) | <200ms | 1000ms |
|
||||
| NFR-1.7 | Mount completion | <100ms | 500ms |
|
||||
|
||||
#### NFR-2: Throughput
|
||||
|
||||
| ID | Requirement | Target |
|
||||
|----|-------------|--------|
|
||||
| NFR-2.1 | Sequential read throughput (cached) | >500 MB/s |
|
||||
| NFR-2.2 | Sequential read throughput (local origin) | >200 MB/s |
|
||||
| NFR-2.3 | Metadata operations per second | >1000 ops/s |
|
||||
| NFR-2.4 | Concurrent file handles | >1000 |
|
||||
|
||||
#### NFR-3: Scalability
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-3.1 | The system SHALL handle libraries with 1,000,000+ files |
|
||||
| NFR-3.2 | The system SHALL handle directories with 100,000+ entries |
|
||||
| NFR-3.3 | The system SHALL maintain O(1) mount time regardless of library size |
|
||||
| NFR-3.4 | The system SHALL maintain O(log n) lookup time for paths |
|
||||
| NFR-3.5 | The system SHOULD handle libraries with 10,000,000+ files [P3] |
|
||||
| NFR-3.6 | The system SHOULD support 100+ concurrent clients [P3] |
|
||||
| NFR-3.7 | The system SHOULD achieve <100μs cached stat for high-performance use [P3] |
|
||||
|
||||
### 4.2 Resource Usage
|
||||
|
||||
#### NFR-4: Memory
|
||||
|
||||
| ID | Requirement | Limit |
|
||||
|----|-------------|-------|
|
||||
| NFR-4.1 | Idle memory usage | <50 MB |
|
||||
| NFR-4.2 | Active usage (1000 files accessed) | <200 MB |
|
||||
| NFR-4.3 | Peak usage under load | <500 MB |
|
||||
| NFR-4.4 | Per-file metadata overhead | <1 KB |
|
||||
| NFR-4.5 | The system SHALL NOT load entire files into memory |
|
||||
|
||||
#### NFR-5: Disk
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-5.1 | Metadata cache size SHALL be configurable (default: 100 MB) |
|
||||
| NFR-5.2 | Content cache size SHALL be configurable (default: 10 GB) |
|
||||
| NFR-5.3 | The system SHALL evict cache entries under disk pressure |
|
||||
| NFR-5.4 | The system SHALL function with cache disabled (passthrough mode) |
|
||||
|
||||
#### NFR-6: Network
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-6.1 | The system SHALL minimize network round-trips via batching |
|
||||
| NFR-6.2 | The system SHALL use connection pooling for remote origins |
|
||||
| NFR-6.3 | The system SHALL support bandwidth limiting (configurable) |
|
||||
| NFR-6.4 | Delta sync SHALL achieve >90% bandwidth reduction vs full copy |
|
||||
|
||||
### 4.3 Reliability
|
||||
|
||||
#### NFR-7: Availability
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-7.1 | The system SHALL serve cached data when origin is unavailable |
|
||||
| NFR-7.2 | The system SHALL gracefully degrade with network failures |
|
||||
| NFR-7.3 | The system SHALL retry failed operations with exponential backoff |
|
||||
| NFR-7.4 | The system SHALL not crash on malformed audio files |
|
||||
|
||||
#### NFR-8: Data Integrity
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-8.1 | The system SHALL verify chunk integrity via checksums |
|
||||
| NFR-8.2 | The system SHALL use ACID transactions for cache database |
|
||||
| NFR-8.3 | The system SHALL recover from interrupted synchronization |
|
||||
| NFR-8.4 | The system SHALL detect and report cache corruption |
|
||||
|
||||
### 4.4 Usability
|
||||
|
||||
#### NFR-9: Configuration
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-9.1 | The system SHALL support configuration via file (TOML/YAML) |
|
||||
| NFR-9.2 | The system SHALL support configuration via command-line arguments |
|
||||
| NFR-9.3 | The system SHALL support configuration via environment variables |
|
||||
| NFR-9.4 | The system SHALL provide sensible defaults for all options |
|
||||
|
||||
#### NFR-10: Observability
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-10.1 | The system SHALL log operations at configurable verbosity |
|
||||
| NFR-10.2 | The system SHALL expose metrics (cache hit rate, latency, etc.) |
|
||||
| NFR-10.3 | The system SHALL support health check endpoint/signal |
|
||||
| NFR-10.4 | The system SHOULD support integration with Prometheus/StatsD |
|
||||
|
||||
### 4.5 Compatibility
|
||||
|
||||
#### NFR-11: Platform Support
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-11.1 | The system SHALL run on Linux (kernel 4.x+) |
|
||||
| NFR-11.2 | The system SHOULD run on macOS (via macFUSE) |
|
||||
| NFR-11.3 | The system SHALL require FUSE kernel module |
|
||||
| NFR-11.4 | The system SHALL run without root privileges (user-space FUSE) |
|
||||
|
||||
#### NFR-12: Application Compatibility
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-12.1 | The system SHALL work with standard media players (mpv, VLC, etc.) |
|
||||
| NFR-12.2 | The system SHALL work with media servers (Plex, Jellyfin) |
|
||||
| NFR-12.3 | The system SHALL work with file managers (Nautilus, Dolphin) |
|
||||
| NFR-12.4 | The system SHALL correctly report file sizes and timestamps |
|
||||
|
||||
### 4.6 Security
|
||||
|
||||
#### NFR-13: Access Control
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-13.1 | The system SHALL respect origin file permissions |
|
||||
| NFR-13.2 | The system SHALL run as unprivileged user |
|
||||
| NFR-13.3 | The system SHALL support credential storage for remote origins |
|
||||
| NFR-13.4 | The system SHALL NOT expose credentials in logs or process list |
|
||||
|
||||
### 4.7 Maintainability
|
||||
|
||||
#### NFR-14: Code Quality
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NFR-14.1 | The system SHALL be implemented in a memory-safe language |
|
||||
| NFR-14.2 | The system SHALL have no global interpreter lock (no Python/Ruby) |
|
||||
| NFR-14.3 | The system SHALL use async I/O for concurrent operations |
|
||||
| NFR-14.4 | The system SHALL have modular architecture with pluggable components |
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraints
|
||||
|
||||
### 5.1 Technical Constraints
|
||||
|
||||
| ID | Constraint |
|
||||
|----|------------|
|
||||
| C-1 | Must use FUSE for filesystem interface |
|
||||
| C-2 | Must not require kernel module development |
|
||||
| C-3 | Must work with existing audio file formats (no transcoding) |
|
||||
| C-4 | Cache database must be portable (no external database server) |
|
||||
|
||||
### 5.2 Operational Constraints
|
||||
|
||||
| ID | Constraint |
|
||||
|----|------------|
|
||||
| C-5 | Client is read-only; no writes propagate to origin |
|
||||
| C-6 | Must function offline with cached data |
|
||||
| C-7 | Must not corrupt origin files under any circumstances |
|
||||
|
||||
---
|
||||
|
||||
## 6. Assumptions
|
||||
|
||||
| ID | Assumption |
|
||||
|----|------------|
|
||||
| A-1 | Origin storage is accessible via supported protocol |
|
||||
| A-2 | Audio files contain valid metadata headers |
|
||||
| A-3 | Sufficient local disk space for caching is available |
|
||||
| A-4 | FUSE kernel module is installed and accessible |
|
||||
| A-5 | Network connectivity is intermittent but generally available |
|
||||
|
||||
---
|
||||
|
||||
## 7. Dependencies
|
||||
|
||||
| ID | Dependency | Purpose |
|
||||
|----|------------|---------|
|
||||
| D-1 | FUSE library (fuser/libfuse) | Filesystem interface |
|
||||
| D-2 | SQLite | Metadata and tree cache |
|
||||
| D-3 | Audio parsing library (symphonia) | Metadata extraction |
|
||||
| D-4 | Async runtime (tokio) | Concurrent I/O |
|
||||
| D-5 | CDC library (fastcdc) | Content chunking |
|
||||
| D-6 | Full-text search (tantivy) | Search index [P1] |
|
||||
| D-7 | Image processing (image) | Album art thumbnails [P1] |
|
||||
| D-8 | HTTP client (reqwest) | Remote origins, metadata APIs |
|
||||
| D-9 | WASM runtime (wasmtime) | Plugin sandboxing [P0] |
|
||||
| D-10 | Hash library (xxhash/blake3) | Content addressing [P0] |
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance Criteria
|
||||
|
||||
### 8.1 Functional Acceptance
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-1 | Mount filesystem and browse directories via `ls` |
|
||||
| AC-2 | Play audio file through mounted filesystem with media player |
|
||||
| AC-3 | Seek within audio file without full download |
|
||||
| AC-4 | Directory listing completes without network access (when cached) |
|
||||
| AC-5 | Confirm write operations return EROFS |
|
||||
| AC-6 | Detect and sync changes from origin within configured interval |
|
||||
|
||||
### 8.2 Performance Acceptance
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-7 | Mount completes in <500ms for library of any size |
|
||||
| AC-8 | Cached stat() completes in <5ms (p99) |
|
||||
| AC-9 | Memory stays under 500MB with 10,000 files accessed |
|
||||
| AC-10 | Tag-only change syncs <10KB of data |
|
||||
|
||||
### 8.3 Reliability Acceptance
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-11 | Filesystem remains accessible when origin is offline |
|
||||
| AC-12 | No data corruption after unclean unmount |
|
||||
| AC-13 | Recovers automatically when origin comes back online |
|
||||
|
||||
### 8.4 Multi-Origin Acceptance [P0]
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-14 | Configure and mount multiple origins simultaneously |
|
||||
| AC-15 | Browse unified tree showing content from all origins |
|
||||
| AC-16 | Access same file from preferred origin when duplicated |
|
||||
|
||||
### 8.5 Search & Discovery Acceptance [P1]
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-17 | Search for tracks by partial artist/album/title match |
|
||||
| AC-18 | Browse smart collection (e.g., "Jazz from 1960s") |
|
||||
| AC-19 | View album art via virtual cover.jpg file |
|
||||
|
||||
### 8.6 Plugin Acceptance [P0]
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-20 | Load custom origin plugin at runtime |
|
||||
| AC-21 | Control daemon via Unix socket (cache stats, refresh) |
|
||||
| AC-22 | Receive webhook on file access event |
|
||||
|
||||
### 8.7 Deduplication Acceptance [P0]
|
||||
|
||||
| ID | Criterion |
|
||||
|----|-----------|
|
||||
| AC-23 | Identical chunks stored once regardless of file count |
|
||||
| AC-24 | Deduplication stats visible via control API |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix
|
||||
|
||||
### 9.1 Comparison with beetfs
|
||||
|
||||
| Requirement Area | beetfs | This Specification |
|
||||
|------------------|--------|-------------------|
|
||||
| Mount time | O(N), 5-120s | O(1), <500ms (NFR-1.7) |
|
||||
| Memory per file | Full file size | <1KB (NFR-4.4) |
|
||||
| Write to origin | Yes (DB updates) | No (FR-4.1, FR-4.2) |
|
||||
| Delta sync | None | Required (FR-11) |
|
||||
| Remote origins | None | Required (FR-12) |
|
||||
| Offline access | No | Required (NFR-7.1) |
|
||||
| Cache persistence | No | Required (FR-7.1) |
|
||||
|
||||
### 9.2 Path Template Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `$artist` | Track artist | "Metallica" |
|
||||
| `$album` | Album name | "72 Seasons" |
|
||||
| `$title` | Track title | "Lux Æterna" |
|
||||
| `$track` | Track number (zero-padded) | "03" |
|
||||
| `$disc` | Disc number | "1" |
|
||||
| `$year` | Release year | "2023" |
|
||||
| `$genre` | Genre | "Metal" |
|
||||
| `$format` | File extension | "flac" |
|
||||
| `$format_upper` | File extension (uppercase) | "FLAC" |
|
||||
|
||||
### 9.3 Error Codes
|
||||
|
||||
| Operation | Error | Code |
|
||||
|-----------|-------|------|
|
||||
| Any write operation | Read-only filesystem | EROFS (30) |
|
||||
| File not found | No such file | ENOENT (2) |
|
||||
| Origin unavailable | I/O error | EIO (5) |
|
||||
| Permission denied | Access denied | EACCES (13) |
|
||||
@@ -1,179 +0,0 @@
|
||||
# MusicFS Week 7 Performance Review
|
||||
|
||||
**Date**: 2026-05-12
|
||||
**Commit**: `09f0197` (Week 7 Remote Origins)
|
||||
**Baseline**: `d5ef68c` (Week 6 Origin Federation)
|
||||
**System**: Linux, NixOS
|
||||
**Test**: Synthetic benchmarks (CDC chunking, hashing, chunk reuse)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Week 7 Remote Origins adds no performance regression.** The core CDC and hashing algorithms remain unchanged; Week 7 adds I/O wrappers (NFS, SMB, S3, SFTP) that are network-bound, not CPU-bound. All NFR targets continue to be met or exceeded.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### CDC Chunker Throughput
|
||||
|
||||
| Metric | Week 6 | Week 7 | Delta | NFR Target | Status |
|
||||
|--------|--------|--------|-------|------------|--------|
|
||||
| CDC Throughput | 3148.7 MB/s | 3007.9 MB/s | -4.5% | N/A* | ✅ |
|
||||
| Chunks per 10MB | 137 | 137 | 0% | — | ✅ |
|
||||
|
||||
*CDC throughput is internal; NFR-2.1/2.2 measure end-to-end read throughput (>500 MB/s cached, >200 MB/s local origin). CDC at ~3 GB/s confirms chunking is not a bottleneck.
|
||||
|
||||
### Hash Computation Throughput
|
||||
|
||||
| Metric | Week 6 | Week 7 | Delta | Status |
|
||||
|--------|--------|--------|-------|--------|
|
||||
| xxHash64 Throughput | 16330.7 MB/s | 16274.6 MB/s | -0.3% | ✅ |
|
||||
|
||||
Hash computation at ~16 GB/s is CPU-limited and far exceeds any I/O bottleneck.
|
||||
|
||||
### Chunk Reuse (NFR-6.4)
|
||||
|
||||
| Metric | Week 6 | Week 7 | NFR-6.4 Target | Status |
|
||||
|--------|--------|--------|----------------|--------|
|
||||
| Chunk Reuse | 99.1% | 99.1% | >90% | ✅ PASS |
|
||||
| Reused Chunks | 107/108 | 107/108 | — | — |
|
||||
| Edit Size | 100 bytes | 100 bytes | — | — |
|
||||
|
||||
**NFR-6.4**: *"Delta sync SHALL achieve >90% bandwidth reduction vs full copy"*
|
||||
|
||||
Result: **99.1% bandwidth reduction** for mid-file metadata edits (100 bytes changed in 2MB file). This exceeds the >90% requirement by 9.1 percentage points.
|
||||
|
||||
---
|
||||
|
||||
## Requirements Compliance
|
||||
|
||||
### NFR-2: Throughput
|
||||
|
||||
| ID | Requirement | Target | Measured | Status |
|
||||
|----|-------------|--------|----------|--------|
|
||||
| NFR-2.1 | Sequential read (cached) | >500 MB/s | ~3000 MB/s* | ✅ |
|
||||
| NFR-2.2 | Sequential read (local origin) | >200 MB/s | ~3000 MB/s* | ✅ |
|
||||
|
||||
*Measured at CDC layer. End-to-end throughput demonstrated in MVP review (2-3 GB/s).
|
||||
|
||||
### NFR-6: Network
|
||||
|
||||
| ID | Requirement | Target | Measured | Status |
|
||||
|----|-------------|--------|----------|--------|
|
||||
| NFR-6.4 | Delta sync bandwidth reduction | >90% | 99.1% | ✅ |
|
||||
|
||||
### NFR-7: Availability (Week 7 Additions)
|
||||
|
||||
| ID | Requirement | Implementation | Status |
|
||||
|----|-------------|----------------|--------|
|
||||
| NFR-7.3 | Retry with exponential backoff | NFS: ESTALE retry (100ms→200ms→400ms) | ✅ |
|
||||
| NFR-7.3 | Retry with exponential backoff | SMB: ENOTCONN retry (100ms fixed) | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Week 7 Changes Analysis
|
||||
|
||||
### What Changed (No Performance Impact Expected)
|
||||
|
||||
| Component | Change | Performance Impact |
|
||||
|-----------|--------|-------------------|
|
||||
| `credentials.rs` | New CredentialStore with redacted Debug | None (startup only) |
|
||||
| `nfs.rs` | NfsOrigin with ESTALE retry, 5s health timeout | None (error path only) |
|
||||
| `smb.rs` | SmbOrigin with ENOTCONN retry, 5s health timeout | None (error path only) |
|
||||
| `s3.rs` | Feature-gated stub | None (not compiled) |
|
||||
| `sftp.rs` | Feature-gated stub | None (not compiled) |
|
||||
| `error.rs` | New error variants | None (enum extension) |
|
||||
|
||||
### Why ~4.5% CDC Variance is Noise
|
||||
|
||||
The 4.5% difference (3148.7 → 3007.9 MB/s) is within expected benchmark noise:
|
||||
|
||||
1. **No code path changed** — FastCDC algorithm unchanged
|
||||
2. **CPU frequency variation** — Turbo boost, thermal throttling
|
||||
3. **Memory subsystem** — Cache line evictions, NUMA effects
|
||||
4. **OS scheduler** — Process placement, interrupt handling
|
||||
|
||||
A 4.5% variance over 10 iterations of 10MB data is statistically insignificant. To detect real regressions, we'd need:
|
||||
- Warmup iterations (discard first N)
|
||||
- Statistical analysis (mean, stddev, p-value)
|
||||
- Dedicated benchmark infrastructure (criterion.rs)
|
||||
|
||||
---
|
||||
|
||||
## Comparison with MVP Performance Review
|
||||
|
||||
| Metric | MVP Review | Week 7 | Change |
|
||||
|--------|-----------|--------|--------|
|
||||
| Single file read | 3.2 GB/s (warm) | N/A | — |
|
||||
| CDC Throughput | Not measured | 3.0 GB/s | Baseline |
|
||||
| Chunk Reuse | Not measured | 99.1% | Baseline |
|
||||
| Mount time | ~8ms | N/A | — |
|
||||
| stat() latency | 3ms | N/A | — |
|
||||
|
||||
MVP review focused on end-to-end FUSE operations. Week 7 review focuses on CDC/sync layer since remote origins add I/O wrappers, not CPU-bound logic.
|
||||
|
||||
---
|
||||
|
||||
## Test Details
|
||||
|
||||
```
|
||||
Test Type: Synthetic microbenchmarks
|
||||
Data Size: 10 MB (CDC), 64 KB × 10000 (hash), 2 MB (reuse)
|
||||
Iterations: 10 (CDC), 10000 (hash), 1 (reuse)
|
||||
Build: cargo build --release
|
||||
Rust: stable (via nix develop)
|
||||
```
|
||||
|
||||
### Benchmark Code
|
||||
|
||||
CDC and hash throughput measured with in-memory data to isolate algorithm performance from I/O. Chunk reuse measured with simulated metadata edit (100 bytes changed mid-file).
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Add Formal Benchmarks (Priority: Medium)
|
||||
|
||||
Current benchmarks are ad-hoc. Add criterion.rs for:
|
||||
- Reproducible measurements with statistical analysis
|
||||
- Regression detection in CI
|
||||
- Historical tracking
|
||||
|
||||
```toml
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
```
|
||||
|
||||
### 2. Add Integration Benchmarks (Priority: Low)
|
||||
|
||||
Week 7 adds NFS/SMB wrappers. Add benchmarks for:
|
||||
- ESTALE retry overhead
|
||||
- Health check timeout behavior
|
||||
- Connection pool performance (when S3/SFTP implemented)
|
||||
|
||||
### 3. Test with Real Network Origins (Priority: High for Week 8+)
|
||||
|
||||
Current benchmarks use local mounts. Before deploying:
|
||||
- Benchmark against real NFS server
|
||||
- Measure latency distribution (p50, p95, p99)
|
||||
- Test failure scenarios (network partition, slow origin)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Week 7 introduces no performance regression.** The 4.5% CDC throughput variance is within noise margin. NFR-6.4 (>90% bandwidth reduction) continues to be exceeded at 99.1%.
|
||||
|
||||
Remote origin wrappers (NFS, SMB) are I/O-bound and will only affect performance when accessing remote storage. The retry logic (ESTALE, ENOTCONN) and health timeouts are error-path-only and have no impact on happy-path performance.
|
||||
|
||||
**All 102 tests pass with 0 warnings.**
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Requirements Specification](requirements.md) — NFR-2 (Throughput), NFR-6 (Network), NFR-7 (Availability)
|
||||
- [MVP Performance Review](mvp-performance-review.md) — Baseline end-to-end measurements
|
||||
- [Week 7 Plan](plans/week-07-remote-origins.md) — Remote origins implementation
|
||||
Reference in New Issue
Block a user