Epidemiolohiya ng core dump: pag-ayos ng 18-taóng bug
Paggamit ng population-level analysis para i-debug ang mahihirap na crash sa aming data infrastructure.
Lalong umaasa ang mga modelo at agent ng OpenAI sa nasusukat na imprastraktura ng data para maghanap ng kaugnay na data sa inference time: kapag pinag-iisipan ng mga modelo ang tanong mo. Ang ilan sa mga serbisyong ito ay nakasulat sa C++, na ang mababang-antas na kontrol sa system ay tumutulong sa aming sulitin ang performance at bawasan ang paggamit ng memory. Mahalaga ang mga benepisyong ito sa efficiency habang lumalaki kami, pero dahil walang memory safety ang C++, puwedeng mag-crash ang mga bug kapag nagsusulat sa mali o di-umiiral na memory address.
Ilang buwan ang nakalipas, may nakita kaming ilang crash sa loob ng serbisyo ng Rockset, isang pasadyang bahagi ng aming ChatGPT data infrastructure na mahalaga sa maraming data plugin at sa paghahanap sa mga pag-uusap. Sa bawat crash na ito, tila natapos ang isang normal na C++ function at bumalik sa pekeng address, kaya pinahinto ng kernel ang programa dahil hindi na nakaturo sa code ang instruction pointer. Minsan, NULL ang return address slot sa stack frame. Minsan, mismong stack pointer CPU register ang tila lihis ng 8 byte, na para bang kahit paano ay nabawasan ang %rsp sa gitna ng normal na execution. Sa parehong kaso, nangyari ang crash sa pag-return.
Hindi karaniwang failure mode ang mga ito para sa application code. Posible ang stray write na tumama lang sa naka-save na return address, pero napakaimposible nito. Mas kakaiba pa ang bug na nagmi-misalign ng %rsp ng 8 nang walang inline assembly, setcontext, o longjmp (wala kaming ginagamit sa mga iyon), dahil direktang ina-adjust lang ng compiled code ang register na iyon sa function prologue at epilogue. May matibay na ebidensiyang kumokontra sa bawat hypothesis na naisip namin (o ng ChatGPT), kaya tila imposible ang bug.
Ang inakala naming isang problema ay naging dalawang magkaibang bug pala, na nagkataong natuklasan nang sabay. Una, tahimik na hardware corruption sa isang Azure host, kung saan hindi lang tama ang pagkukuwenta ng CPU. Ikalawa, isang 18-taóng race condition sa GNU libunwind, isang di-napansing bug sa malawak na ginagamit na open source library.
Kuwento ang post na ito kung paano namin natukoy at naayos ang tila di-maipaliwanag na mga crash sa pamamagitan ng pag-iisip na parang epidemiologist at paggawa ng de-kalidad na data set tungkol sa buong populasyon ng mga crash.
Una, palalimin muna natin ang tungkol sa Rockset. Isa itong cloud-native data system para sa search at real-time analytics na ginagamit namin sa maraming internal use case sa OpenAI, gaya ng sync connectors (nakuha ng OpenAI ang Rockset noong 2024). Ginagamit ang streaming updates para panatilihing updated ang index ng knowledge base ng isang workspace, para makahanap ang ChatGPT ng kaugnay na impormasyon kapag sumasagot sa mga tanong o nagsasagawa ng actions.
Nakasulat sa C++ ang execution layer ng Rockset. Nagbibigay ang C++ language ng mababang-antas na access sa CPU, na mabuti para sa performance at efficiency, pero ibig sabihin nito ay puwedeng humantong ang application bugs sa invalid memory accesses at segfaults. Para makatulong sa paghahanap ng mga ito, ginagamit namin ang fatal signal handler ng folly para mag-log ng stack trace kapag may crash, at ina-upload namin sa Azure blob storage ang kaugnay na core dumps (snapshot ng estado ng programa noong nag-crash ito) para sa susunod na pagsusuri. Naka-replicate ang lahat ng query processing leaves ng Rockset, kaya nababawasan ang epekto ng crash sa client. Gayunman, bawat segfault ay katumbas ng bug na kailangang ayusin para maabot ang aming reliability at quality goals.
Ang unang approach namin ay tratuhin ang mga core na ito bilang karaniwang debugging problem: suriin nang mabuti ang ilang core dump, bumuo ng hypothesis, at isa-isang alisin ang mga ito.
Karamihan ng mga crash ay nangyari sa method na tinatawag na DocumentTree::updateDocument. Sa mga crash na ito, tila tinawag ng updateDocument ang di-kilalang function X, nasira ang stack habang active si X, at bumalik si X sa address na hindi executable code. Sa ilang kaso, mukhang valid ang kakaka-pop lang na frame ni X maliban sa NULL ang naka-save nitong return address. Sa ibang kaso, mismong stack pointer ang mukhang mali, pero tila updateDocument pa rin ang susunod na valid frame.
Hindi namin alam kung kailan nasisira ang stack, kaya napakalawak ng kailangang hanapin. Malaking method ang updateDocument at maraming inlining dito, kaya napakarami ng posibleng kandidato para kay X.
Bug ba ito sa aming C++ code? Isyu sa compiler o linkage? Problema sa isa sa aming runtime libraries? Bug sa Linux kernel sa signal delivery o context switching? Mas bihira pa bang bagay? Kung stray write ito, bakit hindi ito nahuli ng aming ASAN staging environment?
Sinubukan naming gamitin ang application-level logs para tukuyin ang lahat ng paglitaw ng problema, pero mahirap ikategorya ang stack-corruption bugs mula sa logs lang dahil mismong naka-log na stack traces ay sira o nawawala. Hindi kami nakabuo ng log query na walang parehong false positives at false negatives. Manu-mano kaming nagsuri ng mas maraming core at nakakita ng karagdagang halimbawa, pero masyadong matrabaho ang prosesong iyon para makapagbigay ng mapagkakatiwalaang data set.
Sa yugtong ito ng imbestigasyon, (mali naming) inalis ang posibilidad ng hardware bug, dahil nakakita kami ng crashes sa maraming region at hardware type, kaya software-only causes pa rin ang hinahanap namin. Ilang araw kaming nagpakalalim sa isang misaligned-%rsp crash, binubuo muli ang pre-crash history gamit ang laman ng stack at registers. Nagbigay ito ng ilang posibleng clue, pero dahil hindi namin binitiwan ang paunang konklusyon na iisa ang sanhi ng lahat ng bug, hindi kami nakausad.
Bago umabot sa turning point ng aming imbestigasyon, mahalagang ipaliwanag kung anong impormasyon ang kinukuha namin mula sa core files.
Naka-compile ang Rockset gamit ang -fno-omit-frame-pointer, kaya laging maaabot ang active stack frame sa pamamagitan ng %rbp, at bumubuo ang callers ng linked list ng frame pointers.
Sa Linux x86_64, nagre-reserve din ang AMD64 System V ABI ng 128 byte sa ibaba ng %rsp bilang red zone. Available ang bahaging iyon sa userspace code at, mahalaga, ipinapangako ng kernel na hindi ito gagalawin kapag nag-deliver ito ng signal, bilang bahagi ng kontrata ng ABI.
Sentral ang red zone sa pag-debug namin ng post-return crash, dahil iniingatan nito ang ilang impormasyon mula bago ang return. Kapag na-trigger ang SIGSEGV, tumatakbo ang fatal signal handler ng folly sa stack ng crashing thread. Ang mga stack frame na hindi na active (dahil nag-return na ang function) ay maco-clobber ng signal handler, maliban sa huling 128 byte. Kaya masasabi namin ang tulad ng “mukhang valid ang kakaka-pop lang na stack frame ni X, maliban sa NULL ang return address.” Iniingatan ng red zone ang ilan sa inactive frames, o minsan buntot lang ng isang inactive frame.
Nakakita kami ng isang misaligned-stack crash kung saan napakaliit ng lahat ng sangkot na function. Dahil doon, nakita naming naging misaligned ang %rsp habang tumatakbo ang medyo simpleng function, at may mga sumunod pang call na nagtagumpay. Nag-crash lang ang programa nang sa wakas ay sinubukang mag-return ng active function. Wala sa mga code path na iyon ang gumamit ng exceptions, inline assembly, setcontext, o longjmp, kaya kung talagang nagbago ang stack pointer gaya ng ipinahihiwatig ng core, walang kapani-paniwalang bug sa userspace code ang makapagpaliwanag sa isyu.
Dinala kami niyon patungo sa kernel.
Mas agresibong gumagamit ng signals ang Rockset kaysa karamihan ng programa. Hinahati ang query execution sa maraming magagaan na task na nagpapalitan ng data. Mahalaga ito para mahusay na mahawakan ang high-QPS workloads, pero pinapahirap nito ang per-query CPU accounting dahil ang trabaho ng maraming query ay multiplexed sa iisang thread pool.
Ang solusyon namin ay tinatawag naming coarse_thread_cputime_clock, na murang inaaproksima ang clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) para makapag-sample sa bawat task boundary. Magagamit ang timer_create API para mag-iskedyul ng periodic signal delivery batay sa ilang konsepto ng paglipas ng oras, kabilang ang naiipong CPU time. Nag-iiskedyul kami ng signal (SIGUSR2) na i-deliver kada ilang millisecond ng CPU time; pagkatapos nito, ina-update ng signal handler ang thread-local value. Kahit maraming task ang hindi nakakakita na umuusad ang coarse clock habang tumatakbo sila, ang pagsasama ng lahat ng delta ay nagbibigay ng walang-bias na tantiya ng aktwal na CPU time para sa isang query.
Dahil napakadalas naming mag-deliver ng signals, naging kapani-paniwala ang isang bihirang kernel bug sa context switching o signal delivery. Naglaan kami ng oras sa pagbabasa ng bug reports, kernel source code, at Azure-specific kernel patches. Sinubukan namin ang stress tests. Wala kaming nahanap na tila kaugnay.
Sa puntong iyon, nagpasya kaming umatras at sumubok ng ibang approach.
May dalawang malawak na paraan para mag-debug ng problemang tulad nito.
Ang isa ay kumilos na parang doktor: tutukan ang isang pasyente, magpatakbo ng maraming test, at subukang i-diagnose ang isang kaso mula sa detalyadong ebidensiya.
Ang isa pa ay kumilos na parang epidemiologist: tingnan ang buong populasyon at itanong kung may mga pattern na hindi kayang ipakita ng iisang kaso. Nagsimula ba ang bug sa isang partikular na release? May korelasyon ba ito sa isang hardware SKU (ang partikular na CPU at server model), isang region, o isang kernel version? May maraming magkakaibang cluster ba na nakatago sa mukhang iisang syndrome?
Kadalasan, nasa doctor mode kami. Ang mahalagang pagbabago ay ang pasyang kailangan naming mangalap ng de-kalidad na population data.
Nabigo ang nauna naming mga pagtatangkang awtomatikong hanapin ang lahat ng instance ng problema dahil sinusubukan naming gumamit ng text searches sa logs. Mas maraming impormasyon ang mismong core dumps, pero hindi scalable ang manu-manong pagtingin sa mga ito. Nagpasya kaming maglaan ng effort para gumawa ng pipeline na awtomatikong makakapag-analisa ng core dumps.
Pinapagsulat namin ang ChatGPT ng script na nag-download ng prefix ng bawat core file, nag-extract ng registers, nag-filter ng kilalang false positives gamit ang logs, at awtomatikong nag-label ng crash bilang return-to-null, misaligned-stack, o iba pa. Pagkatapos, pinatakbo namin ang script na iyon nang parallel sa bawat production Rockset core dump mula sa nakaraang taon.
Ito ang turning point.
Nang magkaroon kami ng malinis na data set, agad lumitaw ang mga korelasyon. Ang tinatrato naming isang kakaibang bug ay dalawang magkahiwalay na crash population pala.
Kalat sa maraming cluster at geographic region ang return-to-null cores. Tumaas kamakailan ang dalas ng mga ito, pero walang malinaw na petsa ng simula at walang malinis na boundary sa imprastraktura.
Ibang-iba ang itsura ng misaligned-stack crashes. Lahat ay galing sa isang region, may malinaw na petsa ng simula, at hindi nangyari sa mga node na matagal nang tumatakbo. Bagama’t sangkot ang maraming Azure VM (virtual machines na naka-host sa cloud), mukhang isang pisikal na makinang may sirang hardware ang pattern, na nagdudulot ng problema sa alinmang VM na mapunta roon.
Iyon ang sandaling napagtanto naming pinaghalo namin sa isip ang dalawang bug. Dahil pinaghahalo namin ang counterexamples mula sa parehong bug, hindi kami makahanap ng iisang magkakaugnay na paliwanag.
Gamit ang malinis na listahan ng Kubernetes nodes at timestamps, natunton namin ang misaligned-stack crashes pabalik sa iisang pisikal na host, na madaling i-denylist.
Hindi namin nagawang i-reproduce ang register corruption sa host na iyon sa controlled environment, kahit matapos ang ilang linggong stress testing. Pero nang alisin sa serbisyo ang problemadong host, nawala ang misaligned-stack crashes.
Hindi permanenteng solusyon ang pag-alis sa masamang host, sa diwang hindi nito napipigilan ang bagong paglitaw ng parehong problema. Gayunman, maaari naming baguhin ang software para kapag naulit ang katulad na isyu, madali itong matukoy at mahawakan. Pinahusay namin ang aming fatal signal handler para isama ang register state, upang matukoy namin ang pag-ulit mula sa logs lang (hindi kailangan ng core dump). Binago namin ang control plane para karaniwang nire-reuse ang VMs sa halip na nire-recycle, kaya mas madali sa antas namin ng infrastructure stack ang bad-node detection. In-update din namin ang aming runbooks (at ang mental models ng team) para isama ang posibilidad na ito.
Nang naihiwalay ang bad-host crashes, mas madaling pangatwiranan ang natitirang return-to-null cores. Noon, inalis namin ang exception unwinding dahil akala namin may counterexamples kami: crashes sa code paths kung saan tiyak na hindi ginamit ang exceptions. Pero lahat ng counterexample na iyon ay mula sa hardware-corruption cluster.
Nang balikan namin ang natitirang cores nang nasa isip iyon, nakita naming eksaktong baligtad ang konklusyong ito: lahat ng crash ay nangyayari habang nag-e-exception unwinding.
Kapag nag-throw ng exception ang C++, kailangang tukuyin ng runtime kung aling catch block ang tatanggap nito at aling destructors o cleanup handlers ang tatakbo habang papunta roon. Ini-emit ng compiler ang metadata na ito, pero ang aktwal na matching ay dynamic na nangyayari sa runtime.
Hindi talaga ang function na tumatawag sa throw ang nagsasagawa ng exception unwinding, kundi helper functions na tinatawag ng nabuong compiled code. Sinusuri ng mga runtime routine na iyon ang stack, kinukuha ang metadata tungkol sa mga function na nasa stack, dynamic na naghahanap ng cleanup handlers at catch blocks, at inililipat ang control sa isa sa mga lokasyong iyon. Kasama sa paglilipat ng control ang pag-unwind sa lahat ng nasa pagitan na stack frames (kabilang ang sa helper functions).
Sa operasyon, mas malapit ito sa longjmp o fiber switch kaysa sa normal na call at return. Kailangang i-restore ang callee save registers, pati ang stack frame registers na %rbp at %rsp.
Naka-link ang aming binary laban sa dalawang library na may implementations ng mga function na nagsasagawa ng C++ exception unwinding: libgcc at GNU libunwind. Ang mga definition ng GNU libunwind ang pinili ng dynamic linker. Nagulat kami roon; inaasahan naming mananaig ang implementation ng libgcc dahil sa symbol versioning rules; gayunman, ipinakita ng pagsusuri sa running binaries na hindi iyon ang nangyari.
Sa puntong ito, nagbago ang working hypothesis namin habang niluwagan namin ang isa pang palagay na nabuo noong akala naming iisa lang ang bug.
Baka hindi ordinaryong function return sa NULL ang nakikita namin. Baka unwind transfer ang nakikita namin—epektibong setcontext-style register restore—kung saan naging NULL ang destination instruction pointer bago nailipat ang control. Sa madaling sabi, maling data mula sa unwind library, hindi maling return address slot sa stack.
Malaki ang ipinakitid nito sa problema. Alinman sa nagko-compute ang GNU libunwind ng maling destination state, o tama ang kino-compute nito pero may sumisira rito bago ito mailapat.
Binasa namin ang GNU libunwind source at nakita naming gumagawa ito ng ucontext_t sa stack, pinupunan ang nais na register state para sa frame ng cleanup handler, at ibinibigay ang pointer sa struct na iyon sa internal assembly routine: _Ux86_64_setcontext.
Sa puntong ito, nasa amin na ang lahat ng piraso.
Ang nabuong ucontext_t ay nasa isa sa mga stack frame na inu-unwind ng _Ux86_64_setcontext habang tumatakbo ang function na iyon. Binabasa ba ng _Ux86_64_setcontext ang struct pagkatapos nitong baguhin ang %rsp, sa puntong hindi na bahagi ng active stack ang struct? Gagawin nitong vulnerable ito sa pagka-clobber ng signal delivery, gaya ng madalas naming SIGUSR2.
Oo ang sagot.
Narito ang huling anim na instruction ng _Ux86_64_setcontext sa bersyon ng GNU libunwind na ginagamit namin, na karamihan ay mov instructions na naglo-load mula memory papunta sa destination register:
(Nakaturo ang %rdi sa stack-allocated na ucontext_t, at ang mga macro na UC_MCONTEXT_* ay nag-e-expand lang sa fixed offset kung saan naka-store ang partikular na register.)
Ang unang instruction ang simula ng race window. Ina-update nito ang %rsp para tumuro sa bagong ibaba ng active stack. Pagka nangyari ito, ang struct na tinuturo ng %rdi ay hindi na bahagi ng active stack (o red zone), at hindi na ito bawal galawin ng kernel.
Karaniwan, hindi ito nagdudulot ng problema, pero kung dumating ang signal sa eksaktong tamang (maling?) sandali, bubuo ang kernel ng signal frame sa %rsp-128. Puwede nitong ma-overwrite ang memory na tinuturo ng %rdi.
Kung mangyari iyon bago basahin ng susunod na instruction ang UC_MCONTEXT_GREGS_RIP(%rdi), maaaring masira ang na-restore na instruction pointer. Sa mga crash namin, naging NULL ito.
Iyon ang bug.
Ipinapaliwanag din ng assembly na ito ang isang obserbasyong nakalito sa amin: bakit may NULL ang function X sa return address slot ng naunang stack frame.
Isinulat ang setcontext para i-restore ang lahat ng register, kabilang ang %rdi, kaya hindi nito magagamit ang register na iyon para basahin ang UC_MCONTEXT_GREGS_RIP(%rdi) sa huling sandali ng control transfer. Sa halip, mas maaga nitong binabasa ang value, sine-save ito sa stack, nire-restore ang ilan pang register, saka ginagamit ang retq para basahin ang naka-save na value at ilipat ang control.
Ang mukhang “bumalik ang isang function sa NULL” sa mga core ay talagang “gumawa ang unwinder ng target return address sa stack, pero nasira ang target na iyon bago natapos ang transfer.” Inakala naming kailangang mangyari in-place ang corruption ng return address slot, dahil wala kaming alam na lugar kung saan sinasadyang isulat sa return address slot ang (corruptible) data.
Ang nagpapamukhang absurd sa bug na ito ay kung gaano kaliit ang race window. Sa ganitong race condition, kailangang mangyari ang external event (ang signal) sa pagitan ng dalawang hakbang na ginagawa ng ibang thread. Habang mas magkalapit ang mga hakbang na iyon, mas mababa ang tsansang mangyari ang race condition.
Sa kasong ito, literal na isang instruction lang ang lapad ng vulnerable window! Kailangang ma-deliver ang signal pagkatapos mabago ang %rsp, pero bago i-load ng susunod na instruction ang %rip. Maraming simpleng instruction na ganito ang puwedeng patakbuhin kada cycle sa modernong super-scalar out-of-order CPU, kaya humigit-kumulang isang daang picosecond ang race window.
Nang makita namin ang race na ito, unang reaksyon namin na siguro ay napakabihira nito para maipaliwanag ang naobserbahang crash rate. Nakakakita kami ng higit sa isang dosenang return-to-null crash kada araw sa buong fleet. Talaga bang maipapaliwanag iyon ng one-instruction race habang naglilinis ng exception?
Bumaling kami sa Fermat estimation. Kung nasa antas ng segundo ang vulnerable window at dumarating ang SIGUSR2 kada segundo ng CPU time, bawat exception cleanup handler o catch block ay may humigit-kumulang na probabilidad na matalo sa race.
Gumagamit ang Rockset ng exceptions bilang bahagi ng internal ingest backpressure mechanism nito. Ang isang overloaded na host ay puwedeng mag-throw ng nasa antas na exceptions kada segundo. Ibig sabihin nito, ang mean time between failures ng host na gumagamit ng backpressure ay segundo, o isang crash kada ilang oras. Sa laki ng fleet, higit pa iyon sa sapat para maipaliwanag ang naobserbahang dalas ng crash.
Luma na ang GNU libunwind bug—mahigit 18 taon na, naroon na sa unang bersyon ng x86_64 na sumuporta sa C++ exception unwinding.
Kaya bakit ngayon ito lumitaw?
Halos proporsyonal ang crash rate sa dami ng exceptions na na-throw at dami ng signals na na-deliver. Nakasalalay din ito sa dami ng stack na kinokonsumo ng signal handler.
Di-pangkaraniwan ang Rockset sa lahat ng tatlong axis. Nagta-throw kami ng exceptions sa mataas na rate bilang bahagi ng normal overload control; nagde-deliver kami ng SIGUSR2 nang di-karaniwang madalas dahil sa coarse_thread_cputime_clock; at mas maaga ngayong taon, pinagamit namin ng mas maraming stack ang SIGUSR2 handler sa pagdagdag ng tawag sa timer_getoverrun, para ma-account namin ang merged signals.
Mukhang mahalaga ang huling pagbabagong iyon. Kung kaunti lang ang stack na ginagamit ng handler, maaaring hindi nito maabot at ma-overwrite ang stale na ucontext_t memory. Bago ang pagbabagong iyon, wala kaming nakitang ganitong crashes. Pagkatapos ng pagbabago, nanatiling mababa ang rate hanggang tinaasan namin ang load para sa ilang use case na nagpahirap sa backpressure mechanism.
Sa madaling sabi, laging naroon ang libunwind bug, pero kamakailan lang lumampas sa threshold ang produkto ng aming exception rate, signal rate, at handler stack usage kaya naging nakikita ito sa operasyon.
Ipinapaliwanag din ng mekanismong ito ang pagkakatong parehong nag-crash ang hardware bug at libunwind bug karamihan sa loob ng DocumentTree::updateDocument. Malakas ang bias ng mga crash mula sa libunwind patungo sa method na ito, dahil lagi itong active sa puntong nagta-throw kami ng exception para maglapat ng ingest backpressure. Malakas din itong napili para sa %rsp-misalignment crashes dahil ang masamang hardware node ay SKU na ginagamit namin para sa bulk ingest, na ginugugol ang karamihan ng CPU time nito sa method na iyon.
Ang agad naming mitigation ay lumipat mula GNU libunwind papunta sa unwinder ng libgcc. Magandang trade iyon mismo: nakinabang ang implementation ng libgcc sa maraming trabaho para bawasan ang lock contention, na mahalaga kapag nag-scale sa malalaking VM.
Nag-upstream din kami ng self-contained reproducer at fix(magbubukas sa bagong window) sa GNU libunwind, at tiniyak naming walang katulad na isyu ang ibang unwinders.
Marami kaming natutunan sa debugging journey na ito tungkol sa mga partikular na detalye ng dynamic linking, DWARF unwind metadata, Linux signal delivery, System V ABI, at C++ exception machinery. Pero mas simple ang pangunahing aral kaysa lahat ng iyon.
Ang pinakamahalagang hakbang ay hindi ang matalinong pagbasa ng assembly o malalim na kaalaman sa mga detalye. Ito ay ang paggawa ng de-kalidad na data set. Kung wala ang data set na ito, pinaghahalo namin ang dalawang magkaibang phenomena sa iisang kuwento at sinusubukang mangatwiran palabas sa kalituhan. Nang magkaroon kami ng tumpak at kumpletong population data, naging malinaw ang istruktura ng problema: ang isang crash population ay galing sa masamang host, at ang isa ay galing sa race sa libunwind. Nang gumanda ang data, gumaan ang debugging.
Para sa infrastructure systems tulad ng Rockset, napakahalaga niyon. Pinatibay ng imbestigasyong ito ang aming pangako sa malalim na instrumentation, automated investigations, at tuloy-tuloy na pagpapahusay sa aming operational tooling. Ang reliability ay hindi lang pag-aayos ng bugs matapos mangyari ang mga ito—ito ay pagbuo ng data, workflows, at kasanayang ginagawang madiagnose at malutas ang mga problemang mukhang imposible.
Mga May-akda
By Nathan Bronson, Member of Technical Staff


