Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions config/ondemandpin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package config

import "time"

const (
DefaultOnDemandPinReplicationTarget = 5
DefaultOnDemandPinCheckInterval = 10 * time.Minute
DefaultOnDemandPinUnpinGracePeriod = 24 * time.Hour

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.

⚠️ The 24h DefaultOnDemandPinUnpinGracePeriod is shorter than how long a DHT provider record stays valid (amino.DefaultProvideValidity is 48h), and CountProviders cannot tell a stale record from a live peer.

A provider that goes offline right after the grace timer starts is still returned by the DHT for up to 48h. So this node can stay above ReplicationTarget for the full 24h and unpin while it may be the last one still holding the blocks.

Could DefaultOnDemandPinUnpinGracePeriod be derived from amino.DefaultProvideValidity instead of a hardcoded 24h? With a grace period longer than the record validity, stale records expire before the period ends, the count drops below target, and the timer resets.

config/provide.go already imports the amino package and checks Provide.DHT.Interval against that constant, so the default here would follow upstream if the DHT ever changes it.

)

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.)

// Minimum providers desired in the DHT (excluding self).
ReplicationTarget OptionalInteger

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

// How long replication must stay above target before unpinning.
UnpinGracePeriod OptionalDuration
}
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
280 changes: 280 additions & 0 deletions core/commands/pin/ondemandpin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
package pin

import (
"fmt"
"io"
"time"

cmds "github.com/ipfs/go-ipfs-cmds"
"github.com/ipfs/kubo/config"
cmdenv "github.com/ipfs/kubo/core/commands/cmdenv"
"github.com/ipfs/kubo/core/commands/cmdutils"
"github.com/ipfs/kubo/ondemandpin"
)

const onDemandLiveOptionName = "live"

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.

the ondemand ls command shows the locally stored pin records (CID, pinned status, timestamps). With ---live, it additionally queries the DHT in real-time to report how many providers currently exist

@ihlec ihlec Apr 1, 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.

DHT queries are sequential. Doing ipfs pin ondemand ls --live on hundreds of CIDs will take a long time. Still handy for debugging and development.


var onDemandPinCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Manage on-demand pins.",
ShortDescription: `
On-demand pins when few DHT providers exist in the routing table; unpins after
replication stays above target for a grace period. Requires config
Experimental.OnDemandPinningEnabled.
`,
},
Subcommands: map[string]*cmds.Command{
"add": addOnDemandPinCmd,
"rm": rmOnDemandPinCmd,
"ls": listOnDemandPinCmd,
},
}

type OnDemandPinOutput struct {
Cid string `json:"Cid"`
}

var addOnDemandPinCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Register CIDs for on-demand pinning.",
ShortDescription: `Registers CID(s) for on-demand pinning; checker pins when needed.`,
},
Arguments: []cmds.Argument{
cmds.StringArg("cid", true, true, "CID(s) to register."),
},
Type: OnDemandPinOutput{},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
n, err := cmdenv.GetNode(env)
if err != nil {
return err
}

cfg, err := n.Repo.Config()
if err != nil {
return err
}
if !cfg.Experimental.OnDemandPinningEnabled {
return fmt.Errorf("on-demand pinning is not enabled; set Experimental.OnDemandPinningEnabled = true in config")
}

store := n.OnDemandPinStore

api, err := cmdenv.GetApi(env, req)
if err != nil {
return err
}

for _, arg := range req.Arguments {
p, err := cmdutils.PathOrCidPath(arg)
if err != nil {
return fmt.Errorf("invalid CID or path %q: %w", arg, err)
}

rp, _, err := api.ResolvePath(req.Context, p)
if err != nil {
return fmt.Errorf("resolving %q: %w", arg, err)
}
c := rp.RootCid()

if err := store.Add(req.Context, c); err != nil {
return err
}

if checker := n.OnDemandPinChecker; checker != nil {
checker.Enqueue(c)
}

if err := res.Emit(&OnDemandPinOutput{
Cid: c.String(),
}); err != nil {
return err
}
}
return nil
},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *OnDemandPinOutput) error {
fmt.Fprintf(w, "registered %s for on-demand pinning\n", out.Cid)
return nil
}),
},
}

var rmOnDemandPinCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "Remove CIDs from on-demand pinning.",
ShortDescription: `
Removes CID(s) from the registry. Checker-pinned content is unpinned.

Works when on-demand pinning is disabled, to clear old registrations.
`,
},
Arguments: []cmds.Argument{
cmds.StringArg("cid", true, true, "CID(s) to remove."),
},
Type: OnDemandPinOutput{},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
n, err := cmdenv.GetNode(env)
if err != nil {
return err
}

store := n.OnDemandPinStore

api, err := cmdenv.GetApi(env, req)
if err != nil {
return err
}

for _, arg := range req.Arguments {
p, err := cmdutils.PathOrCidPath(arg)
if err != nil {
return fmt.Errorf("invalid CID or path %q: %w", arg, err)
}

rp, _, err := api.ResolvePath(req.Context, p)
if err != nil {
return fmt.Errorf("resolving %q: %w", arg, err)
}
c := rp.RootCid()

isOurs, err := ondemandpin.PinHasName(req.Context, n.Pinning, c, ondemandpin.OnDemandPinName)

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.

ipfs pin ondemand rm decides what to unpin from the pin name (PinHasName with OnDemandPinName), and on-demand is not reserved: ValidatePinName only limits length.

So a user can create their own pin with ipfs pin add --name on-demand, and ipfs pin ondemand rm on that CID will delete it.

The order makes it worse: api.Pin().Rm runs before store.Remove, so for a CID that was never registered the pin is already gone by the time ErrNotRegistered reaches the user.

Could rm read the store first and unpin only when the record exists and PinnedByUs is set? That keys ownership on the store record instead of a free-text name, so nothing has to be reserved; if you would rather keep the name check, namespacing OnDemandPinName as something like kubo:on-demand and rejecting that prefix in ValidatePinName would work too.

if err != nil {
return fmt.Errorf("checking pin state for %s: %w", c, err)
}
if isOurs {
if err := api.Pin().Rm(req.Context, rp); err != nil {
return fmt.Errorf("unpinning %s: %w", c, err)
}
}

if err := store.Remove(req.Context, c); err != nil {
return err
}

if err := res.Emit(&OnDemandPinOutput{
Cid: c.String(),
}); err != nil {
return err
}
}
return nil
},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *OnDemandPinOutput) error {
fmt.Fprintf(w, "removed %s from on-demand pinning\n", out.Cid)
return nil
}),
},
}

type OnDemandLsOutput 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.

General UX nits: for a feature that unpins on its own, ls says little about why.

The stored Record only has PinnedByUs, LastAboveTarget and CreatedAt, and --live gives a fresh DHT count, not what the checker last saw or did. So these all print the same not-pinned: count above target, a user pin in the way (checkRecord skips those), hasStorageBudget false (only a warn log), or a pin that failed or ran past checkTimeout.

The unpin time is knowable, above-target-since plus the configured UnpinGracePeriod, but the operator has to open the config and do the math.

Could ondemandpin.Record also keep LastCheckedAt, LastProviderCount and a short LastResult reason, so ls can show them and a computed UnpinAt?

A dry-run mode would help too, so an operator can watch what the checker would do for a week before letting it unpin anything.

Cid string `json:"Cid"`
PinnedByUs bool `json:"PinnedByUs"`
Providers *int `json:"Providers,omitempty"`
LastAboveTarget string `json:"LastAboveTarget,omitempty"`
CreatedAt string `json:"CreatedAt"`
}

var listOnDemandPinCmd = &cmds.Command{
Helptext: cmds.HelpText{
Tagline: "List on-demand pins.",
ShortDescription: `
Lists CIDs registered for on-demand pinning with their current state.
Use --live to include real-time provider counts from the DHT.
`,
},
Arguments: []cmds.Argument{
cmds.StringArg("cid", false, true, "Optional CID(s) to filter."),
},
Options: []cmds.Option{
cmds.BoolOption(onDemandLiveOptionName, "l", "Perform live provider lookup."),
},
Type: OnDemandLsOutput{},
Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
n, err := cmdenv.GetNode(env)
if err != nil {
return err
}

store := n.OnDemandPinStore

live, _ := req.Options[onDemandLiveOptionName].(bool)

var globalTarget int
if live {
cfg, err := n.Repo.Config()
if err != nil {
return err
}
globalTarget = int(cfg.OnDemandPinning.ReplicationTarget.WithDefault(config.DefaultOnDemandPinReplicationTarget))
}

var records []ondemandpin.Record
if len(req.Arguments) > 0 {
api, err := cmdenv.GetApi(env, req)
if err != nil {
return err
}
for _, arg := range req.Arguments {
p, err := cmdutils.PathOrCidPath(arg)
if err != nil {
return fmt.Errorf("invalid CID or path %q: %w", arg, err)
}
rp, _, err := api.ResolvePath(req.Context, p)
if err != nil {
return fmt.Errorf("resolving %q: %w", arg, err)
}
rec, err := store.Get(req.Context, rp.RootCid())
if err != nil {
return err
}
records = append(records, *rec)
}
} else {
records, err = store.List(req.Context)
if err != nil {
return err
}
}

for _, rec := range records {
out := OnDemandLsOutput{
Cid: rec.Cid.String(),
PinnedByUs: rec.PinnedByUs,
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
}
if !rec.LastAboveTarget.IsZero() {
out.LastAboveTarget = rec.LastAboveTarget.Format(time.RFC3339)
}

if live && n.Routing != nil {

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.

🤔 hm.. iiuc this also is pretty brittle: CountProviders runs once per record inside the loop, on req.Context with no deadline, so ls --live takes as long as all the DHT lookups added up, and one CID that nobody provides can stall the rest of the listing.

checkRecord bounds each check with checkTimeout. A context.WithTimeout per lookup, plus a few running in parallel, would do the same here.

⚠️ The other one is the n.Routing != nil guard: with no routing, Providers stays nil, so the text output drops the providers= column and JSON omits the field. The result looks like a plain ls, with no hint that --live did nothing.

Could it return an error there, or explicitly say that provider counts are unavailable? In general, try to write code that avoids hiding the fact silent failures occured, better to be explicit and/or loud.

count := ondemandpin.CountProviders(req.Context, n.Routing, n.Identity, rec.Cid, globalTarget)
out.Providers = &count
}

if err := res.Emit(&out); err != nil {
return err
}
}
return nil
},
Encoders: cmds.EncoderMap{
cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, out *OnDemandLsOutput) error {
pinState := "not-pinned"
if out.PinnedByUs {
pinState = "pinned"
}
fmt.Fprintf(w, "%s", out.Cid)
if out.Providers != nil {
fmt.Fprintf(w, " providers=%d", *out.Providers)
}
fmt.Fprintf(w, " %s created=%s", pinState, out.CreatedAt)
if out.LastAboveTarget != "" {
fmt.Fprintf(w, " above-target-since=%s", out.LastAboveTarget)
}
fmt.Fprintln(w)
return nil
}),
},
}
2 changes: 2 additions & 0 deletions core/commands/pin/pin.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ var PinCmd = &cmds.Command{
"verify": verifyPinCmd,
"update": updatePinCmd,
"remote": remotePinCmd,

"ondemand": onDemandPinCmd,
},
}

Expand Down
4 changes: 4 additions & 0 deletions core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import (
"github.com/ipfs/kubo/core/node"
"github.com/ipfs/kubo/core/node/libp2p"
"github.com/ipfs/kubo/fuse/mount"
"github.com/ipfs/kubo/ondemandpin"
"github.com/ipfs/kubo/p2p"
"github.com/ipfs/kubo/repo"
irouting "github.com/ipfs/kubo/routing"
Expand All @@ -76,6 +77,9 @@ type IpfsNode struct {
PrivateKey ic.PrivKey `optional:"true"` // the local node's private Key
PNetFingerprint libp2p.PNetFingerprint `optional:"true"` // fingerprint of private network

OnDemandPinStore *ondemandpin.Store
OnDemandPinChecker *ondemandpin.Checker `optional:"true"`

// Services
Peerstore pstore.Peerstore `optional:"true"` // storage for other Peer instances
Blockstore bstore.GCBlockstore // the block store (lower level)
Expand Down
5 changes: 5 additions & 0 deletions core/node/groups.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,8 @@ func Online(bcfg *BuildCfg, cfg *config.Config, userResourceOverrides rcmgr.Part
// new CIDs still announce via fast-provide-root and 'ipfs provide once'.
isProviderEnabled := cfg.Provide.Enabled.WithDefault(config.DefaultProvideEnabled)

isOnDemandPinEnabled := cfg.Experimental.OnDemandPinningEnabled

return fx.Options(
fx.Provide(BitswapOptions(cfg)),
fx.Provide(Bitswap(isBitswapServerEnabled, isBitswapLibp2pEnabled, isHTTPRetrievalEnabled)),
Expand All @@ -370,6 +372,8 @@ func Online(bcfg *BuildCfg, cfg *config.Config, userResourceOverrides rcmgr.Part

LibP2P(bcfg, cfg, userResourceOverrides),
OnlineProviders(isProviderEnabled, cfg),

maybeProvide(OnDemandPinChecker(cfg.OnDemandPinning), isOnDemandPinEnabled),

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.

⚠ ️gap: the checker is wired on Experimental.OnDemandPinningEnabled alone, with no check that routing can actually answer provider queries.

CountProviders in ondemandpin/checker.go uses FindProvidersAsync, which has no error signal (!), so a lookup that never ran looks the same as a CID with zero providers. With Routing.Type=none the router is routinghelpers.Null and its FindProvidersAsync returns a closed channel, so the count is always 0. A DHT that is still bootstrapping, or an HTTP router that is down, also gives 0. Too many footguns.

Since 0 is always below ReplicationTarget, the checker pins every registered CID. While the router stays quiet, handleWellReplicated is never reached, so those pins are never released.

Better to error explciitly than to be silent and pretend things work fine if user has custom config. This saves or burns days of debugging.

Could the checker refuse to start when the routing system cannot do provider lookups, and treat a failed lookup as unknown instead of as a real count of 0?

)
}

Expand Down Expand Up @@ -463,6 +467,7 @@ func IPFS(ctx context.Context, bcfg *BuildCfg) fx.Option {
fx.Provide(BlockService(cfg)),
fx.Provide(Pinning(providerStrategy)),
fx.Provide(Files(providerStrategy)),
fx.Provide(OnDemandPinStore),
Core,
)
}
Loading
Loading