Skip to content

feat(crossseed): add pooled partial completion#1594

Open
Audionut wants to merge 12 commits intodevelopfrom
refactor/cross-seed-pooling
Open

feat(crossseed): add pooled partial completion#1594
Audionut wants to merge 12 commits intodevelopfrom
refactor/cross-seed-pooling

Conversation

@Audionut
Copy link
Contributor

@Audionut Audionut commented Mar 13, 2026

  • Single (non-matching) file reflink handling
  • Multi-file reflink handling
  • Multi-file hardlink handling
  • Disc based reflink handling
  • Disc based hardlink handling

Summary by CodeRabbit

  • New Features

    • Pooled partial completion for coordinated cross‑seed handling in hardlink and reflink modes
    • Reflink single‑file size‑mismatch override: recheck + auto‑resume when conditions met
    • Configurable "max missing bytes after recheck" (min 1 MiB, default 100 MiB)
    • Startup now restores active partial pools so coordination resumes after restart
    • UI controls added under Hardlink/Reflink Mode to manage these settings
  • Documentation

    • Detailed guides and troubleshooting for pooled partial completion, reflink mismatch cases, and behavior notes

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 13, 2026

Walkthrough

Adds pooled partial completion for cross-seed: DB schema and models for partial-pool members, service-level partial-pool orchestration and restoration, API/settings plumbing, frontend controls, startup recovery, and extensive tests across service, model, API, migration, and UI layers.

Changes

Cohort / File(s) Summary
Service Core & Orchestration
internal/services/crossseed/partial_pool.go, internal/services/crossseed/*_test.go, cmd/qui/main.go
Implements partial-pool lifecycle: restoration on startup, background worker, selection/ranking, propagation (hardlink/reflink), registration and persistence wiring; calls crossSeedService.HandleTorrentAdded on torrent add. Wires new partial-pool store into service construction.
Persistence & Models
internal/models/crossseed_partial_pool.go, internal/models/crossseed.go, internal/models/*_test.go
Adds CrossSeedPartialPoolMember/CrossSeedPartialFile types and CrossSeedPartialPoolMemberStore with Upsert/ListActive/GetByAnyHash/DeleteExpired; extends CrossSeedAutomationSettings with three new fields and defaults; persistence read/write updated.
Database Migrations
internal/database/migrations/067_add_cross_seed_partial_pools.sql, internal/database/postgres_migrations/068_add_cross_seed_partial_pools.sql, internal/database/postgres_integration_test.go
Adds three new columns to cross_seed_settings and creates cross_seed_partial_pool_members table (columns, unique constraint, indexes, trigger). Postgres test asserts presence of the new columns.
API & Validation
internal/api/handlers/crossseed.go, internal/api/handlers/crossseed_patch_test.go, internal/api/handlers/crossseed_settings_validation_test.go
Extends automation request/patch types and handlers, adds selective patch decode/merge helpers, validates maxMissingBytesAfterRecheck (min 1 MiB), centralizes settings validation and merges for PUT/PATCH, and updates tests.
Service Tests & Mocks
internal/services/crossseed/*, internal/services/crossseed/*_test.go
Large suite of new/updated tests and test harnesses exercising pool restoration, selection, propagation, edge cases, concurrency, and QBittorrent interactions; adds multiple mock sync managers and helpers.
Frontend & Types
web/src/pages/CrossSeedPage.tsx, web/src/types/index.ts, internal/web/swagger/openapi.yaml
Adds three new global settings fields, BYTES_PER_MIB constant, UI controls and props for Hardlink/Reflink managed partial handling, updates TS types and OpenAPI docs for new fields.
Documentation
documentation/docs/features/cross-seed/*.md
Adds guidance, rules, and troubleshooting for pooled partial completion, reflink single-file size-mismatch override, and related behaviors and caveats.
Tests & DB Test Utilities
internal/models/*_test.go, internal/database/*, internal/services/*, internal/api/*
Updates many unit/integration tests to account for new fields, migrations, and partial-pool behavior (including added Progress fields in some test fixtures).

Sequence Diagram

sequenceDiagram
    participant Ext as External Event (Torrent Added)
    participant SVC as CrossSeed Service
    participant DB as Partial Pool Store
    participant QBT as QBittorrent Sync Manager
    participant WRK as Background Worker

    Ext->>SVC: HandleTorrentAdded(ctx, instanceID, torrent)
    SVC->>DB: GetByAnyHash(target_hash / v2)
    DB-->>SVC: existing member? / nil
    SVC->>DB: Upsert member (if registering)
    SVC->>WRK: wake worker / signal
    WRK->>DB: ListActive(now)
    DB-->>WRK: active members
    WRK->>SVC: processPartialPools()
    SVC->>QBT: GetTorrents(hashes) / GetTorrentFilesBatch
    QBT-->>SVC: torrent metadata & files
    SVC->>SVC: build pool state & select downloader
    SVC->>QBT: propagate files (hardlink or reflink)
    QBT-->>SVC: propagation result
    SVC->>DB: Update member timestamps / DeleteExpired
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

cross-seed, enhancement, database:migrations, api, web, documentation, automations, hardlink, area/backend, area/frontend

Suggested reviewers

  • s0up4200

Poem

🐇 I found a seed, half-grown in the dirt,

I nudged a friend’s patch and shared what it hurt.
Pools hum and rotate, links mend what was torn,
Restored at startup, so no work is forlorn.
Hooray — cooperative seeds sprout at dawn!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(crossseed): add pooled partial completion' directly and clearly describes the main feature being added - pooled partial completion functionality for cross-seed operations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/cross-seed-pooling
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (7)
internal/services/crossseed/matching_layout_test.go (1)

134-149: Strengthen tie precondition to avoid false-positive passes

This test can pass even if candidates are not truly tied for reasons unrelated to cross-seed tagging. Consider asserting both candidates produce the same base match before calling findBestCandidateMatch.

Suggested test hardening
 	sourceRelease := rls.Release{}
 	sourceFiles := qbt.TorrentFiles{{Name: "payload.bin", Size: 4 << 30}}
 	candidate := CrossSeedCandidate{
@@
 	}
 
+	taggedMatch := svc.getMatchType(
+		&sourceRelease,
+		svc.releaseCache.Parse("Minimal.Payload"),
+		sourceFiles,
+		svc.syncManager.(*candidateSelectionSyncManager).files["tagged"],
+	)
+	untaggedMatch := svc.getMatchType(
+		&sourceRelease,
+		svc.releaseCache.Parse("Minimal.Payload"),
+		sourceFiles,
+		svc.syncManager.(*candidateSelectionSyncManager).files["untagged"],
+	)
+	require.Equal(t, taggedMatch, untaggedMatch, "test setup should isolate cross-seed tie-breaker")
+	require.Equal(t, "exact", untaggedMatch)
+
 	filesByHash := svc.batchLoadCandidateFiles(context.Background(), candidate.InstanceID, candidate.Torrents)
 	bestTorrent, files, matchType, _ := svc.findBestCandidateMatch(context.Background(), candidate, &sourceRelease, sourceFiles, filesByHash, 5.0)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/matching_layout_test.go` around lines 134 - 149,
The test currently assumes the two torrents are tied but doesn't verify they
produce the same base match; before calling svc.findBestCandidateMatch,
explicitly compute and assert that both candidate torrents yield the same base
match/match score using the service helper that evaluates a single torrent
against the source (e.g. a computeBaseMatch/score function or the existing
internal matcher), by passing each torrent from candidate.Torrents together with
sourceRelease, sourceFiles and filesByHash (obtained via
svc.batchLoadCandidateFiles), and require that their base match types/scores are
equal; this ensures the test only exercises tag-based tie-breaking in
findBestCandidateMatch.
internal/services/crossseed/rootless_savepath_test.go (1)

456-533: Reflink fallback test may not reliably trigger the fallback scenario.

Unlike the hardlink test (lines 389-392) which explicitly creates a conflict file in managedRoot to force hardlink mode to fail, this reflink test doesn't create any conflict in the managed destination. The test appears to rely on implicit failure (e.g., filesystem lacking CoW support) rather than an explicit trigger.

This makes the test behavior environment-dependent — it may pass for the wrong reason on filesystems that don't support reflinks, or fail unexpectedly on CoW-capable filesystems where reflink mode succeeds.

Consider creating an explicit conflict file similar to the hardlink test to ensure the fallback path is deterministically exercised:

♻️ Suggested fix to add explicit conflict
 	tempDir := t.TempDir()
 	baseDir := filepath.Join(tempDir, "managed")
 	oldRoot := filepath.Join(baseDir, "Old Tracker", pathutil.IsolationFolderName("oldhash12345678", torrentName))
+	managedRoot := filepath.Join(baseDir, "New Tracker", pathutil.IsolationFolderName(newHash, torrentName))
 	expectedRoot := oldRoot
 
 	require.NoError(t, os.MkdirAll(oldRoot, 0o755))
+	require.NoError(t, os.MkdirAll(managedRoot, 0o755))
 	require.NoError(t, os.WriteFile(filepath.Join(oldRoot, "Movie.mkv"), []byte("movie"), 0o600))
+	require.NoError(t, os.WriteFile(filepath.Join(managedRoot, "Movie.mkv"), []byte("conflict"), 0o600))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/rootless_savepath_test.go` around lines 456 -
533, The test TestProcessCrossSeedCandidate_ReflinkFallbackKeepsMatchedSavePath
is environment-dependent because it never creates a conflicting file in the
managed destination to force reflink to fail; modify the test to explicitly
create a conflicting file in the managed savepath (the same path that
Service.processCrossSeedCandidate would attempt to place the reflinked file)
before calling service.processCrossSeedCandidate so reflink mode
deterministically errors and the code falls back to regular copy; mirror the
approach used in the hardlink test by creating the conflict file (e.g., write a
dummy file at the managed destination path) so sync.addedOptions["savepath"]
ends up set to the matched save path as asserted.
internal/services/crossseed/service_candidate_cache_test.go (1)

14-47: Prefer a table-driven structure for these cache scenarios

Both tests share the same setup and differ mainly in case-specific actions/assertions. Consolidating them into table-driven cases will reduce duplication and make new cache scenarios easier to add.

As per coding guidelines, "Prefer table-driven test cases in Go tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/service_candidate_cache_test.go` around lines 14
- 47, Refactor the two tests TestCacheAutomationCandidateResponse_BoundedSize
and TestCacheAutomationCandidateResponse_DuplicateKeyDoesNotGrowOrder into a
single table-driven test that reuses the same setup (creating automationContext
with candidateCache and candidateOrder) and runs per-case actions: one case that
inserts automationCandidateCacheMaxEntries+25 distinct keys and asserts
candidateCache length equals automationCandidateCacheMaxEntries and the oldest
key ("release-0") is evicted, and another case that inserts the same key twice
and asserts candidateCache length is 1 and candidateOrder length is 1 with
"same-release" at index 0; implement cases using a slice of structs with name,
inputs, and expected assertions, and call cacheAutomationCandidateResponse
inside the loop to exercise the behavior while keeping assertions per-case.
internal/models/crossseed_partial_pool_test.go (1)

1-2: Missing copyright header.

Other test files in this PR include the full copyright header (e.g., // Copyright (c) 2025-2026, s0up and the autobrr contributors.). This file only has the SPDX license identifier.

Add copyright header for consistency
+// Copyright (c) 2025-2026, s0up and the autobrr contributors.
 // SPDX-License-Identifier: GPL-2.0-or-later
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/models/crossseed_partial_pool_test.go` around lines 1 - 2, Add the
missing copyright header to the top of the test file so it matches other tests:
insert the full multi-line copyright comment used elsewhere (e.g., the line
starting with "// Copyright (c) 2025-2026, s0up and the autobrr contributors.")
followed by the existing SPDX identifier ("// SPDX-License-Identifier:
GPL-2.0-or-later") at the very top of
internal/models/crossseed_partial_pool_test.go to ensure consistency with other
test files.
internal/web/swagger/openapi.yaml (1)

6227-6231: Expose the 100 MiB default as schema metadata.

maxMissingBytesAfterRecheck documents the default in prose, but generated docs/clients will miss it unless the schema also sets default: 104857600.

📝 Suggested schema tweak
         maxMissingBytesAfterRecheck:
           type: integer
           format: int64
+          default: 104857600
           minimum: 1048576
           description: Maximum missing bytes allowed after recheck for pooled reflink automation before the torrent is left paused for manual review. Minimum 1 MiB, default 100 MiB.

Apply the same addition in both CrossSeedAutomationSettingsPatch and CrossSeedAutomationSettings.

Also applies to: 6335-6339

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/web/swagger/openapi.yaml` around lines 6227 - 6231, The OpenAPI
schema for maxMissingBytesAfterRecheck only documents the 100 MiB default in
text; update the schema by adding default: 104857600 to the
maxMissingBytesAfterRecheck property in both CrossSeedAutomationSettingsPatch
and CrossSeedAutomationSettings so generated docs/clients receive the default
value, ensuring the integer/format and minimum remain unchanged.
internal/services/crossseed/hardlink_mode_test.go (1)

983-985: Assert stopped together with paused here.

These checks currently only pin paused, so they would miss a regression where the add options stop sending the paired stopped flag.

✅ Tighten the assertions
 assert.Equal(t, managedRoot, syncManager.addOptions["savepath"])
 assert.Equal(t, "true", syncManager.addOptions["paused"])
+assert.Equal(t, "true", syncManager.addOptions["stopped"])

Apply the same assertion in each success-path test that inspects paused add options.

Based on learnings: The autobrr/qui project uses a custom go-qbittorrent client library (github.com/autobrr/go-qbittorrent) that supports both "paused" and "stopped" parameters when adding torrents via the options map. Both parameters should be set together when controlling torrent start state, as seen in internal/services/crossseed/service.go and throughout the codebase.

Also applies to: 1127-1128, 1211-1212

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/hardlink_mode_test.go` around lines 983 - 985,
The test currently asserts that syncManager.addOptions["paused"] is "true" but
misses asserting the paired "stopped" flag; update the assertions to also check
syncManager.addOptions["stopped"] == "true" wherever you assert paused (e.g.,
the block with assert.Equal(t, "true", syncManager.addOptions["paused"]) and the
other similar success-path checks later in the same test), ensuring both
parameters are validated together to match the behavior in service.go and the
go-qbittorrent client.
web/src/pages/CrossSeedPage.tsx (1)

646-714: Keep these persisted managed-mode settings visible even when no instance currently uses managed mode.

Line 646 unmounts this whole section, but the same fields are still kept in globalSettings and later serialized in the global PATCH payload. That makes previously enabled pooled/reflink settings easy to save accidentally and impossible to clear until someone re-enables hardlink or reflink on an instance. Prefer rendering the section disabled with an explanatory note instead of hiding it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/pages/CrossSeedPage.tsx` around lines 646 - 714, The section is
currently completely unmounted when managedModeSummary.hasManagedMode is false
which lets persisted flags in globalSettings (enablePooledPartialCompletion,
maxMissingBytesAfterRecheck, allowReflinkSingleFileSizeMismatch) remain set and
be accidentally saved; instead always render the managed-mode UI block but
disable its interactive controls and show an explanatory note when
hasManagedMode is false. Change the top-level conditional from rendering the
whole div to rendering it unconditionally, use managedModeSummary.hasManagedMode
to set disabled on the Switches and the Input (and to grey out explanatory
text), and add a short note inside the block when !hasManagedMode explaining
“These settings are persisted but only applied when an instance enables
hardlink/reflink; you can clear them here even if no instance currently uses
managed mode.”
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/api/handlers/crossseed.go`:
- Around line 35-57: The PUT handler currently decodes into
automationSettingsRequest and overwrites the full
models.CrossSeedAutomationSettings via UpdateAutomationSettings, which clears
persisted fields (rssSource*, webhookSource*, source tags, skipAutoResume*,
skipPieceBoundarySafetyCheck, etc.) and makes MaxMissingBytesAfterRecheck
default to 0 causing validation errors for older clients; fix by loading the
existing models.CrossSeedAutomationSettings inside the PUT `/settings` handler,
decode the incoming payload into automationSettingsRequest, merge only the
provided/nullable fields from automationSettingsRequest into the loaded settings
(preserving all other existing fields such as rss/webhook sources, source tags,
skip flags, and skipPieceBoundarySafetyCheck), apply validation to the merged
settings (don’t treat omitted MaxMissingBytesAfterRecheck as zero-meaningful),
then call UpdateAutomationSettings with the merged model; reference
automationSettingsRequest and UpdateAutomationSettings to locate the relevant
code.

In `@internal/database/postgres_migrations/068_add_cross_seed_partial_pools.sql`:
- Around line 20-30: The Postgres migration for cross_seed_partial_pool_members
is missing the UPDATE trigger that refreshes updated_at (so UPSERTs leave stale
timestamps), causing partialPoolMemberMissingGraceActive() to behave
incorrectly; to fix, add a Postgres trigger and function that sets updated_at =
CURRENT_TIMESTAMP on UPDATE for the cross_seed_partial_pool_members table
(matching the SQLite 067 behavior), or alternatively update the DO UPDATE clause
in crossseed_partial_pool.go to include updated_at = CURRENT_TIMESTAMP in its
SET list; prefer adding the CREATE FUNCTION/CREATE TRIGGER pair for
cross_seed_partial_pool_members to mirror SQLite and ensure updated_at updates
on every UPDATE.

In `@internal/models/crossseed_partial_pool.go`:
- Around line 190-197: GetByAnyHash is currently matching only on
target_hash/target_hash_v2 and can return expired
cross_seed_partial_pool_members; update the SQL built in the query variable to
exclude expired rows by adding an expires_at liveness check (e.g., AND
(expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) or the project's
equivalent NOW() check) so it matches the same liveness rule as ListActive; make
the same change to the other query block referenced around the later lines (the
second query that also selects from cross_seed_partial_pool_members) so both
lookups ignore expired pool members.
- Around line 265-268: In DeleteExpired, don't swallow errors from
result.RowsAffected(): when calling result.RowsAffected() capture and propagate
the error instead of returning (0, nil); change the error handling around the
result.RowsAffected() call so it returns (0, err) (or a wrapped error) to
callers and/or logs the failure; locate the result.RowsAffected() invocation and
the rows variable in DeleteExpired and update the return to propagate the
reported error.

In `@internal/services/crossseed/ensure_cross_category_test.go`:
- Around line 66-73: Replace the manual wg.Add + go func() { defer wg.Done() }()
pattern with the new WaitGroup.Go method: remove the wg.Add(goroutines) call and
the explicit defer wg.Done() inside the goroutine, and call wg.Go(func() {
<-start; errCh <- svc.ensureCrossCategory(context.Background(), 1,
"movies.cross", "/downloads/movies") }) for each iteration so the WaitGroup
manages goroutine lifecycle via Go().

In `@internal/services/crossseed/hardlink_mode_test.go`:
- Around line 603-607: Replace the custom mapsClone usage inside
recheckConfirmationSyncManager.AddTorrent with the stdlib maps.Clone: add an
import for "maps" and change m.addOptions = mapsClone(options) to m.addOptions =
maps.Clone(options); remove or stop using the custom mapsClone helper (and
delete it if unused elsewhere) so the test uses the built-in maps.Clone from Go
1.21+.
- Around line 560-575: The GetTorrents stub in recheckConfirmationSyncManager
currently ignores the filter; update the method (GetTorrents) to respect the
provided qbt.TorrentFilterOptions when selecting which torrents to return (e.g.,
match by infohash or other fields from the filter) so tests reflect production
polling; remove the custom mapsClone helper and replace its usage with the
standard library maps.Clone(options) call; and update the test assertions that
validate added torrents to assert that both "paused" and "stopped" options are
present and set to "true" (matching production behavior) for the added-torrent
checks referenced in the tests.

In `@internal/services/crossseed/partial_pool_test.go`:
- Line 1: Replace the old context/cancel pattern and the C-style loop to satisfy
Go 1.22+ idioms: in the test where you currently do "ctx, cancel :=
context.WithCancel(context.Background()); defer cancel()", remove that and use
the testing T's context via "ctx := t.Context()" (keep uses of ctx unchanged);
and replace the loop "for i := 0; i < 5; i++ { ... }" with the new repeat form
"for range 5 { ... }" (remove the unused loop index i). Ensure these edits are
applied in the same test/function that declares ctx/cancel and the loop so go
fix passes.

In `@internal/services/crossseed/partial_pool.go`:
- Around line 1324-1337: The function dropPartialPoolMember currently calls
removePartialPoolMember even when pausePartialPoolHash fails; change the flow so
you first call pausePartialPoolHash (as is), and only if it succeeds call
removePartialPoolMember and emit the Info log about leaving it paused for manual
review; if pausePartialPoolHash returns an error, log the Debug/Error (already
present) and return without calling removePartialPoolMember or emitting the
success Info message, ensuring the manual-review marker isn't dropped on
transient qBittorrent/API failures (refer to Service.dropPartialPoolMember,
Service.pausePartialPoolHash, and Service.removePartialPoolMember).

In `@internal/services/crossseed/service_candidate_cache_test.go`:
- Line 23: The test uses the old C-style loop "for i := 0; i < total; i++";
update that loop to the Go 1.22+ range-over-int idiom by replacing the loop with
the range form that iterates over the integer "total" (i.e., use the new for i
:= range ... form) so the test compiles with go fix and matches current
idiomatic syntax; locate the loop that references the variable "total" in
service_candidate_cache_test.go and change its header accordingly while leaving
the loop body unchanged.

---

Nitpick comments:
In `@internal/models/crossseed_partial_pool_test.go`:
- Around line 1-2: Add the missing copyright header to the top of the test file
so it matches other tests: insert the full multi-line copyright comment used
elsewhere (e.g., the line starting with "// Copyright (c) 2025-2026, s0up and
the autobrr contributors.") followed by the existing SPDX identifier ("//
SPDX-License-Identifier: GPL-2.0-or-later") at the very top of
internal/models/crossseed_partial_pool_test.go to ensure consistency with other
test files.

In `@internal/services/crossseed/hardlink_mode_test.go`:
- Around line 983-985: The test currently asserts that
syncManager.addOptions["paused"] is "true" but misses asserting the paired
"stopped" flag; update the assertions to also check
syncManager.addOptions["stopped"] == "true" wherever you assert paused (e.g.,
the block with assert.Equal(t, "true", syncManager.addOptions["paused"]) and the
other similar success-path checks later in the same test), ensuring both
parameters are validated together to match the behavior in service.go and the
go-qbittorrent client.

In `@internal/services/crossseed/matching_layout_test.go`:
- Around line 134-149: The test currently assumes the two torrents are tied but
doesn't verify they produce the same base match; before calling
svc.findBestCandidateMatch, explicitly compute and assert that both candidate
torrents yield the same base match/match score using the service helper that
evaluates a single torrent against the source (e.g. a computeBaseMatch/score
function or the existing internal matcher), by passing each torrent from
candidate.Torrents together with sourceRelease, sourceFiles and filesByHash
(obtained via svc.batchLoadCandidateFiles), and require that their base match
types/scores are equal; this ensures the test only exercises tag-based
tie-breaking in findBestCandidateMatch.

In `@internal/services/crossseed/rootless_savepath_test.go`:
- Around line 456-533: The test
TestProcessCrossSeedCandidate_ReflinkFallbackKeepsMatchedSavePath is
environment-dependent because it never creates a conflicting file in the managed
destination to force reflink to fail; modify the test to explicitly create a
conflicting file in the managed savepath (the same path that
Service.processCrossSeedCandidate would attempt to place the reflinked file)
before calling service.processCrossSeedCandidate so reflink mode
deterministically errors and the code falls back to regular copy; mirror the
approach used in the hardlink test by creating the conflict file (e.g., write a
dummy file at the managed destination path) so sync.addedOptions["savepath"]
ends up set to the matched save path as asserted.

In `@internal/services/crossseed/service_candidate_cache_test.go`:
- Around line 14-47: Refactor the two tests
TestCacheAutomationCandidateResponse_BoundedSize and
TestCacheAutomationCandidateResponse_DuplicateKeyDoesNotGrowOrder into a single
table-driven test that reuses the same setup (creating automationContext with
candidateCache and candidateOrder) and runs per-case actions: one case that
inserts automationCandidateCacheMaxEntries+25 distinct keys and asserts
candidateCache length equals automationCandidateCacheMaxEntries and the oldest
key ("release-0") is evicted, and another case that inserts the same key twice
and asserts candidateCache length is 1 and candidateOrder length is 1 with
"same-release" at index 0; implement cases using a slice of structs with name,
inputs, and expected assertions, and call cacheAutomationCandidateResponse
inside the loop to exercise the behavior while keeping assertions per-case.

In `@internal/web/swagger/openapi.yaml`:
- Around line 6227-6231: The OpenAPI schema for maxMissingBytesAfterRecheck only
documents the 100 MiB default in text; update the schema by adding default:
104857600 to the maxMissingBytesAfterRecheck property in both
CrossSeedAutomationSettingsPatch and CrossSeedAutomationSettings so generated
docs/clients receive the default value, ensuring the integer/format and minimum
remain unchanged.

In `@web/src/pages/CrossSeedPage.tsx`:
- Around line 646-714: The section is currently completely unmounted when
managedModeSummary.hasManagedMode is false which lets persisted flags in
globalSettings (enablePooledPartialCompletion, maxMissingBytesAfterRecheck,
allowReflinkSingleFileSizeMismatch) remain set and be accidentally saved;
instead always render the managed-mode UI block but disable its interactive
controls and show an explanatory note when hasManagedMode is false. Change the
top-level conditional from rendering the whole div to rendering it
unconditionally, use managedModeSummary.hasManagedMode to set disabled on the
Switches and the Input (and to grey out explanatory text), and add a short note
inside the block when !hasManagedMode explaining “These settings are persisted
but only applied when an instance enables hardlink/reflink; you can clear them
here even if no instance currently uses managed mode.”
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1bcf8270-89e2-46d7-95b4-e6866d76b5e7

📥 Commits

Reviewing files that changed from the base of the PR and between 77eedd9 and 0b60f38.

📒 Files selected for processing (28)
  • cmd/qui/main.go
  • documentation/docs/features/cross-seed/hardlink-mode.md
  • documentation/docs/features/cross-seed/overview.md
  • documentation/docs/features/cross-seed/rules.md
  • documentation/docs/features/cross-seed/troubleshooting.md
  • internal/api/handlers/crossseed.go
  • internal/api/handlers/crossseed_patch_test.go
  • internal/api/handlers/crossseed_settings_validation_test.go
  • internal/database/migrations/067_add_cross_seed_partial_pools.sql
  • internal/database/postgres_integration_test.go
  • internal/database/postgres_migrations/068_add_cross_seed_partial_pools.sql
  • internal/models/crossseed.go
  • internal/models/crossseed_partial_pool.go
  • internal/models/crossseed_partial_pool_test.go
  • internal/models/crossseed_test.go
  • internal/models/postgres_bool_args_test.go
  • internal/services/crossseed/ensure_cross_category_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/matching_layout_test.go
  • internal/services/crossseed/partial_pool.go
  • internal/services/crossseed/partial_pool_test.go
  • internal/services/crossseed/rootless_savepath_test.go
  • internal/services/crossseed/service.go
  • internal/services/crossseed/service_candidate_cache_test.go
  • internal/services/crossseed/service_completion_queue_test.go
  • internal/web/swagger/openapi.yaml
  • web/src/pages/CrossSeedPage.tsx
  • web/src/types/index.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
internal/api/handlers/crossseed_settings_validation_test.go (1)

30-64: Collapse the PUT/PATCH rejection cases into one table-driven test.

These two tests only vary by HTTP method and handler entrypoint, so a small table would remove duplicated request/response setup and make future validation cases cheaper to extend.

As per coding guidelines, "Prefer table-driven test cases in Go tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/crossseed_settings_validation_test.go` around lines 30
- 64, Collapse the two duplicate tests into a single table-driven test that
iterates over the two variants (PUT -> UpdateAutomationSettings and PATCH ->
PatchAutomationSettings) and asserts the same response; create a slice of test
cases containing the method string and a function or name of the handler
entrypoint to call (e.g., "PUT" / handler.UpdateAutomationSettings and "PATCH" /
handler.PatchAutomationSettings), reuse the common request/recorder setup and
expected assertions (status http.StatusBadRequest and JSON error body), and run
each subtest with t.Run so both cases are exercised without duplicated setup.
internal/api/handlers/crossseed.go (1)

35-35: Consider centralizing the min-byte threshold constant.

minMaxMissingBytesAfterRecheck is currently handler-local. Moving it to a shared model/service constant would reduce drift risk between API, service, and UI validation rules.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handlers/crossseed.go` at line 35, The handler-local constant
minMaxMissingBytesAfterRecheck should be moved to a shared constant in a common
package (e.g., model or service constants) and referenced from there: add an
exported constant (e.g., MinMaxMissingBytesAfterRecheck) in the shared package,
replace the local const minMaxMissingBytesAfterRecheck in crossseed.go with that
exported constant, and update any other API/service/UI places to use the shared
constant to avoid drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@documentation/docs/features/cross-seed/hardlink-mode.md`:
- Around line 123-135: The documentation's "Single-File Size Mismatch Override"
section currently describes unconditional recheck/resume behavior and omits
caveats when other features interact; update the paragraph and any related table
rows to document that the override will trigger qBittorrent recheck only when
"Skip recheck" is disabled and that reflink mode may enter the recheck path for
reasons other than extras (e.g., this single-file size mismatch flow), and
clarify interactions with "pooled partial completion" and the "Allow reflink
single-file size mismatch" option in the "Hardlink / Reflink Mode" UI so users
understand recheck can be skipped if "Skip recheck" is enabled.

In `@internal/database/postgres_migrations/068_add_cross_seed_partial_pools.sql`:
- Around line 6-30: Add a per-instance uniqueness constraint/index for
target_hash_v2 on the cross_seed_partial_pool_members table and update the
upsert logic to respect it: modify the schema for table
cross_seed_partial_pool_members to create a unique index or constraint on
(target_instance_id, target_hash_v2) with a WHERE clause target_hash_v2 IS NOT
NULL (matching the existing UNIQUE(target_instance_id, target_hash) semantics),
and update the store upsert code that writes to cross_seed_partial_pool_members
to include target_hash_v2 in its conflict/ON CONFLICT targeting so
inserts/updates obey the same per-instance uniqueness rule for v2 hashes.

In `@internal/services/crossseed/partial_pool.go`:
- Around line 203-210: When automation settings are nil or
EnablePooledPartialCompletion is false the function currently returns without
removing existing pooled markers, leaving torrents treated as pooled by
partialPoolOwnsTorrent/partialPoolOwnsLiveTorrent; add a cleanup step here to
drain existing pool members: call a new or existing helper (e.g.
s.clearPooledMembers(ctx) or s.drainPooledMembers(ctx)) that deletes
DB/in-memory pool markers for this service and logs any error, then return;
ensure the helper touches the same storage used by
partialPoolOwnsTorrent/partialPoolOwnsLiveTorrent so paused/orphaned members are
removed immediately.

---

Nitpick comments:
In `@internal/api/handlers/crossseed_settings_validation_test.go`:
- Around line 30-64: Collapse the two duplicate tests into a single table-driven
test that iterates over the two variants (PUT -> UpdateAutomationSettings and
PATCH -> PatchAutomationSettings) and asserts the same response; create a slice
of test cases containing the method string and a function or name of the handler
entrypoint to call (e.g., "PUT" / handler.UpdateAutomationSettings and "PATCH" /
handler.PatchAutomationSettings), reuse the common request/recorder setup and
expected assertions (status http.StatusBadRequest and JSON error body), and run
each subtest with t.Run so both cases are exercised without duplicated setup.

In `@internal/api/handlers/crossseed.go`:
- Line 35: The handler-local constant minMaxMissingBytesAfterRecheck should be
moved to a shared constant in a common package (e.g., model or service
constants) and referenced from there: add an exported constant (e.g.,
MinMaxMissingBytesAfterRecheck) in the shared package, replace the local const
minMaxMissingBytesAfterRecheck in crossseed.go with that exported constant, and
update any other API/service/UI places to use the shared constant to avoid
drift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f78187a9-a8d1-42ce-81c6-fdba9c235f19

📥 Commits

Reviewing files that changed from the base of the PR and between 0b60f38 and b9624bc.

📒 Files selected for processing (13)
  • documentation/docs/features/cross-seed/hardlink-mode.md
  • documentation/docs/features/cross-seed/rules.md
  • documentation/docs/features/cross-seed/troubleshooting.md
  • internal/api/handlers/crossseed.go
  • internal/api/handlers/crossseed_settings_validation_test.go
  • internal/database/postgres_migrations/068_add_cross_seed_partial_pools.sql
  • internal/models/crossseed_partial_pool.go
  • internal/models/crossseed_partial_pool_test.go
  • internal/services/crossseed/ensure_cross_category_test.go
  • internal/services/crossseed/hardlink_mode_test.go
  • internal/services/crossseed/partial_pool.go
  • internal/services/crossseed/partial_pool_test.go
  • internal/services/crossseed/service_candidate_cache_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/services/crossseed/service_candidate_cache_test.go
  • documentation/docs/features/cross-seed/rules.md
  • internal/models/crossseed_partial_pool_test.go
  • documentation/docs/features/cross-seed/troubleshooting.md

Comment on lines +6 to +30
CREATE TABLE cross_seed_partial_pool_members (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
source_instance_id BIGINT NOT NULL,
source_hash TEXT NOT NULL,
target_instance_id BIGINT NOT NULL,
target_hash TEXT NOT NULL,
target_hash_v2 TEXT,
target_added_on BIGINT NOT NULL DEFAULT 0,
target_name TEXT NOT NULL DEFAULT '',
mode TEXT NOT NULL,
managed_root TEXT NOT NULL,
source_piece_length BIGINT NOT NULL DEFAULT 0,
max_missing_bytes_after_recheck BIGINT NOT NULL DEFAULT 104857600,
source_files_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
UNIQUE(target_instance_id, target_hash)
);

CREATE INDEX idx_cross_seed_partial_pool_members_source
ON cross_seed_partial_pool_members(source_instance_id, source_hash);

CREATE INDEX idx_cross_seed_partial_pool_members_expires
ON cross_seed_partial_pool_members(expires_at);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make target_hash_v2 unique per instance too.

The new store treats target_hash and target_hash_v2 as interchangeable identities, but this schema only guarantees uniqueness on (target_instance_id, target_hash). A hybrid torrent can therefore be persisted twice if one path keys it by v1 and another by v2, and the later any-hash lookup becomes nondeterministic. Add a per-instance unique index/constraint for target_hash_v2 when it is non-null, and update the store upsert to target the same rule.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/database/postgres_migrations/068_add_cross_seed_partial_pools.sql`
around lines 6 - 30, Add a per-instance uniqueness constraint/index for
target_hash_v2 on the cross_seed_partial_pool_members table and update the
upsert logic to respect it: modify the schema for table
cross_seed_partial_pool_members to create a unique index or constraint on
(target_instance_id, target_hash_v2) with a WHERE clause target_hash_v2 IS NOT
NULL (matching the existing UNIQUE(target_instance_id, target_hash) semantics),
and update the store upsert code that writes to cross_seed_partial_pool_members
to include target_hash_v2 in its conflict/ON CONFLICT targeting so
inserts/updates obey the same per-instance uniqueness rule for v2 hashes.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/services/crossseed/partial_pool.go (1)

800-809: Consider logging or returning a more specific error when directory creation fails.

When os.MkdirAll fails, the error is returned but doesn't indicate which path failed. This could make debugging harder in production.

💡 Optional improvement
 if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
-	return err
+	return fmt.Errorf("create destination directory for %s: %w", dstName, err)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/services/crossseed/partial_pool.go` around lines 800 - 809, The
os.MkdirAll call around filepath.Dir(dstPath) returns the raw error without
context; change the error handling in the block that calls os.MkdirAll to wrap
or log the error with the directory path (use filepath.Dir(dstPath) and dstPath)
so the returned error clearly states which directory creation failed (e.g.
include the directory string in the error message using fmt.Errorf or a logger),
keeping the existing behavior in the subsequent Lstat/Remove handling in
partial_pool.go.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/services/crossseed/partial_pool.go`:
- Around line 800-809: The os.MkdirAll call around filepath.Dir(dstPath) returns
the raw error without context; change the error handling in the block that calls
os.MkdirAll to wrap or log the error with the directory path (use
filepath.Dir(dstPath) and dstPath) so the returned error clearly states which
directory creation failed (e.g. include the directory string in the error
message using fmt.Errorf or a logger), keeping the existing behavior in the
subsequent Lstat/Remove handling in partial_pool.go.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4bf74e66-f925-43d9-a0c9-6362ada88d76

📥 Commits

Reviewing files that changed from the base of the PR and between b9624bc and 269d140.

📒 Files selected for processing (6)
  • documentation/docs/features/cross-seed/hardlink-mode.md
  • internal/services/crossseed/ensure_cross_category_test.go
  • internal/services/crossseed/partial_pool.go
  • internal/services/crossseed/partial_pool_test.go
  • internal/services/crossseed/service.go
  • web/src/pages/CrossSeedPage.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant