in-place filter

A Tömörített (gzip) lemezkép használata loopback eszközként topicban felmerült a "helyben kitömörítés" kérdése.

Felkeltette az érdeklődésemet; írtam rá egy programot: https://git.sr.ht/~lersek/ipf

Hozzászólások

Kiprobaltam, szepen megy:

# dd if=/dev/zero bs=1M count=128 of=test.img
# losetup /dev/loop0 ./test.img
# mkfs.ext4 /dev/loop0
# tune2fs -m 0 /dev/loop0
# mount /dev/loop0 /mnt/tmp
# mkdir /mnt/tmp/apal
# chown apal.apal /mnt/tmp/apal
# exit

$ cd /mnt/tmp/apal
/mnt/tmp/apal$ scp -r -p ...:.../.../xyz.json .
/mnt/tmp/apal$ mkfifo orig-fifo filtered-fifo
/mnt/tmp/apal$ mkdir queue-dir
/mnt/tmp/apal$ df -k .
Filesystem     1K-blocks   Used Available Use% Mounted on
/dev/loop0        121299 111833      6845  95% /mnt/tmp
/mnt/tmp/apal$ ls -l 
total 111818
prw-r--r-- 1 apal apal         0 May  5 09:16 filtered-fifo
prw-r--r-- 1 apal apal         0 May  5 09:16 orig-fifo
drwxr-xr-x 2 apal apal      1024 May  5 09:16 queue-dir
-rw-r--r-- 1 apal apal 114498953 May  5 09:13 xyz.json
/mnt/tmp/apal$ gzip xyz.json 
gzip: xyz.json.gz: No space left on device
/mnt/tmp/apal$ md5sum xyz.json
5e3d2a0e966b4f8536c81f7212756cfd  xyz.json
/mnt/tmp/apal$ gzip -f < ./orig-fifo > ./filtered-fifo &
[1] 22861
/mnt/tmp/apal$ ipf -f xyz.json -w orig-fifo -r filtered-fifo -d queue-dir -s $((1*1024*1024))
[1]+  Done                    gzip -f < ./orig-fifo > ./filtered-fifo
/mnt/tmp/apal$ mv xyz.json xyz.json.gz
/mnt/tmp/apal$ zcat xyz.json.gz | md5sum
5e3d2a0e966b4f8536c81f7212756cfd  -
/mnt/tmp/apal$ df -k .
Filesystem     1K-blocks  Used Available Use% Mounted on
/dev/loop0        121299 14041    104637  12% /mnt/tmp
/mnt/tmp/apal$ 

A mukodesi elvek reszleteit meg meg tanulmanyozom :)

Koszi, hasznos lehet, tenyleg!

Kicsit megpeccseltem hogy tudjon ilyet is:

ipf -f xyz.json -p 'gzip -f' -d queue-dir -s $((1*1024*1024))

Mukodik, de az input sanitizing meg odebbvan :) Pl a getopt()-ban sem vagyok annyira jartas hogy a "vagy -r es -w vagy -p opcio kell"-t tudjam tesztelgetni. Meg ugye mi tortenik akkor ha a processzt nem birja elinditani, meg ilyesmi... mert most eleg csunya ez a dolog.

A filter gyerekprocesszkénti elindításával szándékosan nem foglalkoztam.

Mivel itt nem többszálú a parent (= az ipf), azért lehetne pipe() + fork() + dup2() + system()-et használni. [*] De még ebben az esetben is sok munka lenne a hibakezelést és a szignálok kezelését gondosan, szabályosan megcsinálni. (Az ipf az első processz, a fork() után van ugye egy child processz, akkor abban a system() elindít egy shell-t mint grandchild processzt, a shell meg a felhasználó által megadott pipeline minden egyes parancsához forkol még egy-egy "great-grandchild" processzt... És akkor még ott van az a még korábbi probléma, hogy egy pipeline-ban az utolsó pozíció előtt szereplő parancsok exit status-ával nem szoktunk törődni.)

Taknyolni (= hibakezelést kihagyni vagy elnagyolni) én nem vagyok hajlandó, a rendes kidolgozására pedig nincs időm (nem tartozik a core feature-höz amúgy sem!), úgyhogy ezért használ a program szándékosan named pipe-okat (FIFO-kat). Ez volt nagyjából a legelső döntés, amit meghoztam :) Eredetileg még getopt()-ot sem használtam (rögzített számú és sorrendű operandusokat várt a program), de a getopt-ot nem volt nehéz vagy terjedelmes belerakni, és így legalább az option-argument-ek sorrendje variálható, nem kötött. A szignálkezelés elkerülhetetlen volt, mert megszakításnál a queue directory-t ki kell pucolni. Ez a minimum, tehát ennyi elég is. :)

(

[*] Többszálú programból fork() hatására csak egy egyszálú gyerekprocessz ágazik le; az a szál létezik benne, amelyik a fork()-ból visszatér, a többi eredeti szál nem. Ennek következtében a gyerekben a fork() után (és az exec előtt) kizárólag async-signal-safe függvényeket lehet hívni, mert bármi más függvény olyan belső (libc) lock-októl függhet, amelyeket a gyerekprocesszben már senki sem fog feloldani (-> deadlock) -- az a szál, akinél a lock van, a gyerekprocesszben egyszerűen nem létezik. Emiatt nem lehet például fork() után malloc()-ot, execlp()-t / execvp()-t (!), system()-et stb. használni. Rögtön adódik a probléma, hogy a shell-jellegű parancssor parse-olást, amit normálisan a system()-re bíznánk, nem lehet a system()-re bízni. Adódik még az a probléma is, hogy a PATH search nem bízható az execlp()-re. Tavaly nyáron írtam egy async-signal-safe execvp() helyettesítőt; elképesztő mennyiségű munka volt, különösen a unit tesztek:

  1. https://gitlab.com/nbdkit/libnbd/-/commit/aa696c0a6a9d39e72b182c4bf5449…
  2. https://gitlab.com/nbdkit/libnbd/-/commit/0b7172b3cffaae23fa182a2d160f8…
  3. https://gitlab.com/nbdkit/libnbd/-/commit/0a083bc835a26476d563c91b970c2…
  4. https://gitlab.com/nbdkit/libnbd/-/commit/f4b8a2fac73068b03f697c84daaa7…

)

... Az ipf-et valószínűleg nem ajánlanám nem-interaktív használatra (szkriptelni lehet, de akkor a szkriptet érdemes szemmel követni); eleve olyan a felhasználási területe, hogy csak végső esetben érdemes hozzányúlni (ahogy a README.md-ben is írtam, én is azt ajánlom, hogy inkább vegyen a felhasználó több diszket). Szkriptből meg valahogyan így futtatnám (elnagyolva):

# recompress from gz to xz

unset filter1_pid
unset filter2_pid

cleanup()
{
  kill $filter1_pid $filter2_pid
  rm orig-fifo.$$ middle-fifo.$$ filtered-fifo.$$
  rmdir queue-dir.$$
}

trap cleanup EXIT

mkdir queue-dir.$$
mkfifo orig-fifo.$$ middle-fifo.$$ filtered-fifo.$$

gzip -d <orig-fifo.$$ >middle-fifo.$$ &
filter1_pid=$!

xz <middle-fifo.$$ >filtered-fifo.$$ &
filter2_pid=$!

ipf -f file -w orig-fifo -r filtered-fifo -d queue-dir -s ...
ipf_status=$?

wait $filter1_pid
filter1_status=$?
unset filter1_pid

wait $filter2_pid
filter2_status=$?
unset filter2_pid

if [ 0 -eq $ipf_status ] && 
   [ 0 -eq $filter1_status ] &&
   [ 0 -eq $filter2_status ]; then
  ...
fi

Igenigen, ezutobbi valtozat is mukodik, kiprobaltam :)

/mnt/tmp/apal$ ipf -f xyz.json.gz -p 'gzip -d | xz' -d queue-dir -s $((1*1024*1024))
/mnt/tmp/apal$ mv xyz.json.gz xyz.json.xz
/mnt/tmp/apal$ xzcat xyz.json.xz | md5sum
5e3d2a0e966b4f8536c81f7212756cfd  -

Szoval a `middle-fifo`-t meg szepen letrehozza a (ba)sh, ahogy kell! 

Most az akademiaibb problemam -- ebben az "ipf hozza letre a gyerekprocesszt" temaban -- inkabb az hogy hogyan lehet megkulonboztetni azt az esetet hogy a "child processz nem birt egy exec*() valamit elinditani" attol az esettol hogy "a child processz elindult es szabalyosan egy 0 hosszu stream-et hozott letre majd zart le". Nyilvan valami olyasmi lehet jo hogy a "pipe read oldala azelott bezarodott hogy a write oldalba barmit is irtunk volna", de ez ugye meg race condition es/vagy timing problema. 

Igen, a signal-os dolgokat jobban at kell neznem hogy igy hogy is vannak itt az `ipf`-ben pontosan. Az az amit nagyon-nagyon kerulok ugy altalaban - azaz ha valamit meg lehet oldani 100 sorbol szinkron multiplexinggel es 10 sorbol signal-okkal, akkor is inkabb ezelobbit valasztom. Oke, szelsoseges a pelda... De inkabb ezelobbi, es akkor akkora meglepetes nem erhet minket. Bar itt ebben a peccsben fork() utan van a signal-ok letrehozasa, szoval elvileg a gyerek semmit nem lat abbol:

  if ( process_filter != NULL )
   {    if ( (pf_pid=open_process(process_filter,&orig_fifo,&filtered_fifo))<=0 )
                goto free_buf_tmp;
   }
  else
   {    if (!open_file_and_check_type(&orig_fifo, O_WRONLY, false)) {
            goto close_regf;
         }
        if (!open_file_and_check_type(&filtered_fifo, O_RDONLY, false)) {
            goto close_orig_fifo;
         }
        pf_pid=0;
  }

  if (!signals_setup(&orig_actions, &orig_mask)) {
    goto close_filtered_fifo;
  }

Most az akademiaibb problemam -- ebben az "ipf hozza letre a gyerekprocesszt" temaban -- inkabb az hogy hogyan lehet megkulonboztetni azt az esetet hogy a "child processz nem birt egy exec*() valamit elinditani" attol az esettol hogy "a child processz elindult es szabalyosan egy 0 hosszu stream-et hozott letre majd zart le".

Az egyetlen lehetőség erre a child process exit status-ának begyűjtése a waitpid()-del. Semmi más nincs.

A POSIX konvenció az, hogy

  • exit status 0: minden rendben,
  • 1-125: sikerült execl()-elni a gyerekben a kívánt image-et, és az ilyen hibával lépett ki,
  • 127: nem lehetett megtalálni a gyerekben futtatandó image-et (az execl() ENOENT-tel hiúsult meg),
  • és a 126 az, amikor a gyerekben az execl() bármilyen más hibával hiúsul meg, pl. ENOEXEC hibakód, vagy el sem jutunk addig, hogy az execl()-t megkíséreljük, mert pl. korábban egy dup2() vagy akármi más kudarcot vall.
  • A >=128 értékeket nem használjuk, mert bár C-ben a WIFEXITED / WIFSIGNALED jól használható, shell-ben a $? ezeket összemossa, és a szignál hatására elpukkant processzt 128+n értékkel jelzi a $?, ahol n a szignál száma.

Lásd:

ebben a peccsben fork() utan van a signal-ok letrehozasa

Onnantól, hogy child process van, nagyon komplikált a takarítás. Vesd össze: ha interaktív shell-ből indítod, akkor mind szülő, mind gyerek ugyanabban a session-ben és ugyanabban a process group-ban lesznek, tehát ha nyomsz egy ^C-t, akkor mindkettő fog kapni SIGINT-et. Viszont ha a utility-t controlling terminal nélkül indítjuk el (pl. cron-ból, vagy ssh-n keresztül, de nem interaktívan / pty allokálása nélkül), majd utána SIGTERM-mel akarjuk leállítani a feldolgozást, akkor a user vakarhatja a fejét, hogy a pstree kimenetéből most akkor kinek küldjön SIGTERM-et. Ha jól tudom, erre szokták azt mondani, hogy ha már child process-eid vannak, akkor jobb, ha léthehozol a fő processzben egy unix domain socket-et, amin parancsokat fogadsz. :/

A fork() után tenni a szignálkezelést azért lehet problémás, mert van egy olyan ablak a szülőben, ahol el tudja leak-elni a gyereket (a fork() és a sigaction() között kap mondjuk egy SIGTERM-et). A fordított sorrend meg azért lehet problémás, mert ha a szülőben az összes sigaction-t egy függvénybe csoportosítjuk, és pl. a SIGXFSZ-t ignoráljuk, akkor utána a gyerekben is ignorálva lesz. Mindenképpen macera. Utólag nehéz belehekkelni. Ha forkolni akarunk, akkor eleve úgy kell felépíteni a parent-ben a lépéseket. (És én ezt szándékosan nem csináltam meg, mert túl sok meló, és a program nem erről szólt.)

A szép megoldás az, amikor mindent előkészítünk a fork()-hoz, kifejezetten a gyerek kedvéért, utána fork(), aztán a szülőben a gyerek PID-jét már rendszerszinten allokált erőforrásnak tekintjük, pont úgy, mint egy létrehozott temporális file-t, és ha a setup további részében bármi gikszer van, pl. akár egy SIGTERM érkezése is (!), akkor ezeket az erőforrásokat már le kell takarítani (unlink a temp file-ra, kill a child process-re, stb).

Az egyetlen lehetőség erre a child process exit status-ának begyűjtése a waitpid()-del. Semmi más nincs.

Igen, ez valo igaz, de akkor mar keso... Most ha azt mondom hogy `ipf ... -p 'gzip -f' ...` akkor minden oke, letrehozza a tomoritett file-t, de ha azt mondom hogy `ipf ... -p 'gizp -f' ...` akkor bizony ott lesz egy 0 hosszusagu file az eredeti helyen... 

Így van! Ez is csak egy filter-beli hibának minősül, az pedig elrontja a file-t.

A child-ban egyszerűen nincs olyan pont, ahol pozitív megerősítést tudnál küldeni a szülőnek, hogy "rendben elindítottam az igazi filtert". Ha az execl() meghiúsodik, arról lehet szólni a parent-nek, de ha az execl() sikeres, arról nem -- mert a sikeres execl() után már egy tök általános filter, pl. gzip, fog futni, ami nem fog kommunikálni. Így a szülő sem tud a feldolgozás megkezdése előtt (mondjuk) waitpid()-del ellenőrzést végezni: a hibát éppen megkaphatná, de siker esetén nincs információ.

Ismétlem, ez nem különbözik attól, mintha a gzip-et (mondjuk) -Q kapcsolóval hívnád meg -- az execl() szépen lemenne, de a gzip azonnal kiszállna azzal, hogy "invalid option". Az eredmény ugyanaz: az eredeti file kuka.

Igen, ez sajnos ilyen... talan tenyleg annyival vagyis ott lehet megfogni a dolgot hogy hibas opcio (vagy nem letezo futtathato file) megadasa eseten a read pipe mar akkor is bezarodik megmielott barmi adatot is kuldenel neki a write pipe-n keresztul. Ha a kimenet szabalyosan egy nulla hosszusagu stream (pl a `gzip -d` valasza a 1f8b080000000000000303000000000000000000 stream-re), akkor meg nem szabad bezarodni a read pipe-nak meg azelott hogy barmit is kuldtel volna a write pipe-on keresztul. Nezegetem kicsit a filter() fuggvenyt, hatha latok valami modozatot erre... 

Ellenpélda: "head -c 0" ;)

Szerk.: kicsit bővebben, a filter()-t úgy akartam megírni, hogy teljesen általános legyen; ne tételezzen fel semmit a filter viselkedéséről. Pl. a "true" is egy filter bizonyos értelemben (végső soron a "head -c 0" egy variációja), és a "printf 'hello world\n'" is az. Én meg akartam engedni teljesen általános filter viselkedést, és az a cél ütközik az olyan heurisztikával, amit felvetsz.

(Egy másik érdekesség, hogy az EPIPE íráskor nem számít végzetesnek vagy igazából hibának sem; egyszerűen annyit jelent, hogy a filter nem kér több adatot (pl. head -c 10). Erre eredetileg nem gondoltam, de aztán eszembe jutott egy tizenévvel ezelőtti beszélgetésem a reddit-ről, és akkor beleraktam az explicit "orig_fifo_broken" kezelést. Meglehetős komplikációt jelentett.)

mi tortenik ha a konvertalas kozben lerohad az egesz? (aramszunet, oomkiller, akarmi). lehet folytatni vagy maradt egy hasznalhatatlan blob fajlunk?

A vegtelen ciklus is vegeter egyszer, csak kelloen eros hardver kell hozza!

Szerintem ehhez maganak a `filter` pipe-nak is tamogatnia kell a sajat allapotanak a lementeset hogy segitsegedre legyen varatlan krizis eseten. Normalis esetben is, ha pl van egy `gzip -f` filtered, azt megszakitod, akkor hiaba jon letre valamennyi a *.gz-bol, legjobb esetben is elolrol kell kezdened a filter futtatasat (tomoritest) hogy onnan tudd folytatni ahol abbahagyta. 

Szoval igen, ez egy kockazatos muveletsor lesz, az ketsegtelen. 

Ha a feldolgozás elkezdődött, de nem fejeződött be, akkor az eredeti file kuka. A doksi nem árul zsákbamacskát:

https://git.sr.ht/~lersek/ipf#risks

Az ipf exit status-ából és stderr-jéből lehet következtetni arra, hogy ez az eset áll-e fenn. Ha az exit status 1, akkor még a setup fázisban történt probléma, a file-hoz még nem nyúltunk. Ha az exit status 141, 153, 129, 130 vagy 143 (per $?), akkor a file szintén nem korrupt; az viszont nem mondható meg (pusztán az exit status-ból), hogy az eredeti-e a file tartalma, vagy már a feldolgozott ("átszűrt"). Ha az exit status 2 és 127 között van (inkluzíve), akkor az eredeti file kuka. Ha >=128 (kivéve az előbb felsoroltakat), akkor szintén kuka.

https://git.sr.ht/~lersek/ipf#exit-status-summary

... Ja és ahogy a doksiban hangsúlyozom, attól, hogy az ipf 0 státusszal száll ki, maga a filter még simán csinálhatott disznóságot :)

Ezt a segédprogramot akkor érdemes használni, ha kiegészítő háttértárra nincs lehetőség, és a fenti kockázat vállalható.