The node's own composer reads the catalog half through meshcatalogclient's Entries cache over its own calls of mesh-catalog's verb entry, and no longer watches or reads the indexer's modules, manifests and verdicts or the builder's builds: a module's manifest is its entry's, each artifact's reference is made from the digest its want's part names (artifact-store://<module>/<artifact>/blobs/<digest>, A-50). A want whose entry is asked or not found is held, its have the phase held with a detail opening entry-asked: or entry-missing: naming the fingerprint. The entries and wants it composes from are kept in composed-<node> (entry.<digits> and want.<module>), written before the composition that uses them, read at start before anything is asked, a kept want standing until the wants' live read answers, and dropped when nothing names them. The shadow keeps its reads (G §628). The node grant gains the verb entry and the running stream's consumer calls; OverNATS hears the running stream as the link's session does. Builds: design 47 G §421, §683, §614, §628, A-47, A-50
517 lines
18 KiB
Go
517 lines
18 KiB
Go
package selfcompose
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
|
|
|
"github.com/novox/mesh-host/internal/agent"
|
|
)
|
|
|
|
// **The catalog half, read by fingerprint** (design 47 G §421, §683, §698; the lead's answers A-47 and A-50). The node-engine
|
|
// follows no branch: for each build its wants and the runnings it heard name, it holds the catalog graph's entry of that
|
|
// fingerprint, asked of the seat mesh-catalog's verb `entry` once (meshcatalogclient.Entries) and kept in its composition
|
|
// state `composed-<node>` beside the compositions (`entry.<fingerprint>`, the entry as `entry` answers it), with each want it
|
|
// composes from (`want.<module>`), written whole before the composition that uses them and read at its start before it asks
|
|
// anything. A module's manifest is the entry's, and each artifact's reference is made from the digest its want's parts name
|
|
// (referenceOf), so it reads neither the indexer's states nor the builder's.
|
|
|
|
// The keys of the kept entries and wants in the composition state.
|
|
const (
|
|
keptEntryPrefix = "entry."
|
|
keptWantPrefix = "want."
|
|
// keptWithin bounds one write of the composition state from an answer of the catalog half.
|
|
keptWithin = 15 * time.Second
|
|
)
|
|
|
|
// keptWant is a want as the composition state keeps it (§683): the want beside when this node first kept it.
|
|
type keptWant struct {
|
|
V int `json:"v"`
|
|
Since time.Time `json:"since"`
|
|
Want meshwants.WantsByWantNodeModuleValue `json:"want"`
|
|
}
|
|
|
|
// keptEntry is an entry held from the composition state or an answer, with when it was kept.
|
|
type keptEntry struct {
|
|
entry meshcatalog.CatalogEntry
|
|
since time.Time
|
|
}
|
|
|
|
// EntryRow is one entry the node-engine keeps, as the seat's verb first-views lists it (§698).
|
|
type EntryRow struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
State string `json:"state"`
|
|
Since time.Time `json:"since"`
|
|
}
|
|
|
|
// WantRow is one want the node-engine keeps, as the seat's verb first-views lists it (§698).
|
|
type WantRow struct {
|
|
Module string `json:"module"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
KeptSince time.Time `json:"kept-since"`
|
|
}
|
|
|
|
// byEntries says the composer reads the catalog half by fingerprint (the node's own composer), as the shadow, which keeps
|
|
// its reads of the indexer until it goes with the checker (§628), does not.
|
|
func (c *Composer) byEntries() bool { return c.entries != nil }
|
|
|
|
// newEntries makes the composer's cache of entries over env's call of `entry`.
|
|
func (c *Composer) newEntries() {
|
|
if c.env.Entry == nil {
|
|
return
|
|
}
|
|
c.entries = meshcatalogclient.NewEntries(c.env.Entry)
|
|
c.entries.Now = c.now
|
|
c.entries.Changed = c.entryChanged
|
|
c.keptEnt = map[string]keptEntry{}
|
|
c.keptWants = map[string]bool{}
|
|
c.keptWantVal = map[string]keptWant{}
|
|
}
|
|
|
|
// entryKey is an entry's key in the composition state, wantKey a want's.
|
|
//
|
|
// The fingerprint is keyed by its digits, as the compositions' keys are (engcomp.Key): a key holds no colon.
|
|
func entryKey(fp string) string {
|
|
return keptEntryPrefix + strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2)
|
|
}
|
|
|
|
func wantStateKey(module string) string { return keptWantPrefix + module }
|
|
|
|
// loadKept reads the entries and wants the composition state keeps, once at the start, before anything is asked of the catalog
|
|
// half (§683). A want read stands only until the live read of the wants answers (endKeptWants). A state that cannot be read
|
|
// is said, and what it would have kept is asked of `entry` and read from the wants as they come.
|
|
func (c *Composer) loadKept(ctx context.Context) {
|
|
if !c.byEntries() || c.env.ComposedState == nil {
|
|
return
|
|
}
|
|
kept, err := c.env.ComposedState.Entries(ctx)
|
|
if err != nil {
|
|
c.sayOnceEach("the entries and wants this node kept cannot be read, so each entry its wants name is asked of the catalog half: " + err.Error())
|
|
return
|
|
}
|
|
now := c.now()
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for key, e := range kept {
|
|
switch {
|
|
case strings.HasPrefix(key, keptEntryPrefix):
|
|
var a meshcatalog.EntryAnswer
|
|
if json.Unmarshal(e.Value, &a) != nil || a.Entry.Module == "" {
|
|
c.env.Say("the kept entry " + key + " cannot be read, and is asked of the catalog half again")
|
|
continue
|
|
}
|
|
c.keptEnt[meshcatalogclient.SourcePrefix2+strings.TrimPrefix(key, keptEntryPrefix)] = keptEntry{entry: a.Entry, since: now}
|
|
case strings.HasPrefix(key, keptWantPrefix):
|
|
var w keptWant
|
|
module := strings.TrimPrefix(key, keptWantPrefix)
|
|
if json.Unmarshal(e.Value, &w) != nil || w.Want.Fp == "" {
|
|
c.env.Say("the kept want " + key + " cannot be read, so it stands until the wants are read")
|
|
continue
|
|
}
|
|
if _, live := c.wants[module]; !live {
|
|
c.wants[module] = w.Want
|
|
c.keptWants[module] = true
|
|
}
|
|
c.keptWantVal[module] = w
|
|
}
|
|
}
|
|
}
|
|
|
|
// endKeptWants ends the standing of the kept wants once the live read of the wants answered: from then the wants the seat
|
|
// says are the ones the node composes from, never a want older than its stream's (§683). A kept want the read did not
|
|
// announce is gone.
|
|
func (c *Composer) endKeptWants() {
|
|
if !c.byEntries() {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for module := range c.keptWants {
|
|
delete(c.wants, module)
|
|
delete(c.keptWants, module)
|
|
}
|
|
}
|
|
|
|
// entryChanged is told of each entry answered or found missing: an answer is kept in the composition state before the pass it
|
|
// asks (so the composition that uses it finds it kept), and a pass is asked.
|
|
func (c *Composer) entryChanged(row meshcatalogclient.EntryRow) {
|
|
if row.State == meshcatalogclient.EntryAnswered && row.Entry != nil {
|
|
c.mu.Lock()
|
|
c.keptEnt[row.Fingerprint] = keptEntry{entry: *row.Entry, since: row.Since}
|
|
c.mu.Unlock()
|
|
c.keep(entryKey(row.Fingerprint), meshcatalog.EntryAnswer{Entry: *row.Entry})
|
|
}
|
|
c.askRehand()
|
|
}
|
|
|
|
// keep writes one value into the composition state; a state that cannot be written is said, and the value is kept again by
|
|
// the next pass.
|
|
func (c *Composer) keep(key string, v any) {
|
|
if c.env.ComposedState == nil {
|
|
return
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), keptWithin)
|
|
defer cancel()
|
|
c.keptWrite.Lock()
|
|
defer c.keptWrite.Unlock()
|
|
if _, err := c.env.ComposedState.Put(ctx, key, raw); err != nil {
|
|
c.sayOnceEach("the node's composition state cannot keep " + key + ": " + err.Error())
|
|
}
|
|
}
|
|
|
|
// forget deletes one key from the composition state.
|
|
func (c *Composer) forget(key string) {
|
|
if c.env.ComposedState == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), keptWithin)
|
|
defer cancel()
|
|
c.keptWrite.Lock()
|
|
defer c.keptWrite.Unlock()
|
|
if err := c.env.ComposedState.Delete(ctx, key); err != nil {
|
|
c.sayOnceEach("the node's composition state cannot drop " + key + ": " + err.Error())
|
|
}
|
|
}
|
|
|
|
// entryOf is the entry of fp as the composer holds it: kept, or asked of the catalog half once (Entries.Need).
|
|
func (c *Composer) entryOf(fp string) (meshcatalog.CatalogEntry, meshcatalogclient.EntryState) {
|
|
c.mu.Lock()
|
|
k, ok := c.keptEnt[fp]
|
|
c.mu.Unlock()
|
|
if ok {
|
|
return k.entry, meshcatalogclient.EntryAnswered
|
|
}
|
|
return c.entries.Need(context.Background(), fp)
|
|
}
|
|
|
|
// wantedBuilds is each fingerprint a want of this node or a running heard names, of the new form, with whether a want names it.
|
|
func (c *Composer) wantedBuilds() map[string]bool {
|
|
out := map[string]bool{}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for module, w := range c.wants {
|
|
if module != agent.NodeModule && strings.HasPrefix(w.Fp, meshcatalogclient.SourcePrefix2) {
|
|
out[w.Fp] = true
|
|
}
|
|
}
|
|
for _, mods := range c.heard {
|
|
for _, run := range mods {
|
|
if !run.stopped && strings.HasPrefix(run.fingerprint, meshcatalogclient.SourcePrefix2) {
|
|
if _, named := out[run.fingerprint]; !named {
|
|
out[run.fingerprint] = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// syncEntries asks the entry of each build a want or a running names, drops the one nothing names any more, keeps each want it
|
|
// composes from, and lays the picture frombus reads (c.modules, c.manifests) from the entries it holds. It runs at the start of
|
|
// each pass.
|
|
func (c *Composer) syncEntries(ctx context.Context) {
|
|
if !c.byEntries() {
|
|
return
|
|
}
|
|
need := c.wantedBuilds()
|
|
for fp := range need {
|
|
c.entryOf(fp)
|
|
}
|
|
for _, row := range c.entries.Rows() {
|
|
if _, named := need[row.Fingerprint]; !named {
|
|
c.entries.Release(row.Fingerprint)
|
|
}
|
|
}
|
|
c.mu.Lock()
|
|
var drop []string
|
|
for fp := range c.keptEnt {
|
|
if _, named := need[fp]; !named {
|
|
delete(c.keptEnt, fp)
|
|
drop = append(drop, entryKey(fp))
|
|
}
|
|
}
|
|
type pending struct {
|
|
module string
|
|
kw keptWant
|
|
}
|
|
var put []pending
|
|
var gone []string
|
|
now := c.now()
|
|
for module, w := range c.wants {
|
|
if module == agent.NodeModule || c.keptWants[module] {
|
|
continue
|
|
}
|
|
had, ok := c.keptWantVal[module]
|
|
if ok && reflect.DeepEqual(had.Want, w) {
|
|
continue
|
|
}
|
|
since := now
|
|
if ok && had.Want.Fp == w.Fp {
|
|
since = had.Since
|
|
}
|
|
kw := keptWant{V: 1, Since: since, Want: w}
|
|
c.keptWantVal[module] = kw
|
|
put = append(put, pending{module, kw})
|
|
}
|
|
for module := range c.keptWantVal {
|
|
if _, held := c.wants[module]; !held {
|
|
delete(c.keptWantVal, module)
|
|
gone = append(gone, wantStateKey(module))
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
// Written whole before the composition that uses them (§683).
|
|
for _, p := range put {
|
|
c.keep(wantStateKey(p.module), p.kw)
|
|
}
|
|
for _, key := range append(drop, gone...) {
|
|
c.forget(key)
|
|
}
|
|
c.layModules()
|
|
}
|
|
|
|
// layModules lays c.modules and c.manifests from the entries held: what each node was heard to run first, then each module this
|
|
// node's want names, at the want's fingerprint (a want wins).
|
|
func (c *Composer) layModules() {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
modules := map[string]meshcatalog.ModulesByModuleValue{}
|
|
lay := func(module, fp string) {
|
|
k, ok := c.keptEnt[fp]
|
|
if !ok || k.entry.Module == "" {
|
|
return
|
|
}
|
|
e := k.entry
|
|
digest, raw, err := entryManifest(e)
|
|
if err != nil {
|
|
return
|
|
}
|
|
m := meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: e.Repository, Commit: e.Commit, Path: e.Path,
|
|
Needs: e.Needs, Packages: e.Packages, Verdict: e.Verdict}
|
|
if e.Inputs != nil {
|
|
m.Problems, m.Warnings, m.Unknown = e.Inputs.Problems, e.Inputs.Warnings, e.Inputs.Unknown
|
|
}
|
|
modules[module] = m
|
|
c.manifests[digest] = raw
|
|
}
|
|
nodes := make([]string, 0, len(c.heard))
|
|
for node := range c.heard {
|
|
nodes = append(nodes, node)
|
|
}
|
|
sort.Strings(nodes)
|
|
for _, node := range nodes {
|
|
for module, run := range c.heard[node] {
|
|
if !run.stopped && run.fingerprint != "" {
|
|
lay(module, run.fingerprint)
|
|
}
|
|
}
|
|
}
|
|
for module, w := range c.wants {
|
|
if module != agent.NodeModule && w.Fp != "" {
|
|
lay(module, w.Fp)
|
|
}
|
|
}
|
|
// A module an assignment places whose entry is not held (not wanted here, not heard running, or its entry asked and not
|
|
// answered) stands in the catalogue as its name alone, so the placement still finds it: a module held for its entry offers
|
|
// nothing to the rest until its entry is held (§421).
|
|
for _, p := range c.assignments {
|
|
for _, pm := range p.Modules {
|
|
if _, held := modules[pm.Module]; held || pm.Module == "" {
|
|
continue
|
|
}
|
|
raw, _ := json.Marshal(map[string]any{"module": pm.Module, "version": "0"})
|
|
sum := sha256.Sum256(raw)
|
|
digest := "sha256:" + hex.EncodeToString(sum[:])
|
|
// At the fingerprint its want names, so it is no build behind the catalog's and its hold (entryHolds) is what says it.
|
|
modules[pm.Module] = meshcatalog.ModulesByModuleValue{Manifest: digest, Fp2: c.wants[pm.Module].Fp}
|
|
c.manifests[digest] = raw
|
|
}
|
|
}
|
|
c.modules = modules
|
|
}
|
|
|
|
// entryManifest is an entry's manifest as a reader takes it, with its digest: the one the indexer set at take-in, or, where it set
|
|
// none, the digest of the manifest's bytes.
|
|
func entryManifest(e meshcatalog.CatalogEntry) (digest string, raw []byte, err error) {
|
|
if raw, err = json.Marshal(e.Manifest); err != nil {
|
|
return "", nil, err
|
|
}
|
|
digest = e.ManifestDigest
|
|
if digest == "" {
|
|
sum := sha256.Sum256(raw)
|
|
digest = "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
return digest, raw, nil
|
|
}
|
|
|
|
// entryHolds is, of each module this node's want names, why its entry is not held: asked and not answered, or answered not
|
|
// found. It is the phase held with a detail opening entry-asked: or entry-missing: and naming the fingerprint (§421, §614).
|
|
func (c *Composer) entryHolds() map[string]string {
|
|
if !c.byEntries() {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
wants := map[string]meshwants.WantsByWantNodeModuleValue{}
|
|
for module, w := range c.wants {
|
|
if module != agent.NodeModule {
|
|
wants[module] = w
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
out := map[string]string{}
|
|
for module, w := range wants {
|
|
if !strings.HasPrefix(w.Fp, meshcatalogclient.SourcePrefix2) {
|
|
continue
|
|
}
|
|
switch e, state := c.entryOf(w.Fp); state {
|
|
case meshcatalogclient.EntryAsked:
|
|
out[module] = fmt.Sprintf("%s the entry of the build %s is asked of the catalog half and not yet answered, so the node composes nothing new of %s",
|
|
agent.EntryAsked, w.Fp, module)
|
|
case meshcatalogclient.EntryNotFound:
|
|
out[module] = fmt.Sprintf("%s the catalog half holds no entry of the build %s, so the node composes nothing new of %s",
|
|
agent.EntryMissing, w.Fp, module)
|
|
default:
|
|
if missing := partsMissing(e, w); missing != "" {
|
|
out[module] = fmt.Sprintf("the want of %s names no digest for %s, so the node composes nothing new of it", module, missing)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// partsMissing names the artifacts of e whose digest w's parts do not give; "" when each has one.
|
|
func partsMissing(e meshcatalog.CatalogEntry, w meshwants.WantsByWantNodeModuleValue) string {
|
|
var out []string
|
|
for _, a := range e.Artifacts {
|
|
if w.Parts[a.Ref] == "" {
|
|
out = append(out, a.Ref)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// referenceOf is an artifact's reference made from the digest its want's part names, in the form the artifact store is
|
|
// addressed by (the lead's answer A-50): artifact-store://<module>/<artifact>/blobs/<digest>.
|
|
func referenceOf(module, artifact, digest string) string {
|
|
return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest
|
|
}
|
|
|
|
// buildOf is the build a want names, made from its entry and its parts alone (the builder's done key is not read): done, with
|
|
// each artifact of the entry at the digest its part names and the reference made from it. Nil where the entry is not held, the
|
|
// want names another fingerprint, or a part is missing.
|
|
func (c *Composer) buildOf(module, fp string) *builds.BuilderBuild {
|
|
c.mu.Lock()
|
|
w, wanted := c.wants[module]
|
|
k, ok := c.keptEnt[fp]
|
|
c.mu.Unlock()
|
|
if !wanted || w.Fp != fp || !ok || partsMissing(k.entry, w) != "" {
|
|
return nil
|
|
}
|
|
digest, raw, err := entryManifest(k.entry)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
c.manifests[digest] = raw
|
|
c.mu.Unlock()
|
|
b := builds.BuilderBuild{State: builds.BuildDone, Manifest: digest}
|
|
for _, a := range k.entry.Artifacts {
|
|
_, name, _ := strings.Cut(a.Ref, ":")
|
|
digest := w.Parts[a.Ref]
|
|
b.Artifacts = append(b.Artifacts, builds.BuiltArtifact{Name: name, Kind: a.Kind, Digest: digest,
|
|
Reference: referenceOf(module, name, digest)})
|
|
}
|
|
return &b
|
|
}
|
|
|
|
// FirstViews is the node-engine's first views as the seat's verb first-views lists them (§698): the views it follows (none: it
|
|
// follows no branch), the entries it keeps and the wants it composes from.
|
|
func (c *Composer) FirstViews() (views []map[string]any, entries []EntryRow, wants []WantRow) {
|
|
views, entries, wants = []map[string]any{}, []EntryRow{}, []WantRow{}
|
|
if !c.byEntries() {
|
|
return
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, r := range c.entries.Rows() {
|
|
seen[r.Fingerprint] = true
|
|
entries = append(entries, EntryRow{Fingerprint: r.Fingerprint, State: string(r.State), Since: r.Since})
|
|
}
|
|
c.mu.Lock()
|
|
for fp, k := range c.keptEnt {
|
|
if !seen[fp] {
|
|
entries = append(entries, EntryRow{Fingerprint: fp, State: string(meshcatalogclient.EntryAnswered), Since: k.since})
|
|
}
|
|
}
|
|
for module, w := range c.wants {
|
|
if module == agent.NodeModule || w.Fp == "" {
|
|
continue
|
|
}
|
|
wants = append(wants, WantRow{Module: module, Fingerprint: w.Fp, KeptSince: c.keptWantVal[module].Since})
|
|
}
|
|
c.mu.Unlock()
|
|
sort.Slice(entries, func(i, j int) bool { return entries[i].Fingerprint < entries[j].Fingerprint })
|
|
sort.Slice(wants, func(i, j int) bool { return wants[i].Module < wants[j].Module })
|
|
return
|
|
}
|
|
|
|
// EntryAgain asks fp's entry of the catalog half again now (the seat's verb entry-again, §698): one the composer keeps, whatever
|
|
// its state. The entry kept of it is dropped, so a pass holds the module until the answer.
|
|
func (c *Composer) EntryAgain(ctx context.Context, fp string) error {
|
|
if !c.byEntries() {
|
|
return fmt.Errorf("this engine reads no entry by fingerprint")
|
|
}
|
|
c.mu.Lock()
|
|
_, kept := c.keptEnt[fp]
|
|
delete(c.keptEnt, fp)
|
|
c.mu.Unlock()
|
|
held := kept
|
|
for _, r := range c.entries.Rows() {
|
|
held = held || r.Fingerprint == fp
|
|
}
|
|
if !held {
|
|
return fmt.Errorf("no entry of %s is kept", fp)
|
|
}
|
|
if !kept {
|
|
return c.entries.Again(ctx, fp)
|
|
}
|
|
c.entries.Need(ctx, fp) // makes the row and asks once
|
|
c.askRehand()
|
|
return nil
|
|
}
|
|
|
|
// EntryCall is env.Entry over the engine's link session: the seat mesh-catalog's verb `entry`, read in the engine's own name
|
|
// (the manifest's invokes names it). A refusal in the verb's own words that it keeps no entry of the build is the holder's
|
|
// answer that it holds none, and is told so; any other failure is a call that did not reach the holder, and is told so too.
|
|
func EntryCall(ask func(ctx context.Context, seat, verb string, args any) (json.RawMessage, error)) meshcatalogclient.Call {
|
|
return func(key string, body any) (json.RawMessage, error) {
|
|
seatName, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
raw, err := ask(ctx, seatName, verb, body)
|
|
if err != nil && strings.Contains(err.Error(), noEntryWords) {
|
|
out, _ := json.Marshal(map[string]string{"error": err.Error()})
|
|
return out, nil
|
|
}
|
|
return raw, err
|
|
}
|
|
}
|
|
|
|
// noEntryWords are the words the seat's holder refuses an unknown fingerprint in.
|
|
const noEntryWords = "no entry of fingerprint"
|