Skip to content

feat: add experimental on-demand pinning#11252

Draft
ihlec wants to merge 4 commits into
ipfs:masterfrom
ihlec:master
Draft

feat: add experimental on-demand pinning#11252
ihlec wants to merge 4 commits into
ipfs:masterfrom
ihlec:master

Conversation

@ihlec

@ihlec ihlec commented Mar 26, 2026

Copy link
Copy Markdown

Summary

Automatically pin content when DHT provider counts fall below a configurable replication target, and unpin once
replication has been above target for a grace period.

Helps keeping critical data around, without wasting storage on overly replicated CIDs.

The feature is described in this draft: ipfs/specs#532
The feature is gated behind Experimental.OnDemandPinningEnabled.

CLI commands for ipfs pin ondemand:

  • add -- register CIDs for on-demand pinning
  • rm -- deregister and unpin
  • ls -- list registered CIDs

Design

  • Pin partitioning: the checker needs to distinguish its pins from user
    pins to avoid accidental deletion. This implementation uses boxo's pin name
    field ("on-demand") for that.
  • Storage budget: skips pinning when repo usage exceeds
    StorageMax * StorageGCWatermark.
  • Idle timeout: DAG fetches timeout after 2 minutes
    without receiving new blocks (allows large downloads while skipping dead records).
  • Provide after pin: the checker publishes a DHT provider record after pinning.
  • Sybil limitation: provider counts come from DHT queries, which are
    susceptible to Sybil manipulation. Documented as a known limitation.

Configuration Parameters (TBD)

Option Default Description
OnDemandPinning.ReplicationTarget 5 Minimum providers in DHT (excluding self)
OnDemandPinning.CheckInterval "10m" How often the checker runs
OnDemandPinning.UnpinGracePeriod "24h" How long above target before unpinning

Tests

Unit tests for the checker and store in ondemandpin/.

Visualization of Feature

(out of scope for this CLI PR, but helpful to get the idea)
UI-flow

Add a background checker that automatically pins content when DHT provider counts fall below a configurable replication target and unpins once enough providers exist again after a grace period.

Gated behind Experimental.OnDemandPinningEnabled.
New CLI commands: ipfs pin ondemand {add,rm,ls}

Safety measures:
- storage budget check (respects StorageMax/GCWatermark)
- idle timeout on recursive DAG fetches (2 min without progress)
- pin partitioning via pin name to distinguish on-demand pins from persitent pins.
@ihlec

ihlec commented Mar 31, 2026

Copy link
Copy Markdown
Author

Please focus the review on:

1. Core logic — ondemandpin/

  • ondemandpin/store.go (143 lines) — Datastore with CRUD to manage on-demand pinning CIDs.
  • ondemandpin/checker.go (282 lines) — Frequently queries the DHT for provider counts and pins/unpins accordingly.

2. Kubo integration — wiring and CLI

  • core/node/ondemandpin.go (168 lines) — translates Kubo's pinning system into a simple interface for checker.go.
  • core/commands/pin/ondemandpin.go (280 lines) — Standard cmds boilerplate for on-demand pinning.

*The rest of the files are configuration, glue code, and documentation.

@guillaumemichel guillaumemichel mentioned this pull request Mar 31, 2026
40 tasks
@ihlec

ihlec commented Apr 1, 2026

Copy link
Copy Markdown
Author

@guillaumemichel Please re-run the checks. I addressed the failing ones for overfull lines , typos, and changelog.

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

@lidel

lidel commented Apr 10, 2026

Copy link
Copy Markdown
Member

Triage note: we run out of time to review this in 0.41 iteration, but we've added it to 0.42. Thank you for your contribution and patience. 🙏

@lidel lidel added the need/maintainers-input Needs input from the current maintainer(s) label Apr 10, 2026
@gammazero gammazero added exp/expert Having worked on the specific codebase is important status/blocked Unable to be worked further until needs are met labels May 12, 2026
@gammazero

Copy link
Copy Markdown
Contributor

Triage: need careful review and testing.

@lidel lidel mentioned this pull request May 25, 2026
45 tasks
…ning

# Conflicts:
#	docs/changelogs/v0.42.md
#	docs/config.md
@lidel

lidel commented May 25, 2026

Copy link
Copy Markdown
Member

Triage: short on review bandwidth for v0.42, so we'd like to land this in v0.43 instead. I've rebased on latest master and moved the changelog entry to docs/changelogs/v0.43.md. Nothing for you to do; we'll continue review against v0.43 once the time comes.

@ihlec

ihlec commented Jul 10, 2026

Copy link
Copy Markdown
Author

Hi @lidel , just checking in on this. Please let me know if there’s any additional documentation, context, or testing steps I can provide to help ease the review.

@lidel lidel left a comment

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.

Thanks for waiting @ihlec. This deserved a real review a lot sooner than it got one.

Being straight with you: maintainer time on Kubo is a small shared budget this quarter, and this is a lot of code. A new package, new config, new commands, and a loop that pins and unpins on its own. That is not something I can skim, and a feature that deletes pins by itself really shouldn't be skimmed. I kept not finding the hours, and the release ran out first.

So I'm moving it to draft and off v0.43. That is not a rejection. I believe the idea is good and could be added to future Kubo, but pin ownership, the pin and unpin loop, and the name all need a decision before more code goes in. The inline comments say where I would take each one to be less brittle and respect user config. Feel free to push back on any of it, this is a first-pass set of comments, possible I misunderstood something.

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

Comment thread ondemandpin/checker.go
return
}

if err := c.pins.Pin(ctx, rec.Cid, OnDemandPinName); err != 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.

pinned comes from IsPinned at line 143, before the provider lookup in CountProviders and before Pin fetches the DAG. By the time we pin, it can be minutes old.

If a user runs ipfs pin add --name mydata <cid> in that gap, boxo's doPinRecursive removes the existing recursive pin and re-adds it under the name we pass, so their pin turns into the on-demand pin. Once providers recover and the grace period ends, handleWellReplicated sees OnDemandPinName on it and unpins. The user loses a pin they made themselves.

Could checkRecord re-read the pin state right before it calls Pin? That would not close the race, since the DAG fetch inside Pin still leaves a gap, but it would make the gap much smaller.

Comment thread config/ondemandpin.go
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.

Comment thread ondemandpin/checker.go
log.Info("on-demand pin checker started")
defer log.Info("on-demand pin checker stopped")

ticker := time.NewTicker(c.checkInterval)

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.

None of the three OnDemandPinning options are validated.

A CheckInterval of 0s or a negative value reaches time.NewTicker(c.checkInterval) in Run, and time.NewTicker panics on a non-positive interval. The panic happens in the goroutine started by the OnStart hook in core/node/ondemandpin.go, so one config typo takes the daemon down.

A ReplicationTarget of 0 or less makes count < c.replicationTarget never true. The checker then pins nothing and, once the grace period passes, unpins what it already holds.

Could these be checked at config load, next to the ValidateProvideConfig call in core/node/groups.go (ReplicationTarget >= 1, CheckInterval > 0, UnpinGracePeriod > 0), so a bad value fails at startup with a clear error?

Comment thread ondemandpin/checker.go
log.Errorw("failed to check pin state, skipping CID", "cid", rec.Cid, "error", err)
return
}
if !rec.PinnedByUs && pinned {

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.

🤔PinnedByUs lives in the record, not on the pin, so the two can drift apart.

If the daemon dies between c.pins.Pin (line 183) and c.saveRecord (line 194), or if that save fails (it only logs the error), the pin is on disk while the record still says PinnedByUs=false. From then on this guard reads our own pin as a user pin and skips the CID every cycle. The checker never unpins it once replication recovers, and ipfs pin ondemand ls keeps printing not-pinned for it.

Could the guard ask HasPinWithName(ctx, rec.Cid, OnDemandPinName) instead? handleWellReplicated already calls it before unpinning, and ipfs pin ondemand rm uses PinHasName the same way, so the pin name is already trusted elsewhere.

If the guard trusted it too, PinnedByUs would not be needed at all, and the drift goes away with the field.

Comment thread ondemandpin/store.go
return records, nil
}

func (s *Store) Update(ctx context.Context, rec *Record) error {

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.

🤔 iiuc Update does a blind put with no check that the record still exists, so the checker can recreate a record the user just deleted.

checkRecord reads the Record, then spends up to checkTimeout (5 minutes) on a DHT walk and a DAG fetch. If ipfs pin ondemand rm <cid> lands in that gap, it deletes the key and reports success, but the in-flight saveRecord writes the key back. The CID shows up in ipfs pin ondemand ls again, and the next cycle re-pins it if it is still under target.

The RWMutex does not catch this, since the checker's read and write are separate locked calls.

Could Update do a Has check and return ErrNotRegistered when the key is gone?

},
}

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.

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.

Comment thread config/ondemandpin.go
DefaultOnDemandPinUnpinGracePeriod = 24 * time.Hour
)

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

Comment thread ondemandpin/checker.go
rec.LastAboveTarget = time.Time{}
log.Infow("pinned", "cid", rec.Cid, "providers", count, "target", c.replicationTarget)

if err := c.routing.Provide(ctx, rec.Cid, true); err != 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.

⚠️ This calls routing.Provide directly, so it ignores both Provide.Enabled and Provide.Strategy.

The checker is wired with a raw routing.ContentRouting (core/node/ondemandpin.go), not the node's DHTProvider. When an operator sets Provide.Enabled=false, kubo swaps in NoopProvider so nothing is announced, but this call still goes straight to the DHT. A node that was told not to announce content announces anyway. A bug.

It is also a one-shot, with no place in the reprovide schedule. With the default Provide config (Enabled=true, Strategy=all) the reprovider already keeps these pins announced, so the call is mostly redundant. With a strategy that skips pins (for example mfs), it is the only announcement the node ever makes for the DAG, and the record expires after amino.DefaultProvideValidity. Other nodes running this feature then stop seeing this copy and may pin it themselves, which is what the feature is meant to prevent.

Could the checker take DHTProvider and call StartProviding instead? That respects Provide.Enabled, since the noop provider does nothing when providing is off, and it keeps the CID in the reprovide schedule whatever Provide.Strategy sweeps, so the record does not lapse.

If providing is disabled, this feature cannot really do its job, because other nodes have no way to see this copy. Saying that at startup seems kinder than announcing behind the operator's back, breaking the user expectations.

@lidel lidel marked this pull request as draft July 14, 2026 13:15
On-demand pinning is not landing in v0.43. Review is open and the design
needs changes first, so park the highlight in vFUTURE.md and restore
v0.43.md to the upstream skeleton.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

exp/expert Having worked on the specific codebase is important need/maintainers-input Needs input from the current maintainer(s) status/blocked Unable to be worked further until needs are met

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants