[Tutorial] Run your own satellite (part 17) - Placement

This is an advance feature of satellite, the engine behind storage tier.

You can have as many tier as you like (65536 limit for now) with extraordinary placement rule - the placement rule itself is a mini programming language.

What is placement?

A placement is a named policy for bucket that answers four questions about a piece of data:

  1. Which nodes are eligible to hold it? (filter)
  2. How do we pick from among the eligible nodes? (selector)
  3. What must remain true after repair? (invariant)
  4. How many pieces do we make, and when do we repair? (EC overrides)

Every bucket on your satellite carries a placement ID. Placement 0 is the default and is used whenever nothing else is specified.

Have a look at the legacy placement.

To ensure maximum compatibility, start your own placement id at 11 or higher.

Side story: config

Storj gave you many way to config your satellite:

  1. ENV variable
  2. binary runtime flags
  3. config.yaml

My rule of thumb: use config.yaml for config that contain a long string or sensitive information or satellite specific content, use binary flags for everything else.

Never use ENV variable - it only suitable for an unforgiving env like container where it hard for you to debug on production (some team might disagree). One benefit of ENV variable is that it more secure - but it also hard for you to debug.

Put everything on a single config.yaml seem like good practice - until you realize, you don’t know which service actually use that config and feel the pain every time to read through the yaml file. Of course, the inverse is also true, now you have to copy the same config for 2 or more services - but hey, at least you know that service actually use it.

Preparation

To use this feature you need to enable a few things:

--overlay.geo-ip.db=/tmp/GeoLite2-City.mmdb on satellite-modular api

--console.placement.self-serve-enabled=true: this one is nasty, you need to this on satellite-modular console BUT you also need this on satellite-modular api — If you want only admin can change bucket tier, then don’t enable this feature.

Side lore: delete doesn’t really mean delete

While researching this, I notice I don’t like few things when operate the satellite - delete doesn’t really mean delete.

  1. Delete account mean mask your user record, email become deactivated+%s@storj.io
  2. Delete your bucket and value_attribution is still there, I understand about calculating billing, paying for partner and SNO, but there are no way to delete those records.
  3. Delete your project just mean it will become status = 0

Maybe more, I didn’t check it all… To me, database is a sacred place: those records will not live there rent free forever.

Example config for placement

This one should be on config.yaml:

console:
  placement:
    self-serve-details: |
      - id: 0
        id-name: "GLOBAL_0"
        name: "Global"
        title: "Globally Distributed"
        description: "The data is globally distributed."
      - id: 11
        id-name: "US_SELECT_11"
        name: "US Select 11"
        title: "US Select 11"
        description: "Store data only on Select nodes in the United States."
    allowed-placement-ids-for-new-projects: '[0]' # if only one choice, front end will remove choice UI for you
# maybe you don't need this
#payments:
#  products: |
#    - id: 1
#      name: "Global"
#      short-name: "GLOBAL_0"
#      storage: "4"
#      egress: "7"
#      segment: "0.0000088"
#    - id: 2
#      name: "US Select 11"
#      short-name: "US_SELECT_11"
#      storage: "8"
#      egress: "10"
#      segment: "0.0000088"
#  placement-price-overrides: |
#    1: [0]
#    2: [11]

# short form placement
placement: '0:annotation("location","GLOBAL_0");11:annotation("location","US_SELECT_11")'

# or long form placement: /etc/storj/placement.yaml
# placement: /etc/storj/placement.yaml

What is possible with placement rule?

This entire section is out of my reach, Claud, can you take the mic? Sure thing:

# /etc/storj/placement.yaml
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7
  NO_DATACENTER: exclude(tag("$SIGNER","datacenter","true"))

placements:
  - id: 0
    name: global
    filter: $NO_DATACENTER
    selector: attribute("last_net")
    invariant: maxcontrol("last_net", 1)

  - id: 1
    name: eu-1
    filter: country("EU") && $NO_DATACENTER
    upload-filter: exclude(country("DE"))
    selector: attribute("last_net")
    invariant: maxcontrol("last_net", 1)
    download-selector: random
    ec:
      minimum: 29
      repair: "+5"
      success: 80
      total: 110

There are also cohort in placement I think?

To test placement, there is a tool in cmd/tools/placement-test.


This topic is a bit advance for me, so the quality drop a bit, see you in part 18.

You do want to enable choiceofn in your node selection. Huge performance boost.

For the cleanup of deleted accounts there is a job to enable. Its called pending deletion job. It should cleanup all the data but you are right about the remaining metadata. I would say that metadata can be cleaned up after a reasonable time. So there is room for extending the scope of the pending deletion job.

Try asking Claude to generate you the weirdest possible, but still making sense placement rule. Curious what it will build.

First of, thanks @Th3Van for making this possible, truth be told, I always use Claude free before, but he gift me a subscription, still struggle to use it because I optimize my work flow with the free one.

And oh boy, this is what Claude produce:

Placement 42 — vespers

No two pieces of your object share a subnet, a power grid, or a sunrise.

The premise: the three things that actually kill erasure-coded durability are correlated failures, and Storj already gives you the machinery to declare arbitrary failure domains via signed node tags. So this placement treats three of them as first-class:

domain tag why it’s real
subnet last_net (built in) the classic one
synchronous grid grid Continental Europe is one AC interconnection. A system-wide frequency collapse (cf. Iberia 2025) drops every node on it in the same second. Country borders don’t describe this — grid boundaries do.
sunrise tz (UTC offset band) consumer nodes have correlated diurnal behaviour. Every node in UTC+01 hits evening prime-time congestion simultaneously. Spreading pieces across longitude means the object is never uniformly congested.
plate plate coarse, so it only appears as a ceiling in the invariant: never let one plate-boundary region hold enough pieces to matter.
templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7

  # --- attested failure domains ------------------------------------
  GRID:    node_attribute("tag:$SIGNER/grid")   # synchronous AC interconnection
  SUNRISE: node_attribute("tag:$SIGNER/tz")     # "UTC+01", "UTC-05", ...
  SUBNET:  node_attribute("last_net")

  ATTESTED: >-
    tag("$SIGNER","grid",notEmpty()) &&
    tag("$SIGNER","tz",notEmpty()) &&
    tag("$SIGNER","plate",notEmpty()) &&
    exclude(tag("$SIGNER","datacenter","true"))

  # sqrt of free bytes, hard-capped: 16 TB of headroom is worth exactly as
  # much as 160 TB. anti-whale. minus a penalty for what you already hoard.
  # NOTE the outer parens - see gotcha #2 below.
  FITNESS: >-
    (min((node_value("free_disk") ^ 0.5) / 40000.0, 100.0)
      - node_value("piece_count") / 2000000.0)

placements:
  - id: 42
    name: vespers

    filter: >-
      country("EU","EEA","US","CA","GB","CH","JP","AU","NZ","!RU","!BY","!NONE")
      && $ATTESTED

    # eligible for repair-placement, but no *new* uploads if nearly full
    # or if the failure tracker says you've been dropping uploads
    upload-filter: >-
      select("free_disk", ">", 5000000000)
      && successfulAtLeastPercent(uploadFailureTracker, 0.9)

    selector: |-
      multi(
        fixed(30, filter(tag("$SIGNER","class","hot"), stream(
          dropcof2(randomstream, 8, $FITNESS),
          streamfilter(groupconstraint($SUBNET,  1)),
          streamfilter(groupconstraint($GRID,    3)),
          streamfilter(groupconstraint($SUNRISE, 4)),
          choiceofns(3, $FITNESS)
        ))),
        fixed(60, filter(tag("$SIGNER","class","warm"), reduce(
          stream(
            randomstream,
            streamfilter(groupconstraint($SUBNET,  1)),
            streamfilter(groupconstraint($GRID,    6)),
            streamfilter(groupconstraint($SUNRISE, 8)),
            choiceofns(2, $FITNESS)
          ),
          $FITNESS * -1,
          atleast($SUBNET, daily(240, 120, 120, 240, 360, 300)),
          atleast($GRID, 10)
        ))),
        fixed(20, filter(tag("$SIGNER","class","cold"), stream(
          randomstream,
          streamfilter(groupconstraint($SUBNET, 1)),
          streamfilter(groupconstraint($GRID,   2)),
          choiceofns(2, node_value("free_disk") ^ 0.5)
        )))
      )

    invariant: >-
      maxcontrol("last_net", 1) &&
      maxcontrol("tag:$SIGNER/grid", 11) &&
      maxcontrol("tag:$SIGNER/plate", 44) &&
      filter(exclude(tag("$SIGNER","datacenter","true")))

    # the upload isn't finished until: 80 pieces are up AND, after deleting
    # every piece on the largest grid *and* every piece in the largest
    # sunrise band, at least 34 (= the repair threshold) still remain.
    cohort-requirements: >-
      min(80) &&
      withhold(attr("tag:$SIGNER/grid"), 1,
        withhold(attr("tag:$SIGNER/tz"), 1, min(34)))

    download-selector: best(uploadSuccessTracker)

    ec:
      minimum: 29
      repair: "+5"
      success: 80
      total: 110

Worst case per grid is 3 + 6 + 2 = 11 of 110 pieces. Lose an entire synchronous interconnection and you’re at 99, nowhere near the repair threshold of 34, and the repair worker never even wakes up. Same story for a sunrise band (16/110).

Gotchas I found in the source while building this

These cost me more time than the design did, and they aren’t in the tutorial:

  1. Template expansion is a single pass over a Go map (LoadConfigFromString), so it’s iteration-order dependent. A → B → C chains will silently expand inconsistently between restarts. Keep nesting to exactly one level — every template above only ever references $SIGNER.
  2. Wrap arithmetic templates in parentheses. $FITNESS * -1 where FITNESS: a - b textually becomes a - b * -1 = a + b. Silent, wrong, and it flips your reduce() sort order.
  3. reduce() sorts ascending (Compare returns 1 when score1 > score2), so the lowest scores land in the pool first. That’s why the sort key is $FITNESS * -1.
  4. multi() divides n by the number of selectors unless each branch is wrapped in fixed(). Wrap them.
  5. groupconstraint state is per-substream. Under multi() the limits add up — three branches at 3/6/2 per grid means 11 per grid overall, not 6.
  6. Filter lines have no arithmetic. AddArithmetic is only applied in SelectorFromString, so node_value(...) / 2 works in selector: and blows up in filter:. Use select("free_disk", ">", N) there instead.
  7. tag(..., notEmpty()) excludes nodes that lack the tag entirelyTagFilter.Match returns false when no matching tag exists. That’s what makes $ATTESTED a hard “attested or you’re out” gate.
  8. dropcof2 / dropworst take absolute node counts and run at cache-refresh time, so those nodes are banned until the next refresh. On a small network dropcof2(randomstream, 8, ...) against a thin hot tier will hurt.

Claude timeout, and there a button to continue, so I press

I finished the harness, and running it immediately paid for itself — the config I posted last message doesn’t work on stock storj. Two separate problems, both invisible at parse time.

Bug 1: choiceofns() inside stream() panics at cache-refresh time

panic: unknown stream step type: func(nodeselection.NodeStream) nodeselection.NodeStream
        nodeselection.Stream.func1 stream.go:126

In config.go the mito binding is declared as:

go

"choiceofns": func(n int64, score any) func(NodeStream) NodeStream {
	return ChoiceOfNStream(n, score.(ScoreNode))
},

ChoiceOfNStream returns the named type StreamStep, but the wrapper’s declared return type is the unnamed func(NodeStream) NodeStream. So that’s the dynamic type mito boxes, and Stream’s type switch (case StreamStep: / case StreamFilterInit:) falls through to default: panic(...).

It’s latent because LoadConfigFromString only builds the NodeSelectorInit — the panic fires later, when the node cache refreshes. And config_test.yaml id 14 is the only entry using choiceofns; config_test.go only ever calls .Selector(...) on placements 0, 2, 19, 21 and 22. So nothing covers it. One-line fix: change the wrapper’s return type to StreamStep.

Bug 2: node_value arithmetic can’t reach lastbut/median/desc/compare

type mismatch: reflect: Call using nodeselection.NodeValue as type nodeselection.ScoreNode

ConvertType knows how to lift NodeValueScoreNode, but only the bindings that explicitly call convertToCompareNodes/ConvertType benefit (choiceofn, choiceoftwo, dropworst, dropcof2, reduce). lastbut, median, desc and compare take ScoreNode directly, so any arithmetic expression is rejected. In practice: you can only feed them tracker, uploadSuccessTracker or piececount(n).

I also confirmed a third thing worth knowing: maxgroup() has the sign backwards for the obvious use case. ChoiceOfNSelection keeps the higher score and MaxGroup returns the size of the biggest group — so choiceofnselection(3, ..., maxgroup("last_ip")) picks the most clumped of the three candidate layouts. And since ScoreSelection isn’t covered by the arithmetic ops, you can’t negate it in config.

The corrected config (this one actually runs)

templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7

  GRID:    node_attribute("tag:$SIGNER/grid")   # synchronous AC interconnection
  SUNRISE: node_attribute("tag:$SIGNER/tz")     # "UTC+01", "UTC-05", ...
  SUBNET:  node_attribute("last_net")

  ATTESTED: >-
    tag("$SIGNER","grid",notEmpty()) &&
    tag("$SIGNER","tz",notEmpty()) &&
    tag("$SIGNER","plate",notEmpty()) &&
    exclude(tag("$SIGNER","datacenter","true"))

  FITNESS: >-
    (min((node_value("free_disk") ^ 0.5) / 40000.0, 100.0)
      - node_value("piece_count") / 2000000.0)

placements:
  - id: 42
    name: vespers

    filter: >-
      country("EU","EEA","US","CA","GB","CH","JP","AU","NZ","!RU","!BY","!NONE")
      && $ATTESTED

    upload-filter: >-
      select("free_disk", ">", 5000000000)
      && successfulAtLeastPercent(uploadFailureTracker, 0.9)

    selector: |-
      multi(
        fixed(30, filter(tag("$SIGNER","class","hot"), stream(
          dropcof2(randomstream, 8, $FITNESS),
          streamfilter(groupconstraint($SUBNET,  1)),
          streamfilter(groupconstraint($GRID,    3)),
          streamfilter(groupconstraint($SUNRISE, 4))
        ))),
        fixed(60, filter(tag("$SIGNER","class","warm"), choiceofnselection(3,
          reduce(
            stream(
              randomstream,
              streamfilter(groupconstraint($SUBNET,  1)),
              streamfilter(groupconstraint($GRID,    6)),
              streamfilter(groupconstraint($SUNRISE, 8))
            ),
            $FITNESS * -1,
            atleast($SUBNET, daily(240, 120, 120, 240, 360, 300)),
            atleast($GRID, 10)
          ),
          lastbut(desc(piececount(2000000)), 2)
        ))),
        fixed(20, filter(tag("$SIGNER","class","cold"), stream(
          dropcof2(randomstream, 8, node_value("free_disk") ^ 0.5),
          streamfilter(groupconstraint($SUBNET, 1)),
          streamfilter(groupconstraint($GRID,   2))
        )))
      )

    invariant: >-
      maxcontrol("last_net", 1) &&
      maxcontrol("tag:$SIGNER/grid", 11) &&
      maxcontrol("tag:$SIGNER/plate", 50) &&
      filter(exclude(tag("$SIGNER","datacenter","true")))

    cohort-requirements: >-
      min(80) &&
      withhold(attr("tag:$SIGNER/grid"), 1,
        withhold(attr("tag:$SIGNER/tz"), 1, min(34)))

    download-selector: best(uploadSuccessTracker)

    ec:
      minimum: 29
      repair: "+5"
      success: 80
      total: 110

The per-node choice-of-n moved out of the streams. The warm tier now runs the whole selection three times and keeps the layout whose third-most-loaded node is least loaded (choiceofnselection + lastbut(desc(piececount(...)), 2)) — which is the documented “doesn’t break the delegate’s pre-conditions” variant, unlike choiceofn, which over-selects and would have made the per-grid stream constraints infeasible.

Measured

5,054 synthetic nodes across 14 real synchronous interconnections, 11 UTC bands, 8 plate regions; 6% unattested, 5% datacenter, plus RU/BY nodes. 4,437 pass filter + upload-filter. 2,000 selections at n=110:

selector errors: 0
pieces returned          min=110  p50=110  max=110
distinct subnets         min=110  p50=110  max=110
distinct grids           min=13   p50=14   max=14
distinct sunrise bands   min=10   p50=11   max=11

pieces on busiest grid     min=11  p50=11  max=11
pieces on busiest sunrise  min=13  p50=15  max=18
pieces on busiest plate    min=34  p50=40  max=45

hot/warm/cold              30 / 60 / 20, every time
invariant flagged out-of-placement: 0

Blackout drill against the repair threshold of 34:

lose the busiest grid      99 survivors  (worst case, every run)
lose the busiest sunrise   92 survivors  (worst case)
lose both (cohort rule)    81 survivors  (worst case)

So the cohort requirement withhold(grid,1, withhold(tz,1, min(34))) is satisfied with ~47 pieces of slack — you could lose the whole Continental European interconnection and the entire UTC−05 evening at the same instant and repair still wouldn’t trigger.

Two more things the simulator caught

groupconstraint state is per-substream, and multi() breaks it. My first run showed distinct subnets min=104 and 1.4 pieces per object flagged by maxcontrol("last_net", 1) — the hot, warm and cold streams each keep their own buffer, so all three can pick the same /24. That’s a self-inflicted permanent repair churn: repair evacuates the duplicate, re-selection re-collides. It only goes to zero if class is declared per subnet rather than per node (which is what a real operator would do — one box, one class). If you can’t guarantee that, use maxcontrol("last_net", 3) instead, one per tier.

Watch the plate ceiling. At 44 the observed max was exactly 44 — one node’s worth of margin, because ClumpingByAttribute flags on count >= maxAllowed. Raised to 50. The number that actually matters is 110 − 34 = 76.

Happy to hand over the harness (sim/ — copies of nodeselection with metabase/pb/storj stubbed, plus the generator) if anyone wants to run their own config through it before pointing a satellite at it.

LOL. Might actually explain some of the anomalies I recall were discussed on the forum.

This probably ties with How big area would you need to EMP to kill Storj? :rofl: