Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
85e21f7
feat: add experimental on-demand pinning
ihlec Mar 26, 2026
1015c18
fix: addressed failing checks by cleaning up comments, spelling, and …
ihlec Apr 1, 2026
a0a150f
docs: move changelog highlight to vFUTURE
lidel Jul 14, 2026
4815e8c
fix(pin): reserve "kubo:on-demand" pin names, make ondemand rm non-de…
ihlec Jul 17, 2026
b653636
fix(ondemandpin): do not overwrite pins created during provider lookup
ihlec Jul 21, 2026
e66ba68
fix(ondemandpin): derive unpin grace period from DHT record validity
ihlec Jul 21, 2026
e022855
fix(ondemandpin): validate OnDemandPinning config at daemon start
ihlec Jul 21, 2026
d8d5a54
fix(ondemandpin): key ownership on kubo:on-demand pin name only
ihlec Jul 21, 2026
212f15e
fix(ondemandpin): refuse Null routing and treat failed lookups as unk…
ihlec Jul 21, 2026
bc6fb3f
fix(ondemandpin): add min/max replication deadband and grace jitter
ihlec Jul 21, 2026
fa13068
fix(ondemandpin): exponential check backoff for failing CIDs
ihlec Jul 21, 2026
c3b6891
fix(ondemandpin): do not apply checkTimeout to Pin fetches
ihlec Jul 21, 2026
974103c
fix(ondemandpin): do not resurrect records removed during a check
ihlec Jul 21, 2026
34a679a
fix(ondemandpin): more details for ls and DryRun mode
ihlec Jul 22, 2026
3066da3
fix(ondemandpin): make ls --live explicit about routing and lookup fa…
ihlec Jul 22, 2026
6d89f24
docs(ondemandpin): clarify naming and ipfs-cluster differentiation
ihlec Jul 22, 2026
34f707b
fix(ondemandpin): announce pins via DHTProvider.StartProviding
ihlec Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ type Config struct {
Import Import
Version Version

OnDemandPinning OnDemandPinning

Internal Internal // experimental/unstable options

Bitswap Bitswap
Expand Down
1 change: 1 addition & 0 deletions config/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type Experiments struct {
OptimisticProvide bool
OptimisticProvideJobsPoolSize int
GatewayOverLibp2p bool `json:",omitempty"`
OnDemandPinningEnabled bool

GraphsyncEnabled graphsyncEnabled `json:",omitempty"`
AcceleratedDHTClient experimentalAcceleratedDHTClient `json:",omitempty"`
Expand Down
74 changes: 74 additions & 0 deletions config/ondemandpin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package config

import (
"fmt"
"time"

"github.com/libp2p/go-libp2p-kad-dht/amino"
)

const (
DefaultOnDemandPinReplicationTargetMin = 5
DefaultOnDemandPinReplicationTargetMax = 7
DefaultOnDemandPinCheckInterval = 10 * time.Minute

// Must exceed amino.DefaultProvideValidity so stale DHT records expire
// before unpin. The extra day covers check-interval skew.
DefaultOnDemandPinUnpinGracePeriod = amino.DefaultProvideValidity + 24*time.Hour

// Max NextCheckAt delay after repeated check failures (Cap).
DefaultOnDemandPinCheckBackoffMax = DefaultOnDemandPinUnpinGracePeriod
)

type OnDemandPinning struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ℹ️ Could we settle the naming before this ships, and borrow ipfs-cluster's words instead of new ones?

"On demand" normally means fetch it when someone asks for it (video on demand). This checker does the opposite: it counts providers and pins when the content is rare. Cluster already has a vocabulary for that idea: replication_factor_min and replication_factor_max in its config, ReplicationFactorMin and ReplicationFactorMax in its API, --replication-min and --replication-max in ipfs-cluster-ctl. The mechanism is not the same (cluster allocates among known peers, this checker observes the DHT), but the user-facing question is: how many copies should exist, and what happens when there are too few.

Concretely: config section ReplicationPinning, Experimental.ReplicationPinningEnabled, CLI ipfs pin replication add|rm|ls, package replicationpin/.

The min/max pair is worth borrowing too, not just the words. Cluster's min is the minimum healthy copies, and its max is the target number of nodes that should pin. The gap between the two is the deadband this checker does not have today (see my note on checkRecord): pin below min, unpin only above max. A single ReplicationTarget cannot express that.

The --help text would carry the same vocabulary. While it is being touched: the tagline says content is pinned "when few DHT providers exist in the routing table", but provider records are not in the routing table, they are found with FindProviders.

This is much easier now than later. The pin name on-demand and the datastore prefix /ondemand-pins/ both end up on disk, so renaming after a release means a migration for anyone who turned the experiment on. ipfs/specs#532 carries the same term in its title.

If the current naming is deliberate, I would rather hear your reasoning than push a rename through.

@ihlec ihlec Jul 14, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

When going forward with ReplicationPinning, the user might ask:

  1. Does a regular pin NOT replicate?
  2. Whats the difference to normal pinning?

I chose the wording to reflect a "demand to replicate". If a CID is at risk to be lost - only then start pinning.

Keeping the word "pinning" is essential to show the similarities. So that is a strength of both names that we should keep.

ReplicationPinning tells the user what it is doing, but does not tell the user what it is not doing.
I think we need to point out that it is a non-permanent pinning with conditions.

We could go with ConditionalPinning to keep the feature extensible for other conditions. Available disk space is such a condition, so it would be transparent that the replication demand is not the only condition.

On the other hand, I still think OnDemand pinning reflects the goal of the feature very well (The network needs you to jump in and replicate to protect the data from being lost).
""On demand" normally means fetch it when someone asks for it" - In this case the network asks for it.

"Uncle Sam wants you" vs "IPFS demands you to protect your data."

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I am open to follow your intuition here. Feel free to make a decision after considering my argument.

@lidel lidel Jul 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't feel strongly about this tbh, just wanted to ensure the name is not a placeholder.

If you want to keep "on demand" that is fine with me, just make sure docs / --help text on the CLI commands explain that this feature is a decentralized and uncoordinated sibling of "replication factors" in ipfs-cluster (https://ipfscluster.io/documentation/guides/pinning/#replication-factors). (It is useful to tell people what feature is by how it is different (serving different need) from other thing they may be already familiar with.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

clarified the naming and added ipfs-cluster differentiation

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 6d89f24

// Pin when fewer than this many providers are found in the DHT (excluding self).
ReplicationTargetMin OptionalInteger

// Start the unpin grace period only when more than this many providers are found.
ReplicationTargetMax OptionalInteger

// How often the checker evaluates all registered CIDs.
CheckInterval OptionalDuration

// How long replication must stay above max before unpinning; checker adds up to 2*CheckInterval of jitter.
UnpinGracePeriod OptionalDuration

// When true, the checker logs pin/unpin decisions but does not change the pinset.
DryRun Flag `json:",omitempty"`
}

// ValidateOnDemandPinningConfig rejects invalid min/max and non-positive durations.
func ValidateOnDemandPinningConfig(cfg *OnDemandPinning) error {
min := cfg.ReplicationTargetMin.WithDefault(DefaultOnDemandPinReplicationTargetMin)
max := cfg.ReplicationTargetMax.WithDefault(DefaultOnDemandPinReplicationTargetMax)
if min < 1 {
return fmt.Errorf("OnDemandPinning.ReplicationTargetMin must be at least 1, got %d", min)
}
if max < min {
return fmt.Errorf("OnDemandPinning.ReplicationTargetMax (%d) must be >= ReplicationTargetMin (%d)", max, min)
}
if interval := cfg.CheckInterval.WithDefault(DefaultOnDemandPinCheckInterval); interval <= 0 {
return fmt.Errorf("OnDemandPinning.CheckInterval must be positive, got %v", interval)
}
if grace := cfg.UnpinGracePeriod.WithDefault(DefaultOnDemandPinUnpinGracePeriod); grace <= 0 {
return fmt.Errorf("OnDemandPinning.UnpinGracePeriod must be positive, got %v", grace)
}
return nil
}

// ValidateOnDemandPinningRouting rejects Routing.Type=none.
// Otherwise the checker would pin every registered CID on a null router.
func ValidateOnDemandPinningRouting(routingType string) error {
if routingType == "none" {
return fmt.Errorf("on-demand pinning needs provider lookups; Routing.Type=%q is not usable", routingType)
}
return nil
}

// ValidateOnDemandPinningProvide rejects Provide.Enabled=false (pins would be invisible).
func ValidateOnDemandPinningProvide(provideEnabled bool) error {
if !provideEnabled {
return fmt.Errorf("on-demand pinning needs Provide.Enabled; other nodes cannot discover local pins when providing is off")
}
return nil
}
54 changes: 54 additions & 0 deletions config/ondemandpin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package config

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
)

func TestValidateOnDemandPinningConfig(t *testing.T) {
t.Run("defaults are valid", func(t *testing.T) {
err := ValidateOnDemandPinningConfig(&OnDemandPinning{})
assert.NoError(t, err)
})

t.Run("zero replication min is rejected", func(t *testing.T) {
cfg := &OnDemandPinning{ReplicationTargetMin: *NewOptionalInteger(0)}
err := ValidateOnDemandPinningConfig(cfg)
assert.ErrorContains(t, err, "ReplicationTargetMin")
})

t.Run("max below min is rejected", func(t *testing.T) {
cfg := &OnDemandPinning{
ReplicationTargetMin: *NewOptionalInteger(5),
ReplicationTargetMax: *NewOptionalInteger(4),
}
err := ValidateOnDemandPinningConfig(cfg)
assert.ErrorContains(t, err, "ReplicationTargetMax")
})

t.Run("zero check interval is rejected", func(t *testing.T) {
cfg := &OnDemandPinning{CheckInterval: *NewOptionalDuration(0)}
err := ValidateOnDemandPinningConfig(cfg)
assert.ErrorContains(t, err, "CheckInterval")
})

t.Run("negative grace period is rejected", func(t *testing.T) {
cfg := &OnDemandPinning{UnpinGracePeriod: *NewOptionalDuration(-time.Hour)}
err := ValidateOnDemandPinningConfig(cfg)
assert.ErrorContains(t, err, "UnpinGracePeriod")
})
}

func TestValidateOnDemandPinningRouting(t *testing.T) {
assert.NoError(t, ValidateOnDemandPinningRouting("auto"))
assert.NoError(t, ValidateOnDemandPinningRouting("dht"))
assert.NoError(t, ValidateOnDemandPinningRouting("delegated"))
assert.ErrorContains(t, ValidateOnDemandPinningRouting("none"), "none")
}

func TestValidateOnDemandPinningProvide(t *testing.T) {
assert.NoError(t, ValidateOnDemandPinningProvide(true))
assert.ErrorContains(t, ValidateOnDemandPinningProvide(false), "Provide.Enabled")
}
15 changes: 13 additions & 2 deletions core/commands/cmdutils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmdutils
import (
"fmt"
"slices"
"strings"

cmds "github.com/ipfs/go-ipfs-cmds"

Expand Down Expand Up @@ -54,14 +55,24 @@ func CheckBlockSize(req *cmds.Request, size uint64) error {
return nil
}

// ValidatePinName validates that a pin name does not exceed the maximum allowed byte length.
// Returns an error if the name exceeds MaxPinNameBytes (255 bytes).
// ReservedPinNamePrefix is the namespace for pins created by Kubo-internal
// features rather than by users (e.g. "kubo:on-demand" for on-demand pinning).
// ValidatePinName rejects user-supplied names with this prefix, so a pin name
// under this namespace reliably identifies a Kubo-internal pin.
const ReservedPinNamePrefix = "kubo:"

// ValidatePinName validates that a pin name does not exceed the maximum allowed
// byte length and does not use the Kubo-internal "kubo:" namespace.
func ValidatePinName(name string) error {
if name == "" {
// Empty names are allowed
return nil
}

if strings.HasPrefix(name, ReservedPinNamePrefix) {
return fmt.Errorf("pin names starting with %q are reserved for kubo-internal pins", ReservedPinNamePrefix)
}

nameBytes := len([]byte(name))
if nameBytes > MaxPinNameBytes {
return fmt.Errorf("pin name is %d bytes (max %d bytes)", nameBytes, MaxPinNameBytes)
Expand Down
6 changes: 6 additions & 0 deletions core/commands/cmdutils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,10 @@ func TestValidatePinName(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "bytes")
})

t.Run("pin name with reserved prefix is rejected", func(t *testing.T) {
err := ValidatePinName(ReservedPinNamePrefix + "on-demand")
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved")
})
}
4 changes: 4 additions & 0 deletions core/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ func TestCommands(t *testing.T) {
"/pin/remote/service/add",
"/pin/remote/service/ls",
"/pin/remote/service/rm",
"/pin/ondemand",
"/pin/ondemand/add",
"/pin/ondemand/rm",
"/pin/ondemand/ls",
"/pin/rm",
"/pin/update",
"/pin/verify",
Expand Down
Loading