Нацягваем BEAM Supervision tree сабе на галаву

Артыкул атрымаўся нашмат больш доўгім, чым я разлічваў, таму вось вам змест:

Крыху тэорыі

Я ніколі не сутыкаўся з Erlang і іншымі BEAM мовамі праграмавання ў прадакшэне. Але так выйшла, што мова праграмавання, якой я захапляюся апошні час, Gleam, акурат BEAM мова праграмавання. Апусцім пакуль той факт, што яна здольная таксама кампіляавцца ў JavaScript. Каб выкарыстоўваць Gleam добра, трэба дасканала разумець і BEAM платформу. Было б вельмі непрадбачліва не карыстацца мажлівасцямі, якія прадстаўляе гэтая платформа. І BEAM платформа мае некалькі сваіх асаблівасцей, якіх звычайна не маюць "традыцыйныя" інструменты распрацоўкі.

Па-першае, і, пэўна, самае галоўнае, гэта áктарная мадэль (Áctor — суб'ект, што дзейнічае). Актары ў BEAM гэта такія легкаважныя ("зялёныя") плыні, каруціны, горуціны, як іх могуць называць у іншых мовах праграмавання, але яшчэ крышачку больш легкаважныя (і праз актарную мадэль, мае больш просты і эфектыўны зборшчык смецця) і ёсць грамадзянамі першага парадку (first-class citizen) у віртуальнай машыне BEAM. Можна доўга расказваць, што такое актар агулам і ў разуменні BEAM у прыватнасці, але, лепей прачытаць гэта дзесьці яшчэ. Мы ж зараз сканцэнтруемся на тым, што з рэшты, актар гэта цыкл, які мае некаторы стан і рэагуе на паведамленні звонку, каб гэты стан змяніць. Пры гэтым, доступ да памяці іншых актараў мажлівы выключна праз перадачу паведамленняў з гэтымі данымі, просты доступ забаронены (ці, дакладней сказаць, проста няма такой магчымасці).

Актар на Gleam выглядае так:

type Message(element) {
  Shutdown
  Push(push: element)
  Pop(reply_with: Subject(Result(element, Nil)))
}

pub fn main() {
  let assert Ok(actor) =
    actor.new([]) |> actor.on_message(handle_message) |> actor.start
  let subject = actor.data
  actor.send(subject, Push("Joe"))
  actor.send(subject, Push("Mike"))
  actor.send(subject, Push("Robert"))
  let assert Ok("Robert") = actor.call(subject, 10, Pop)
  let assert Ok("Mike") = actor.call(subject, 10, Pop)
  let assert Ok("Joe") = actor.call(subject, 10, Pop)
  let assert Error(Nil) = actor.call(subject, 10, Pop)
  actor.send(subject, Shutdown)
}

fn handle_message(
  stack: List(e),
  message: Message(e),
) -> actor.Next(List(e), Message(e)) {
  case message {
    Shutdown -> actor.stop()
    Push(value) -> {
      let new_state = [value, ..stack]
      actor.continue(new_state)
    }
    Pop(client) -> {
      case stack {
        [] -> {
          actor.send(client, Error(Nil))
          actor.continue([])
        }

        [first, ..rest] -> {
          actor.send(client, Ok(first))
          actor.continue(rest)
        }
      }
    }
  }
}

Напрыклад, нешта падобнае можна было б зрабіць на Go, але горуціна ўсё ж мае магчымасць узаемадзейнічаць з памяццю, ды і выглядае вельмі не па Go-ску:

type ActorState[E any] struct {
	elements []*E
}

type ActorMessage[E any] interface {
	isActorMessage()
}

type Push[E any] struct {
	element E
}

func (Push[E]) isActorMessage() {}

type Pop[E any] struct {
	reply chan<- *E
}

func (Pop[E]) isActorMessage() {}

type Shutdown struct{}

func (Shutdown) isActorMessage() {}

func mkPop[E any]() (ActorMessage[E], <-chan *E) {
	reply := make(chan *E, 1)
	return Pop[E]{reply}, reply
}

func main() {
	var wg sync.WaitGroup
	wg.Add(1)

	subject := make(chan ActorMessage[string])
	go actor(subject, &wg)
	subject <- Push[string]{"Joe"}
	subject <- Push[string]{"Mike"}
	subject <- Push[string]{"Robert"}

	msg, reply := mkPop[string]()
	subject <- msg
	if result := <-reply; *result != "Robert" {
		panic("not expected")
	}

	msg, reply = mkPop[string]()
	subject <- msg
	if result := <-reply; *result != "Mike" {
		panic("not expected")
	}

	msg, reply = mkPop[string]()
	subject <- msg
	if result := <-reply; *result != "Joe" {
		panic("not expected")
	}

	msg, reply = mkPop[string]()
	subject <- msg
	if result := <-reply; result != nil {
		panic("not expected")
	}

	subject <- Shutdown{}
	wg.Wait()
}

func actor[E any](subject <-chan ActorMessage[E], wg *sync.WaitGroup) {
	defer wg.Done()

	state := ActorState[E]{
		elements: make([]*E, 0),
	}
	for m := range subject {
		switch m := m.(type) {
		case Push[E]:
			state.elements = append(state.elements, &m.element)
		case Pop[E]:
			var e *E = nil
			l := len(state.elements)
			if l > 0 {
				e = state.elements[l-1]
				state.elements = state.elements[:l-1]
			}
			m.reply <- e
		case Shutdown:
			return
		}
	}
}

Але, спадзяюся, прыблізна зразумець можна, што адбываецца. Гэта, дарэчы, добрая ілюстрацыя да пункта "выкарыстоўваць перавагі платформы і не выкарыстоўваць тое, што ў платформе зроблена слаба". Вядома, усё можна было б схаваць у якую бібліятэку з добрым API, але ў нас тут артыкул, а не рэпазіторый, ды і навошта гэта ўвогуле ў Go. Я дзеля нагляднасці проста паказаў, бо пэўна не кожны чытач з лёгкасцю зразумее код на Gleam. Але самае галоўнае, што выкарыстоўваючы такі падыход у Go, мы апроч сінтаксічнага шуму аніякіх пераваг не атрымаем. З BEAM усё інакш бо...

Па-другое гэта наяўнасць Supervisor як базавага будаўнічага элемента праграмы. Дык што ж за звер такі, Supervisor і пры чым тут дрэвы?

Тут мусіў быць доўгі ўступ пра гісторыю стварэння самога Erlang, пра Ericsson, але гэта вы можаце прачытаць будзь дзе яшчэ, галоўнае жаданне. Я тут скокну адразу ў сутнасць. Supervisor, гэта таксама актар, але звычайна, ён не выконвае аніякай бізнес логікі, карыснай для прадукту/праекта. Замест гэтага, ён проста сядзіць і ціханька пільнуе іншых актараў, што былі давераныя яму. І калі актар па нейкіх прычынах паводзіць сябе няправільна, перазапускае або толькі яго, або яго і частку ці ўсіх даверных іншых. Так. Вось так проста. Больш нічога. Але д'ябла, як вядома, крыецца ў дэталях.

Усе ж ведаюць гэты жарт, што калі нешта не працуе, трэба перазапусціь. Пажадана тры разы. І, як высветлілася, гэта даволі добрая стратэгія, каб зрабіць праграму больш стойкай да памылак канкрэтнага класа.

Якога такога класа? Ну, відавочна, калі памылка ў тым, што код напісаны няправільна, рэстартуй не рэстартуй усё роўна атрымаеш... Збой, крэш, экэспшэн, памылку. Гэта зразумела. Але існуюць памылкі іншага кшталту. У складаных сістэмах са складанымі плынямі даных, не-не, ды здарыцца такое, што, стан супярэчыць сам сабе. Складана прывесці прыклад, трэба выдумляць. Уявім што ў нас ёсць крама, ёсць функцыя кошыка, і адна частка сістэмы лічыць, што ў кошку тавараў няма, другая частка сістэмы рапартуе, што ў кошыку тавараў на 20 талераў. Такія памылкі звычайна вельмі складана прадугледзіць і ўжо тым болей апрацаваць, ды яшчэ карэктна, бо ўзнікаюць яны ў вельмі спецыфічных умовах, калі карыстальнік зрабіў нейкую асаблівую паслядоўнасць падзей, да якой ніводны QA не дадумаўся. А можа гэта наступствы збояў сеткі так адлюстраваліся на нашай сістэме? Вельмі складана сказаць і зразумець.

Дык вось, нашая задача ў такім выпадку проста апрацоўваць зразумелыя выпадкі, а калі адбылося нешта незразумелае, падняць лапкі ды здацца, сказаць — мы не разумеем што гэта, калі б мы разумелі, а так мы не разумеем, страшна. Supervisor возьме на сябе адказнасць перазапусціць гэтую частку сістэмы (і толькі яе), скінуўшы стан на першапачатковы. Гэта можа быць пусты кошак з сумай тавараў на 0 талераў, або, магчыма, вы запісалі спіс тавараў у знешняй сістэме, таму можаце прачытаць іх і пералічыць суму.

Вядома, прыклад вельмі сінтэтычны, хто трымае суму ў асобнай частцы памяці, замест таго каб падлічыць яе ў моманце, калі яна патрэбная. Але вы ж пэўна як і я, пазнаяце ў гэтым сцэнарыі шмат падобных памылак з вашых праектаў, хіба не?

Бываюць выпадкі больш складаныя. Не заўсёды асобны Supervisor здольны перазапусціць свае падсістэмы так, каб яны ізноў былі жывымі і працавалі карэктна. У такім выпадку, пасля некаторай колькасці спроб перазапусціцца, упадзе ўжо і сам Supervisor. Што тут рабіць? Вось тут і з'яўляюцца дрэвы. Яны ж Supervision tree. Справа ў тым, што гэты асобна ўзяты Supervisor можа сам быць давераны да пільнавання іншаму Supervisor, у каторага, магчыма, больш кантэксту пра сістэму, больш давераных іншых падсістэм, магчыма перазапуск усіх іх разам дапаможа выправіць сітуацыю.

graph TD
  classDef sup fill:transparent,stroke:#de7aeb,color:#fff
  classDef act fill:#de7aeb,color:#000

  As[Supervisor] --> Bs[Supervisor]
  As[Supervisor] --> Cs[Supervisor]
  Bs --> Aa([Actor])
  Bs --> Ba([Actor])
  Bs --> Ca([Actor])
  Cs --> Da([Actor])
  Cs --> Ea([Actor])
  As ---> Fa([Actor])

  class As,Bs,Cs sup
  class Aa,Ba,Ca,Da,Ea,Fa act
OTP Supervision tree

Чытаючы пра любую мову праграмавання на BEAM, вы абавязкова сустрэнеце даволі філасофскі пункт Let it crash — дазволь яму ўпасці. Першыя спробы пазнаёміцца з Erlang (яшчэ адна мова праграмавання BEAM) у мяне былі яшчэ калі я быў недзе паміж джуном і мідлом. Я быў тады проста яшчэ не гатовы да гэтага. Але, азіраючыся туды, кнігі і людзі мне проста тады не данеслі, што гэта не азначае падзенне пры любой памылцы. Я чамусьці думаў, што Let it crash азначае, што калі ў вас не атрымалася распарсіць радок у чысло, трэба пра гэта крычаць, падаць, біць кулакамі ў істэрыцы. Вядома, бываюць выпадкі, калі і так трэба зрабіць, але зараз не пра гэта. Калі падчас напісання коду вы лёгка можаце ўявіць выпадкі, што тая ці іншая аперацыя можа завяршыцца памылкова і вы ведаеце, як на гэта рэагаваць — не стрымлівайце сябе, апрацуйце! Калі ў нейкім полі JSON структуры вам замест чысла спіс, не трэба падаць, трэба адказаць 400 Bad Request, (хаця нават і гэта вельмі залежыць ад выпадку). Аднак ідэя супервайзераў, проста яшчэ адзін спосаб мець жывую сістэму, нават калі ўсё пайшло зусім не так, як мы чакалі. Нават калі нашыя самыя смелыя ўяўленні пра гэты свет не спраўдзіліся. І так, рашэнне — проста перазапусціцца і пачаць нанава, з чыстага аркуша.

Вядома, варта адзначыць, што гэта не ўніверсальнае рашэнне, але для шматлікіх іншых выпадкаў у нас ужо ёсць ADT, пра перавагу якіх у апрацоўцы некаторых відаў памылак я ўжо расказваў.

Практыка

Гэта ўсё была тэарэтычная частка. Зноўку, па падрабязнасці лепей звярнуцца ў адпаведную літаратуру. На жаль, дакументацыя Gleam на момант напісання гэтага паста не вельмі развітая і нейкія найлепшыя практыкі ў ёй не так і проста знайсці. То-бок шмат прыкладаў, як актара запусціць, як супервайзера, але сабраць усё ў купку для складаных сцэнарыяў у мяне нейкі час не атрымлівалася. Таму каб разабрацца падрабязней, мне прыйшлося занурыцца ў дакументацыю і кнігі па найстарэйшаму жыхару віртуальнай машыны BEAM — Erlang (назва гэтай мовы нават і знаходзіцца ў самой назве віртуальнай машыны BEAM — Bogdan's/Björn's Erlang Abstract Machine)

У мэтах вывучэння мовы Gleam, я раблю невялічкі праект. Нічога асаблівага, ніякай рэвалюцыі, проста каб лепей разумець рэальныя спосабы выкарыстоўвання. Карацей, сам праект не заслугоўвае асаблівай увагі, але некаторыя моманты ў ім, некаторыя праблемы — так.

Абмалюю сітуацыю. Кожны наведвальнік вэб-праграмы можа аўтарызавацца з дапамогай знешняга сэрвісу, стварыўшы тым самым унікальны пакой. Іншыя карыстальнікі, што трапяць у пакой змогуць выконваць запыты ў знешні сэрвіс ад імя ўласніка пакоя. І ўсё гэта неяк так добра кладзецца на актарную мадэль. Я выдзеліў тры тыпы актараў. Мэнэджэр пакояў, пакой, і кліент да знешняга сэрвісу. Каротка пра іх ролі.

Кліент

Захоўвае ў сваім стане токен доступу. Гэты токен доступу час ад часу "пратухае", у яго ёсць час жыцця, таму да яго далучаецца другі токен, які патрэбны каб абнавіць першы. Карацей кажучы, звычайная OAuth2 плынь. Адказны за выкананне запытаў да трэцяга сэрвісу па патрабаванні.

type AccessTokenData {
  AccessTokenData(
    access_token: String,
    refresh_token: String,
    // ...
  )
}

type ThirdPartyClientState {
  ThirdPartyClient(token: AccessTokenData)
}

Пакой

Захоўвае ў сваім стане спасылкі на актараў, якія абслугоўваюць карыстальнікаў, што падключаныя да пакоя. Актараў, што абслугоўваюць карыстальніка я расказваць асабліва не буду. Па-першае, яны нам зараз не вельмі цікавыя, па-другое, іх стварае і кантралюе вэб-сервер. Актар пакоя таксама мае спасылку на актара-кліента да знешняга сэрвісу, каб выконваць запыты ўдзельнікаў пакоя ад імя ўладальніка.

type RoomState {
  RoomState(
    members: List(Member),
    client: Subject(ThirdPartyClientMessage),
  )
}

Мэнэджэр пакояў

Захоўвае ў сваім стане адпаведнасць ідэнтыфікатара пакоя да спасылкі на актара пакоя. Стварае новых актараў пакоя і спыняе іх, калі яны больш не патрэбныя.

type RoomManagerState {
  RoomManagerState(rooms: Dict(String, Subject(Room))
}

Што там з дрэвам?

Мэнэджэр пакояў мусіць існаваць толькі адзін, а вось саміх пакояў і звязаных з імі кліентаў да трэцяга сэрвісу можа быць шмат. Прытым, кліент без пакою ўжо не асабліва мае сэнс, а пакой без кліента дык увогуле памылковы выпадак. Таму дрэва супервайзераў у мяне выглядае неяк так:

graph TD
  classDef sup fill:transparent,stroke:#de7aeb,color:#fff
  classDef act fill:#de7aeb,color:#000
  
  App[Application] --> RMS[Room Manager Supervisor]
  RMS --> RM([Room Manager Actor])
  RMS --> RFS[Room Factory Supervisor]
  RFS --> RS1[Room 1 Supervisor]
  RFS --> RS2[Room 2 Supervisor]
  RS1 --> R1([Room 1 Actor])
  RS1 --> C1([Client 1 Actor])
  RS2 --> R2([Room 2 Actor])
  RS2 --> C2([Client 2 Actor])
  
  class App,RMS,RFS,RS1,RS2 sup
  class R1,C1,R2,C2,RM act
Канкрэтны прыклад Supervision tree

Аднак, ёсць праблема. Калі вы будуеце Supervision tree spec, вы ніяк не можаце паведаміць аднаму суседу інфармацыю пра іншага суседа. То-бок нельга перадаць пакою, як яму звязацца з кліентам.

// Main module
import gleam/otp/factory_supervisor as factory
pub main() {
  let room_factory_name = process.new_name("room_factory")
  let room_factory =
    // выкарыстоўваем supervisor_child, бо ведаем, што нам вярцецца supervisor
    factory.supervisor_child(room.start)
    |> factory.named(room_factory_name)
    |> factory.supervised
    
  // уся астатняя ініцыялізацыя
}

// Room module
import gleam/otp/static_supervisor as supervisor
import gleam/otp/supervision

pub fn start(access_token: AcessTokenData) {
  supervisor.new(supervisor.OneForAll)
  |> supervisor.add(supervision.worker(fn() { client.actor(access_token) }))
  |> supervisor.add(supervision.worker(fn() { actor(TODO) }) // <-- што мне сюды перадаць???
  |> supervisor.start()
}

pub fn actor(client: Subject(ThirdPartyServiceClientMessage)) {
  actor.new(RoomState(members: [], client: client))
  |> actor.on_message(loop)
  |> actor.start
}

І я даволі доўга хадзіў вакол гэтага кавалка, каб зразумець, а як бы гэта ўсё "кананічна" арганізаваць.

Рашэнне

Высветлілася, што я сам сябе перайграў. Я настолькі ўцягнуўся ў думку, што трэба пабудаваць усё, як разумныя дзядзькі вялелі па дызайну OTP (Open Telecom Platform), што прапусціў самы асноўны момант! Актары ў Gleam адразу стартуюць звязанымі са сваімі бацькамі. Звязанасць актараў азначае, што калі па якіхсьці прычынах падае адзін актар, усе звязаныя з ім актары таксама ўпадуць. А калі прыглядзецца, гэта акурат тое, што мне і трэба!

Калі па нейкіх прычынах падае кліент да знешняга сэрвісу, пакой таксама падае, а супервайзер пакояў можа паспрабаваць яго падняць зноў. Вось і ўсё... Такім чынам дрэва будзе выглядаць так:

graph TD
  classDef sup fill:transparent,stroke:#de7aeb,color:#fff
  classDef act fill:#de7aeb,color:#000
  
  App[Application] --> RMS[Room Manager Supervisor]
  RMS --> RM([Room Manager Actor])
  RMS --> RFS[Room Factory Supervisor]
  RFS --> R1([Room 1 Actor])
  R1 --> C1([Client 1 Actor])
  RFS --> R2([Room 2 Actor])
  R2 --> C2([Client 2 Actor])
  
  class App,RMS,RFS,RS1,RS2 sup
  class R1,C1,R2,C2,RM act
Канкрэтны прыклад Supervision tree

Ну а адпаведны код моцна спрашчаецца!

// Main module
import gleam/otp/factory_supervisor as factory
pub main() {
  let room_factory_name = process.new_name("room_factory")
  let room_factory =
    factory.worker_child(room.start)
    |> factory.named(room_factory_name)
    |> factory.supervised
    
  // уся астатняя ініцыялізацыя
}

// Room module
pub fn start(access_token: AcessTokenData) {
  let assert Ok(client) = client.actor(access_token)
  actor(client.data)
}

pub fn actor(client: Subject(ThirdPartyServiceClientMessage)) {
  actor.new(RoomState(members: [], client: client))
  |> actor.on_message(loop)
  |> actor.start
}

Шчыра кажучы, я ажно сам здзівіўся, як доўга гэты факт хаваўся ад майго розуму. Часам самае простае і відавочнае рашэнне і ёсць самым правільным.

Каментары