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


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 entirely — TagFilter.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 NodeValue → ScoreNode, 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:

Less secure. Anyone who can inspect processes on the host will see the values.

Any reasonable server OS limits this to the process’ user and root, so it follows exactly the OS security boundaries.


Here we see corelated nodes example, even it looks like different countries, in reality it look like some VPS gateway used.

Could as well be a routing issue of the ISP that conducts these checks.

It is time to learn this seriously, one thing that trip me up while learning:

filters only mean to remove nodes, never added (except maybe `||` operator).

// Edit later
country("*") is also an added.

So if you don’t have any filter, that very close to Global.

Continent

continent() keeps a short list of two-letter codes: NA, SA, EU, AS, AF, OC and AN.

It sit on shared/location/continent.go.

Why not use GeoLite2-City.mmdb?

Probably because MaxMind’s database only maps a node’s IP to a country (see overlay/service.go), it never mentions continents.

Example

templates:
  SIGNER_ZERO: 1111111111111111111111111111111VyS547o
  NORMAL: exclude(tag("$SIGNER_ZERO","datacenter","true"))

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

  # 1. Plain: one continent, keep the default spreading rules
  - id: 10
    name: south-america
    filter: continent("SA") && $NORMAL
    invariant: maxcontrol("last_net",1)
    selector: attribute("last_net")

  # 2. Union of continents (two calls joined with ||)
  - id: 11
    name: americas
    filter: (continent("NA") || continent("SA")) && $NORMAL
    invariant: maxcontrol("last_net",1)
    selector: attribute("last_net")

  # 3. Exclude a continent (nodes with unresolved country also pass)
  - id: 12
    name: not-africa
    filter: continent("!AF") && $NORMAL
    selector: attribute("last_net")

  # 4. Continent minus specific countries, using country() as a carve-out
  - id: 13
    name: europe-minus-russia
    filter: continent("EU") && country("*","!RU") && $NORMAL
    selector: attribute("last_net")

  # 5. Continent plus a tag: only nodes with a signer tag, in Asia
  - id: 14
    name: asia-tagged
    filter: continent("AS") && tag("$SIGNER_ZERO","tier","gold")
    selector: attribute("last_net")

  # 6. Different rules for new data vs existing data:
  #    stored anywhere in Oceania, but never send new data to Australia
  - id: 15
    name: oceania-no-au-new
    filter: continent("OC") && $NORMAL
    upload-filter: exclude(country("AU"))
    selector: attribute("last_net")

Explain filter id: 12:

Node continent(“!AF”) $NORMAL Result
Home node in Germany pass pass eligible
Home node in Kenya fail pass excluded
Datacenter node in Germany pass fail excluded
Datacenter node in Kenya fail fail excluded
Node with unresolved country, no tag pass pass eligible

Filter vs Attribute

What is filter and what is attribute?

Kind Type in code Question it answers Example
Filter NodeFilter: node → bool Is this node allowed? continent(SA), country(DE)
Attribute NodeAttribute: node → string What label does this node carry? last_net, country, tag:…/dc

continent("SA") is a filter. It builds a set of country codes from location. Continents and checks each node’s country against it.

last_net is an attribute. It returns a string, such as 203.0.113.0, and a string can’t answer allowed/not allowed. It only becomes useful when something else groups, compares or tests against it.

Example

# example in satellite/nodeselection/config_test.yaml

- id: 3
  name: ifeqselector
  # attribute expressions can be used in both selector and invariant definitions
  invariant: maxcontrol(if(eq("tag:surge","true"),"last_ip_port","last_net"),1)
  selector: attribute(if(eq("tag:surge","true"),"last_ip_port","last_net"))

The example above do not have filter, so can I just copy maxcontrol(if(eq("tag:surge","true"),"last_ip_port","last_net"),1) and put into filter?

Turn out, there are limited amount of supported syntax for filters, var supportedFilters.

Here are the complete (current) list keyword can be use:

country
continent
all
&&
||
tag
exclude
empty
notEmpty
nodelist
select
none
successfulAtLeastPercent

As for attribute, the keyword is var supportedAttributes:

node_attribute
subnet
eq
if
group
same

Gotchas:

So I notice some wierd behavior when trying to use filter, these are the same:

exclude(country("DE"))
country("*", "!DE")

The syntax below are legitimate and the same:

exclude(continent("AF"))
continent("!AF")

But there are no syntax for continent("*", "!AF"), so - there a slight different between syntax design between continent and country, when you design your placement, it is good to ask an AI to double check it.

P/s: if you wrote country("!DE"), it really mean: matches no countries at all (not just DE), because it would starts from an empty set.

I would phrase it in this way:

attributes and filters are two different level of abstractions.

  • filters (and upload-filters) are expression to apply to nodes.
  • attributes can be part of those expressions (with other elements). Attribute is just a node → string function. We also have node_values (which is node → number function)

But attributes (and values) can be also used in other expressions (invariant, selector, …)

I strongly recommend to test placements locally, as it’s easy to shoot yourself on your own foot.

Examples from my shell history:

stbb placement nodes --placement=3 --satellite spiridon --placement-config=.../placement.yaml --selector last_ip

stbb placement select-pool --placement=3 --selector 'last_ip' --k 10000 --satellite spiridon --placement-config=.../placement.yaml

--selector is just the grouping for the output.

  • The first command prints out the node pool.
  • The second one simulates nodes selection (10000 times)

I test every single placement change first with these simulations.

stbb = GitHub - elek/stbb · GitHub

FromString(expr)

Search this exact phrase in the source code: FromString(expr string

You’ll see 4 functions:

func FilterFromString // use supportedFilters, see post above
func SelectorFromString // use both supportedFilters and supportedAttributes
func InvariantFromString // also use supportedFilters and supportedAttributes
func DownloadSelectorFromString // use supportedFilters

Edit: there another function: func CohortRequirementsFromString

func CohortRequirementsFromString

FilterFromString

Not much to see here, it use supportedFilters keywords, and that’s it.

SelectorFromString

It use keywords from both supportedFilters and supportedAttributes and more:

attribute
random
unvetted
nodelist
filter
compare
choiceofn
choiceoftwo
pow2 // DEPRECATED
stream
choiceofns
groupconstraint
streamfilter
randomstream
dropworst
dropcof2
balanced
balancedf
weighted
weightedf
weighted_with_adjustment
topology
filterbest
bestofn
dual
choiceofnselection
lastbut
median
piececount
desc // DEPRECATED
node_value
maxgroup
atleast
reduce
daily
multi
fixed

InvariantFromString

Also use keywords from both supportedFilters and supportedAttributes and more:

maxcontrol
filter

DownloadSelectorFromString

Use keyword from supportedFilters and:

random
choiceofn
best
case
requestor
switch
filter

CohortRequirementsFromString

Seem to just use these below:

attr
and
min
withhold

Gradually, we will research what each of the keyword mean and how to combine them together, the process is a bit slow at first, but hang on there.

Full example of a placement rule:

templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7   # replace with YOUR signer ID
  NORMAL: exclude(tag("$SIGNER","datacenter","true"))

placements:
  - id: 20                      # bucket placement ID (forum advice: use 11 or higher)
    name: eu-example            # human-readable label

    # Which nodes may hold pieces of this placement at all
    filter: country("EU") && $NORMAL

    # Extra rule for NEW uploads only (existing pieces on Germany stay valid)
    upload-filter: exclude(country("DE"))

    # How to pick nodes from the allowed pool: one node per subnet
    selector: attribute("last_net")

    # What must stay true after repair: at most 1 piece per subnet
    invariant: maxcontrol("last_net", 1)

    # Which nodes serve downloads (note: it's download-selector, there is no "download-filter")
    download-selector: best(uploadSuccessTracker)

    # Upload counts as complete only if 80 pieces are stored, and 34 would still
    # remain after removing the largest "dc" group. (Meaning as described by the
    # forum author; I haven't read how cohort.go evaluates it.)
    cohort-requirements: min(80) && withhold(attr("tag:$SIGNER/dc"), 1, min(34))

    # Erasure coding overrides
    ec:
      minimum: 29               # k: pieces needed to rebuild a segment
      repair: "+5"              # repair threshold = k + 5 = 34 (a plain int also works)
      success: 80               # upload is successful once this many pieces are stored
      total: 110                # how many pieces to create

supportedFilters

templates:
  SIGNER: 12Q8q2PofHPwycSwAVCpjNxxzWiDJhi8UV4ceZBo4hmNARpYcR7   # a tag signer's node ID
  NORMAL: exclude(tag("$SIGNER","datacenter","true"))

placements:
  # ---- 1. country() ----------------------------------------------------
  - id: 101
    name: germany-only
    filter: country("DE")
    # Nodes with an unknown country are NOT matched (a set containing only DE).

  - id: 102
    name: eu-without-germany
    filter: country("EU","!DE")
    # "EU" = EU member states. Items apply left to right: add EU, then remove DE.

  - id: 103
    name: world-without-russia
    filter: country("*","!RU","!BY","!NONE")
    # "*" = everyone. "!NONE" also drops nodes whose country is unknown.
    # ORDER MATTERS: "*" resets the set to "everyone", so country("!RU","*") means everyone.
    # country("!RU") alone starts from an empty set, so it matches nothing.

  # ---- 2. continent() --------------------------------------------------
  - id: 104
    name: south-america
    filter: continent("SA")
    # Codes: NA SA EU AS AF OC AN. Exactly one argument, no "*" form.
    # continent("EU") is geographic Europe (includes RU, CH, GB), not the EU member list.
    # An unknown code like continent("Europe") panics in the code instead of returning a clean error.

  - id: 105
    name: not-africa
    filter: continent("!AF")
    # "!" starts from the full set and removes Africa, so unknown-country nodes still pass.

  # ---- 3. tag() / exclude() / empty() / notEmpty() ---------------------
  - id: 106
    name: only-gold-tagged
    filter: tag("$SIGNER","tier","gold")
    # The node must carry a tag signed by $SIGNER, named "tier", with the exact value "gold".

  - id: 107
    name: no-datacenter
    filter: exclude(tag("$SIGNER","datacenter","true"))
    # Nodes WITHOUT the tag pass (inner is false, so exclude gives true). Most nodes have no tag.

  - id: 108
    name: must-have-any-region-tag
    filter: tag("$SIGNER","region",notEmpty())
    # notEmpty() = the tag exists with a non-empty value. A node missing the tag is rejected.
    # empty() is the opposite trap: tag(...,"x",empty()) needs the tag present with an empty value.

  # ---- 4. && and || ----------------------------------------------------
  - id: 109
    name: eu-or-uk-no-dc
    filter: (country("EU") || country("GB")) && $NORMAL
    # Parenthesize with && and || mixes. country("EU","GB") says the same thing in one call, which is simpler.

  # ---- 5. select() -----------------------------------------------------
  - id: 110
    name: vetted-with-space
    filter: select("vetted","==","true") && select("free_disk",">",5000000000)
    # select(attribute_or_value, operator, value). Operators: = == != <> < <= > >=
    # Attributes are strings ("vetted","country","last_net","tag:..."); values are numbers ("free_disk","piece_count").
    # Anything else gives "Unsupported node attribute".

  # ---- 6. nodelist() / none() / all() ----------------------------------
  - id: 111
    name: my-own-nodes
    filter: nodelist("/etc/storj/allowed-nodes.txt")
    # One node ID per line (base58 or hex), "#" comments allowed.
    # The file is read once when the config loads, so editing it needs a restart.
    # See example of allowed-nodes.txt below

  - id: 112
    name: nothing-allowed
    filter: none()
    # Matches no nodes. Handy for temporarily disabling a placement.

  # ---- 7. upload-filter vs filter --------------------------------------
  - id: 113
    name: eu-stored-but-no-new-DE
    filter: country("EU")
    upload-filter: exclude(country("DE"))
    # filter = nodes allowed to hold pieces. upload-filter = extra rule for NEW uploads only.
    # Existing German pieces are still fine and aren't repaired away.
    # exclude(country("DE")) lets unknown-country nodes through, but filter already blocks them.

  # ---- 8. successfulAtLeastPercent() -----------------------------------
  - id: 114
    name: skip-flaky-nodes-for-uploads
    filter: country("EU")
    upload-filter: successfulAtLeastPercent(uploadFailureTracker, 0.9)
    # uploadFailureTracker is injected by the environment, not YAML.
    # Nodes with no data yet (NaN) pass; nodes with success rate >= 0.9 pass.

Example content of allowed-nodes.txt:

# my friends' nodes (comment lines start with #)
1aNZuRaYRSxJAGZMBrikdvqNEE6K9BK82DmZnTv6mTqiW5M4W4

# the same kind of ID written as 64 hex characters also works
aaf88a377f642753ac823a0945a40d53c5c5997209b9d9bc47d418c5727e8000


# blank lines are fine, and so is indentation or trailing whitespace
   1aNZuRaYRSxJAGZMBrikdvqNEE6K9BK82DmZnTv6mTqiW5M4W4

We have some new keywords:

CreateNodeValue

https://github.com/storj/storj/blob/45bf46c6bb0eef7dc7bc54f40a91a5be0c479463/satellite/nodeselection/node.go#L177

free_disk
piece_count

CreateNodeAttribute

https://github.com/storj/storj/blob/45bf46c6bb0eef7dc7bc54f40a91a5be0c479463/satellite/nodeselection/node.go#L224

last_net
id // or node_id - same thing
last_ip_port
last_ip
wallet
email
country
vetted

What is filter vs upload-filter vs selector vs invariant vs download-selector vs cohort-requirements

Field Question it answers Phase Affects pieces already stored?
filter Which nodes may belong to this placement at all? Upload, repair, download Yes. Pieces on non-matching nodes are out of placement and get repaired.
upload-filter Which of those may receive new data? Upload only No. Repair won’t move pieces because of it.
selector Of the eligible nodes, which do I pick (and how spread out)? Upload (and repair/balancer when choosing replacement nodes) No, it only picks new nodes.
invariant Is this segment’s current set of nodes well spread? Repair Yes. It flags clumped pieces as unhealthy.
download-selector Of the nodes holding pieces, which do I hand to the client? Download No.
cohort-requirements When does the uplink consider an upload successful enough? Upload, enforced by the client No.

Which field shows up where

Field Upload Download Repair
filter Yes, builds the pool Yes, narrows the nodes Yes, marks out-of-placement
upload-filter Yes, adds to the pool No No
selector Yes, draws nodes No Probably, for replacements
invariant No No Yes, flags clumped pieces
download-selector No Yes No
cohort-requirements Sent to the client, checked there No No

The order how fields is used:

upload: filter + upload-filter, then selector.

download: filter, then download-selector.

repair: invariant and filter used, selector only comes back into play when repair needs replacement nodes.

selector vs invariant

Used at different times: selector prevents bad spread when you write, and invariant detects bad spread afterward. Nothing links them.