Try   HackMD

UNIPI SOL: Relazione Progetto FSS

studente: Claudio Candelori
matricola: 549710
corso: A
anno: 2022
repo: https://github.com/OneClaudio/OneClaDrive

1. Struttura del codice:

  1. SHARED:
    • comm.h: Codici di operazioni condivise fra server e API
    • utils.h: Codice di appoggio contenente funzioni di utilita' usate in tutte le componenti
    • errcheck.h: Libreria contenente macros di error checking
  2. FSS: File Storage Server
    • server.c: Il core di tutto il progetto, contiene anche il codice dei thread worker
    • filestorage.c: Struttura dati (linked list) che ospita i file in RAM
    • idlist.c: Una struttura dati che contiene una coda/lista di identificatori di client, usata sia come coda di id pronti (da manager a workers) sia come lista di id che hanno aperto un file.
  3. API:
    • api.c: Unica interfaccia che l'FSS ha bisogno di esporre per funzionare. Il client sfrutta esclusivamente le funzioni visibili al suo interno per comunicare col server
  4. CLIENT:
    • client.c: Client che, come da specifica, puo' sfruttare i vari cmd line args per chiamare funzioni dell'API e quindi scambiare file con il server.
    • optqueue.c: Ospita una coda di opzioni con i relativi argomenti (ancora raw, non parsed), che il client puo' prendere ed eseguire uno ad uno.

2. Comunicazione SERVER/API:

typedef struct {
	CmdCode code;
	int info;
	char filename[PATH_MAX];
	} Cmd;

typedef enum CmdCode{ IDLE, CREATE,	OPEN, CLOSE,
                      WRITE, APPEND,READ, READN,
                      LOCK, UNLOCK, REMOVE, QUIT } CmdCode;

#define	O_CREATE 0x1
#define O_LOCK	 0x2

Il modo con cui l'API (e quindi i client) comunica quale operazione deve essere eseguita al server e' tramite l'invio di un oggetto di tipo Cmd, per le operazioni piu' semplici contiene gia' al suo interno tutte le informazioni sufficienti per eseguirle:

  • CmdCode code: Codice (sfruttando il tipo enum) che identifica l'operazione richiesta.
  • char filename[PATH_MAX]: Pathname del file su cui si vuole operare. Richiesto da tutte le operazioni (tranne la readNFiles()).
  • int info: Parametro di informazioni ausiliari usate solo da poche operazioni. E' interpretato in maniera diversa a seconda dell'operazione. Per la openFile() conterra' i flag opzionali O_CREATE, O_LOCK. Per la readNFiles() invece rappresentera' il numero di file da leggere.

In tutto il progetto ho deciso di imporre come limite superiore alla lunghezza dei file PATH_MAX che e' definita in <linux/limits.h>. Anche se non e' un vero e proprio limite imposto dal sistema operativo, usarla conferisce la flessibilita' di poter scambiare messaggi con il server di dimensione fissata (come appunto Cmd).

typedef enum Reply{ OK, ANOTHER, CACHE, NOTFOUND, EXISTS, LOCKED, NOTOPEN, ALROPEN, NOTLOCKED, ALRLOCKED, EMPTY, NOTEMPTY, TOOBIG, FATAL } Reply;

Analogamente alla struttura precedente, Reply contiene invece tutti i codici che il server puo' inviare al client come risposta. Si tratta pero' solo di errori che riguardano la logica delle operazioni richieste. I possibili errori interni del server (es: errori delle malloc, errori di IO) sono tutti controllati ma sono principalmente errori gravi e distruttivi, ergo, rientrano tutti nel tag FATAL e non hanno dei codici dedicati.

  • OK: indica che l'operazione richiesta e' stata eseguita con successo, oppure che si puo' procedere al prossimo step (es: invio di file).
  • ANOTHER/CACHE: usati ogniqualvolta il server debba restituire dei file possibilmente in blocco. Ad esempio la readNFiles() comunica al server quanti file vuole leggere, ma non e' detto che siano tutti disponibili, oppure a seguito di una writeFile() l'algoritmo di cache potrebbe espellere piu' di un file per far posto al nuovo file da scrivere. In questi casi il server e il client entrano quindi in un loop e, fintanto che il client riceve come risposta uno di questi due codici, e' segno che il client debba continuare a ricevere file.
  • ALROPEN/ALRLOCKED: Gli unici codici che non sono veri e propri errori e sollevano semplicemente una notifica che indica la ridondanza dell'operazione richiesta. Non sono quindi distruttivi e se ricevuti non segnalano al client un fallimento dell'operazione (restituiscono 0), ma semplicemente stampano un warning.
  • TUTTI GLI ALTRI: Questi sono i veri e propri codici di errore, indicano che l'operazione richiesta non e' andata a buon fine e ne indicano il motivo, in genere una logica delle operazioni sbagliata. Per ognuno di questi una funzione di supporto nell'API (errReply()) stampa un messaggio di errore e setta ERRNO con un codice quanto piu' analogo possibile.

I vari codici delle operazioni sono riutilizzati anche all'interno del file di log, grazie a due funzioni che restituiscono la stringa corrispondente al codice enum.

3. ERROR CHECKING:

Trovando molto poco leggibile il paradigma di esecuzione di certe funzioni dentro agli if per controllarne il valore di ritorno con conseguente gestione dell'errore, ho scritto una mia personale libreria che sfrutta pesantemente le macro, fortemente ispirata e semplificata da quella presente nel manuale Advanced Unix Programming (Rochkind). A mio parere questo metodo migliora di molto la leggibilita' del codice e gli if possono cosi' tornare a fare solamente il proprio "lavoro".

Principalmente una macro (le piu' usate sono ErrNULL() e ErrNEG1( )) esegue e confronta il valore di ritorno con il codice di fallimento. Se questo e' il caso genera un messaggio di errore, usando sempre lo stesso formato, con tutte le informazioni necessarie per risalirvi, dopodiche' salta ad una sezione di codice di cleanup. Il codice di cleanup e' delimitato dalle macro ErrCLEANUP ErrCLEAN, la prima contiene il label a cui saltano tutte le varie macro, inoltre stampa un warning se la zona di cleanup viene raggiunta (per dimenticanza) dal flusso di controllo.
Inoltre usando lo standard gnu99 e' possibile dichiarare piu' volte la stessa label in blocchi annidati. Senza di questo una label puo' essere usata solo una volta in ogni funzione separata.
Alcune altre macro usate sporadicamente sono ErrFAIL e ErrSHHH che vanno direttamente al codice di cleanup senza controlli, la prima stampa il messaggio di errore mentre la seconda fallisce silenziosamente.

-D_GNU_SOURCE: inoltre permette di avere disponibile la funzione strdupa() che e' una strdup() che viene deallocata automaticamente all'uscita dello scope.

Alcune funzioni di libreria molto usate o funzioni d logging frequenti/stampa errori hanno una macro che snellisce ulteriormente la lettura e che ingloba al suo interno tutto l'error checking necessario, in genere ha proprio il nome della funzione originale in all caps come ad esempio:

#define LOCK(l) ErrERRNO(  pthread_mutex_lock(l)   );

#define FWRITE( addr, size, n, id) ErrNEG1(  fwrite( addr, size, n, id)  );

4. SERVER:

4.1. Componenti usate:

struct IdList{
    IdNode* first;		//enqId()--> x-[ ]<-[ ]<-[ ]<-[ ]<-[ ] -->deqId()		
    IdNode* last;		//	        ^                   ^	
    pthread_mutex_t mutex;	//	     first		      last
	}

IdList: utilizza un pattern molto usato all'interno del progetto,una singly linked list permette inserimento e estrazione in O(1), ma ricerca solo in O(n). Viene utilizzata all'interno del server come una coda usando le funzioni enqId() e deqId(). Viene utilizzata nella seguente struttura File per contenere la lista di clienti che hanno il file aperto, tramite le operazioni enqId() e findRmvId() per aggiungere/rimuovere un id, findId() per sapere se un certo id e' nella lista.
Dovendo distinguere quindi fra varie liste, ognuna di queste funzioni prende come parametro anche la lista su cui dovra' andare ad operare.

struct File{
    char  name[PATH_MAX];
    void*  cont;
    size_t size;
    
    IdList*	openIds;
    int 	lockId;
    
    struct File* prev;
    }

struct Storage{
    File* first;
    File* last;
    
	pthread_rwlock_t lock;
	
	size_t numfiles;
	size_t capacity;
	}

File/Storage: Essendomi riuscito a concentrare solo sulla politica FIFO la scelta iniziale e' ricaduta inizialmente sempre su una lista, ed e' quindi piu' adatta all'inserimento di nuovi file e all'espulsione dettata dal cache algorithm (addNewFile() e rmvLastFile() ) che avvengono in tempo costante, mentre le opzioni che richiedono di operare su un file specifico richiedono tempo lineare: la getFile() restituisce un riferimento al file cercato sul quale il server puo' eseguire tutti i controlli e le operazioni necessarie, in piu' e' presente un'operazione (removeThisFile()) per la rimozione di un file specifico (ottenuto tramite la getFile). Queste sono le uniche funzioni necessarie al server per operare sullo storage ed, essendo poche e semplici, in futuro possono essere sostituite con strutture dati piu' performanti a seconda dei bisogni.

Essendo lo storage un'unica entita' in tutto il lato server, e' dichiarato globalmente nell'header filestorage.h con il nome molto creativo di Storage storage e tutte le funzioni e i worker thread operano implicitamente su di esso senza bisogno di doverlo ricevere o passare come parametro ogni volta.
Contiene al suo interno una read/write lock, oltre ai parametri di dimensione e n. file.
L'aggiornamento di questi parametri avviene implicitamente ogni volta che i file vengono aggiunti o rimossi dalle funzioni per operare sullo storage addNewFile(), removeLastFile(), removeThisFile(). Ad eccezione di operazioni che incrementano la dimensione dei file (WRITE/APPEND) che invece se hanno successo aggiornano sia la dimensione dei file che dello storage.

4.2. Manager thread:

Il pattern utilizzato per la realizzazione del server e' quello Manager-Worker con Thread-pool.
Il thread manager corrisponde al main del server.c.
Ha una fase di avvio che utilizza per installare correttamente i gestori dei segnali, leggere ed inizializzare le opzioni e i vari limiti del server dal file di config, aprire il file di log, aprire la socket, avviare i thread pool e inizializzare le strutture con cui comunichera' con essi.

Dopo la fase di startup il manager entra in un suo ciclo che non verra' mai bloccato se non dall'arrivo dei segnali indicati dalla specifica. Il main si blocca sulla pselect() che puo' stare in ascolto dei vari fd E dei segnali e bloccarli automaticamente durante l'esecuzione di una qualsiasi operazione.

Se la pselect() viene interrotta da un segnale restituisce 0, non entra quindi nel loop di gestioni delle connessioni e viene semplicemente eseguita la funzione handler corrispondente che cambia lo stato del server e annota sul log l'evento, il server ha una variabile globale Status che puo' assumere i valori: ON,SOFT, OFF. Il secondo viene settato da SIGHUP, l'ultimo da SIGINT e SIGQUIT.

Il ciclo principale e' ovviamente dotato di condizioni di guardia che escono dal loop quando richiesto:
Esce se Status==OFF, Status==SOFT && activeCid==0, inoltre non accetta piu' nuove connessioni se lo stato passa a Status==SOFT.

Il manager accetta nuove connessioni e le inserisce nel set di fd controllati (all).

Per la comunicazione da manager a workers e' stata utilizzata come gia' accennato la IdList pending che e' dichiarata globalmente e viene usata come coda concorrente tramite le funzioni enqId() e deqId(), che al loro interno acquisiscono una lock (specifica di ogni IdList) prima di poter estrarre o inserire un nuovo id. Il manager inserisce gli id pronti con un'operazione da seguire su questa coda, mentre i thread vi si bloccano in automatico ogni volta che e' vuota in attesa di un client per cui soddisfare una richiesta. Allo spegnimento del server vengono inseriti sulla coda dei codici TERMINATE, uno per ogni worker creato, che indicano al worker di uscire e terminare cosi' in maniera pulita.

Gli fd vengono rimossi dal set di fd controllati durante l'esecuzione, da parte di un worker, delle richieste del client corrispondente. Il worker esegue esclusivamente una singola API call dopodiche' restituisce l'id del client su una named pipe done, cosiche' il manager possa reinserirlo nella lista di id ascoltati. Un client che vuole terminare le richieste invia una notifica di disconnessione (un Cmd con codice QUIT) ed ogni worker dopo aver ricevuto questa intenzione inserisce un codice speciale DISCONN nella pipe done, serve al manager che decrementa di conseguenza il numero di connessioni attive activeCid.

4.3. Worker threads:

Ogni thread, prende un Client ID (cid), dopodiche legge da esso il comando che vuole eseguire leggendo Cmd cmd, e ottiene dallo storage il file corrspondente sul quale operare (il nome univoco e' in cmd.filename e se presente nello storage il file corrispondente viene assegnato ad una variabile File* f).
Dopo di che entra in un lungo switch case sul tipo di richiesta ricevuta (cmd.code), controlla che il cliente abbia tutte le carte in regola e, se e' il caso, esegue e completa la richiesta, in genere chiudendo con REPLY(OK) o con una reply contenente i motivi per cui l'operazione non puo' essere completata.
Se si presenta une errore distruttivo (es: esaurimento memoria malloc) il worker risponde al client con REPLY(FATAL), e subito dopo manda un segnale SIGINT al processo stesso per cercare di far spegnere tutto il server nel modo piu' pulito possibile.

4.4. Logfile:

Il logging e' effettuato da una macro LOG() nel manager, e da una macro LOGOPT( outcome, file, size ) che prende in automatico informazioni sul ClientID cid, sull'operazione in corso cmd.code, e stampa l'esito dell'operazione, e se una lettura/scrittura la dimensione in Byte inviati/ricevuti, e ovviamente su quale file.
Di seguito un modello di un possibile logfile:

MAIN: CREATED WORKER THREAD #3
MAIN: SERVER READY
MAIN: ACCEPTED NEW CLIENT 8
MAIN: ACCEPTED NEW CLIENT 9
WORK: CID=8   OPEN   OK         		./FILES/clearly.png
WORK: CID=8   LOCK   OK         		./FILES/clearly.png
WORK: CID=9   WRITE  CACHE     127863 B	./FILES/riccio-barbq.jpg
WORK: CID=9   WRITE  CACHE     4192 B	        ./FILES/clinteast.jpg
MAIN: SOFT QUIT SIGNAL RECEIVED -> NO NEW CONNECTIONS ALLOWED
WORK: CID=9   WRITE  CACHE     58806 B	./FILES/absolutelyp.jpeg
WORK: CID=9   WRITE  CACHE     212741 B	./FILES/river-rms.jpg
WORK: CID=9   WRITE  OK        995122 B	./FILES/sand.gif
WORK: CID=6   OPEN   EXISTS     		./FILES/clearly.png
WORK: CID=7   LOCK   NOTFOUND   		./FILES/river-rms.jpg
WORK: CID=8   WRITE  TOOBIG     		./FILES/clearly.png

5. API:

Le varie funzioni richieste dalla specifica sono state tutte implementate e fanno largo uso di alcune funzioni interne:

  • int firstCMDREPLY(): Invia al server un elemento di tipo Cmd contenente un bundle di informazioni che come gia' spiegato indicano operazione da eseguire, su quale file e un int di informazioni speciali. Subito dopo legge una Reply dal server cosi' che puo' capire se puo' continuare o no. Questa funzione e' in genere il primo comando di ogni funzione dell'API.
  • int RECVfile( char* savedir): Riceve dal server un file (non si sa quale) quindi ne legge in ordine dimensione, contenuto e pathname. Se la cartella savedir e' specificata invoca la funzione successiva sul file appena ricevuto. Usata sia dalla funzionalita' readNFiles() sia dalle funzioni che potrebbero ricevere file espulsi dal cache alg. (openFile(),writeFile(),appendToFile()) tramite la funzione CACHEretrieve().
  • int SAVEfile(): Salva il file specificato nei parametri cont,size,pathname nella cartella specificata, se la cartella non esiste viene creata dalla mkpath() che crea tutto il path necessario. E' stato deciso di trattare queste cartelle di "salvataggio" come se fossero dei "cestini". I file vengono stoccati dentro la cartella specificata come parametro. L'univocita' dei file e' mantenuta perche' l'informazione sul loro path univoco salvando viene scritta sul nome dello stesso file salvato.

6. CLIENT:

Il client e' stato costruito cercando di aderire quanto piu' possibile alla specifica di progetto, che afferma che un'opzione debba potuta essere ripetuta piu' volte da un client. La scelta e' stata quindi quella di eseguire un parsing iniziale con la funzione getOpt(), e trovare tutte le opzioni iniziali (-h, -f, -p). Tutti gli altri comandi vengono inseriti in una coda optQueue, e eseguiti successivamente nell'ordine in cui appaiono sulla linea di comando.
Prima di iniziare ad eseguire le azioni elencate, ogni client invoca la openConnection() sulla socket specificata dall'opzione -f o su una di dafult, ed installa un handler per il segnale SIGPIPE.

I comandi della specifica sono stati interpretati nel seguente modo:
* -h, -f, -p: sono per configurare informazioni iniziali di tutta l'esecuzione del client.
* -d, -D, -t: sono per aggiornare informazioni che hanno la possibilita' di cambiare durante l'esecuzione del client. Ad esempio ./client -t 200 -w ./READ -t 500 -R scrivera' tutti i file della cartella ./READ a ritmo di uno ogni 200ms, il secondo -t cambia l'intervallo a 500ms e quindi il client continuera' a leggere i file dal server uno ogni 500ms.
* -w, -W: Non essendo presente in specifica un singolo comando per aprire/chiudere i file, ho deciso di adattarmi a questa indicazione. Le opzioni di scrittura invocano implicitamente una openFile(..., O_CREATE | O_LOCK, ...) dopodiche' scrivono il file con la writeFile() e successivamente chiudono il file con closeFile(), ognuna di queste API call avviene con il ritardo specificato da -t.
* -r: Allo stesso modo -r esegue una OPEN>READ>CLOSE ma stavolta la open avviene senza flag di creazione/lock.
* -l, -u: La -l esegue una openFile() e una lockFile() che puo' eventualmente bloccarsi in caso di file bloccato da altri client. La -u esegue una unlockFile() seguita da una closeFile(). Le lock sono indispensabili per eseguire le seguenti operazioni di append -a e rimozione -c, a differenza della -w che assume la lock implicitamente.
* -a: Opzione aggiunta per testare la funzionalita' di append. Necessita di una chiamata -l antecedente sullo stesso file per avere successo. Accetta 2 file, di cui il primo e' l'informazione da appendere e il secondo la destinazione.
* -R: Ho scelto di interpretare l'opzione -R come un grande dump di tutto lo storage, per ottenere i contenuti di tutti i file al suo interno (meno quelli locked da altri client). Quindi sia lato server che lato client la readNFiles() non richiede che i file vengano aperti precedentemente, ne invoca una openFIle().

Tutte le opzioni vengono eseguite in uno switch case sulla lettera corrispondente all'opzione. Tutte le opzioni che accettano una lista di file separati, inoltre, hanno un ciclo interno allo switch case in cui viene fatto il parsing di ogni file con la funzione di libreria strtok_r e per ogni file eseguito il comando corrispondente.

7. MAKEFILE/TESTING:

Il progetto ha una struttura:
SRC: Contiene tutti i vari file sorgente divisi per ogni componente, e i file di oggetto creati in una sottocartella di ogni componente in modo che non ingombrino.
BIN: Contiene la cartella degli ASSET sui quali e' stato testato il progetto, e gli eseguibili e i file generati dall'esecuzione come socket e logfile.
TEST: Contiene gli script in bash e le diverse config richiesti dalla specifica.
DOC: Licenze e questa relazione.

Le varie cartelle sono dichiarate all'interno del makefile come variabili e quindi facilmente modificabili se si vuole cambiare la struttura delle folders.

Gli script dei test in bash vedono automaticamente il contenuto della cartella degli asset e lo assegnano ad un array che poi viene mischiato con il comando shuf in modo che ogni esecuzione risulti almeno parzialmente randomizzata. Questo approccio mi ha permesso di correggere varie dimenticanze che potrei non aver riconosciuto con dei test statici.
Ulteriori file di test possono essere aggiunti direttamente nella cartella FILES e verranno utilizzati dai client.
Sia server che client vengono lanciati in background con i vari timer indicati dalla specifica.

8. COMMENTI FINALI:

Avendo avuto problemi con le tempistiche, sono assenti alcune funzionalita' richieste dalla specifica. In particolare: sono assenti le informazioni di recap stampate dal server alla fine dell'esecuzione e il parser del logfile, anche se il logfile e' stato pensato per poter estrapolare semplicemente tutte le informazioni che si vuole.
Inoltre non ho avuto modo di testare il progetto sul sistema operativo fornito dal corso. Ad ogni modo lo sviluppo e' stato portato avanti su una macchina con Linux Mint XFCE che dovrebbe essere molto simile alla VM di XUbuntu che era stata fornita.
Il codice e' ampliamente commentato completamente in inglese, ma manca una specifica dettagliata di tutte le funzioni.