This is a simple calendar application that allows users do things with events. The application is built using the TCP protocol.
The key is to demonstrate learning of TCP and socket programming, and simple protocol design.
The construction of the protocol should center around the following:
- Simple, easy to understand, easy to implement
- Minimalize the payload size over network. Do not send unnecessary data.
Consists of three main components:
- Client
- Server
Would only have 1 entity: event
, with 6 fields:
username
eventname
date
startTime
endTime
valid
For the scope and purpose of this assignment, it should be enogh.
This table is pretty normalized as well.
No need to think about the database and serialization/deserialization, on the server's side, it's a in-memory database, implemented using a simple linked list.
Per the assignment spec, each user would have 1 list, and the list should
be sorted by order of date
and startTime
.
Also, store the lists in a linked list, and have the size able to be dynamically adjusted.
Insert, update, delete and select operations are all in O(m+n)
time,
where as m
be the number of existing users, and n
be the number of
events in the user's list.
The client is used to interact between the user and the server.
The client's responsibility is to:
- take user input
- validate user input
- parse user input into a request structure (abstract)
- pack the request structure into a request payload (128 bits)
- send the request payload to the server via TCP connection
- receive the response payload from the server.
- unpack the response payload into a response structure (abstract)
- print out the response to the user
The server is used to receive the request and send the response.
Be more detailed:
- create a socket
- bind the socket to a port
- listen for incoming connections
- accept incoming connections (needs to track number of connections)
- receive the request payload from the client
- unpack the request payload into a request structure (abstract)
- process the request (database things)
- pack the response structure into a response payload (104 bits)
- send the response payload to the client
- close the connection
3 types of server:
The MAX_CONN
would be different for each type of server.
Except the select()
based server, which has way smaller limit
due to FD_SETSIZE
(1024). Other servers limit would be hard
to determine.
3 factors that limits the number of connections:
- Maximum socket file descriptors:
- check via
getrlimit(RLIMIT_NOFILE, &rlim)
- check via
- Maximum threads (pthreads):
- from
cat /proc/sys/kernel/threads-max
,- on my vm, it's 256214
- and on
tux
ortrux
cluster, it's 4194304.
- from
cat /proc/sys/kernel/pid_max
,- on my vm, it's 32768,
- and on
tux
ortrux
cluster, it's 41man 94304.
- from
EAGAIN A system-imposed limit on the number of threads was encountered. There are a number of limits that may trigger this error: the RLIMIT_NPROC soft resource limit (set via setrlimit(2)), which limits the number of processes and threads for a real user ID, was reached; the kernel's system-wide limit on the number of processes and threads, /proc/sys/kernel/threads-max, was reached (see proc(5)); or the maximum number of PIDs,
/proc/sys/kernel/pid_max, was reached (see proc(5)).
- Maximum memory...
Similar problem with the max number of users, as the memory and number of mutexes are limited.
So I guess is just to not set a hard limit at all, and do error
handling for each cases, and checks EAGAIN
, ENFILE
, EMFILE
, ENOMEM
for each, and handle them properly.
Approach Based on Select()
: A single-threaded server is used,
but the server can handle multiple clients (and open sockets) with
the select()
system call.
The implementation will be similar to the multi-person chat server example
from Beej's Guide to Network Programming.
In the sense that, we will use keeping track of all open sockets from
listen()
on sockfd
to new_fd
from accept()
in a fd_set
and
use select()
to check for activity on the sockets.
The difference will be that, instead of forwarding the message to all sockets, we will process the request, then send the response to only the socket that sent the request.
Also, the connection will be closed after the response is sent.
Since it's single threaded, there is no need for synchronization and locks on the in-memory database. However, I'll include the lock in the user's list anyways for all to keep consistency (1 data structure design for all 3 types of servers).
With select()
, although there's no need for synchronization, but due to
the underlying implementation of select()
(I didn't read the source code,
but it's either checking on cyclic queue or hardware interrupt), for example,
if the two request,
./mycal ... add ... newEvent
./mycal ... getall
arrive at the same time, the output will be undetermined, the getall
could
either include the result of newEvent or not. And might be different behavior
between each execution.
There are 2 ways:
-
introduce a priority to the server (or notion of precedence), that the modify operation will be processed first, then the read operation.
I'm not sure if
select()
can do this. -
Just say on the documentation here that this application is eventually consistent. If the user is not confident with the result, just re-run the command and it should have the updated result.
Such server can hold up to MAX_CONN = 1024
connections, due to the
limitation of FD_SETSIZE
(1024) on the select()
system call.
Threads will be created from posix threads to handle each client.
The server will act as the main thread, and only listen()
and accept()
connections.
Once a connection is accepted, a new thread will be created, given the
new_fd
as parameter, and will handle the request and response by itself.
main thread will continue to accept()
new connections and create children.
On the child thread's side, it will handle the request, do the database operation (synchonization is required, using mutex on the user's list), then send the response, and close the connection. Once the connection is closed, the child thread will terminate and join back to the main thread.
The list for each user will have an additional mutex
field to synchronize.
Similar to the multi-threaded server, but instead of creating threads,
we will create processes using fork()
.
Because the memory would be different from the multi-threading case,
we need to first use mmap()
to make the linked list a shared memory.
In the child process, it will handle the request, check the mutex, do the database operation, send the response, and close the connection, finally terminate.
I do not plan to use munmap()
to unmap the shared memory, because I'm
not sure if that would nuke the map for sibling processes as well? As
man pages says that:
The region is also automatically unmapped when the process is terminated.
So I'll just let the OS handle it.
The protocol handling and the database operations would be common to all 3 types of servers, consider to extract them into a separate module.
The client and server will have their own utilities to handle the parts where not directly related to the TCP connection.
strstructc: between string and data structure for client.
- Validate the input.
- Parse the command line arguments to data structure (
request_t
). - Format and
response_t
data structure to strings that is ready to output.
payloadstructc: between payload and data structure for client.
- Validate the payload received from the server. (check for invalid response)
- Pack the data structure into a payload.
- Unpack the payload into a data structure.
#include <stdint.h>
#include <stdbool.h>
typedef struct {
char[5] username;
char[10] eventname;
uint16_t daysSinceEpoch;
uint16_t startTime;
uint16_t endTime;
bool isADD;
} request_t;
typedef struct {
uint32_t opcode : 1;
uint32_t clTime : 21;
uint32_t date : 16;
uint32_t uname : 30; // Represented as an integer here, but will need special handling
uint64_t ename : 60; // Same as above
} reqpayload_t;
typedef struct {
bool success;
bool hasBody;
int date;
int startTime;
int endTime;
uint8_t numNext;
} response_t;
int parse_args(int argc, char* argv[], request_t* req);
int reqToPayload(request_t* req, char* buf);
int format_response(response_t* res, char* buf);
int streamToRes(char* buf, response_t* res);
/* local functions */
uint8_t charTo6bit(char c);
char bit6ToChar(uint8_t b);
uint32_t timeToClTime(uint16_t startTime, uint16_t endTime);
int clTimeToTime(uint32_t clTime, uint16_t* startTime, uint16_t* endTime);
Request handling:
user input (char*[]
) -> client -> parse_args()
-> client (request_t
)
-> reqToPayload()
-> client (char[16]
) -> TCP -> server
Response handling:
TCP -> client (char*[13]
) -> streamToRes() -> client (response_t
)
-> format_response() -> client (char*
) -> output
payloadstructs: between payload and data structure for server.
- Validate the payload received from the client. (check for invalid request)
- Unpack the payload into a data structure (
request_t
). - Pack the
response_t
data structure into a payload.
dbops: database operations.
- lock and unlock the mutex (
mutex
,read_lock
,write_lock
) - select, insert, update, delete
- create new user list
#include <stdint.h>
#include <stdbool.h>
typedef struct {
char[5] username;
char[10] eventname;
uint16_t daysSinceEpoch;
uint16_t startTime;
uint16_t deltaTime;
bool isADD;
} request_t;
typedef struct {
bool success;
bool hasBody;
uint16_t daysSinceEpoch;
uint16_t startTime;
uint16_t endTime;
int endTime;
uint8_t numNext;
} response_t;
Trying to achieve a protocol between the client and the server that will save as much bandwidth as possible. That means we need to pack as much information as possible into a single request, and a single response.
To make simple implementation, we set all request to have same size.
I've designed a way to pack the 2 time fields: start and end time, into a 21 bit field to be accurate to minutes.
Since all
Detailed way to encode and decode the time fields will be explained later.
That might be able to save a bit from command
, to encode things like
REMOVE
which would only require 1 time field,GET1
which would require 1 time field,GET2
which would require no time field.GETALL
which would require no time field.
That way, I'd be able to encode all possible commands:
ADD
, UPDATE
, REMOVE
, GET1
, GET2
, GETALL
into 1 bit!
(i'm not drunk when i wrote this)
That would become easier to explain once we get to the design of the time fields.
But per my design, each request would only take 128 bits (16 bytes). If we have a 128 bit computer, entire request can be put into a single register! (afaik, there isn't any such computer yet, the best they do is 128 bit SIMD)
field | len | bits field |
---|---|---|
opcode | 1 | [0] |
clTime | 21 | [1-21] |
date | 16 | [22-37] |
uname | 30 | [38-67] |
ename | 60 | [68-127] |
Note that the len
is in bits.
1 bit field, use fields from clTime
to encode additional commands.
Calendar Time, 21 bits field,
Contain both start and end time, accurate to minutes.
since some patterns will never be used as time, we can use them to encode
for commands like REMOVE
, GET1
, GET2
, GETALL
.
16 bits field (this is the result of saving bits from here and there)
16 bits would support a range of 65536 calendar days, which is about 180 years.
More than enough for anyone to use (unless timetravellers)
Set epoch to 1900-01-01, and we can support dates from 1900 to 2080.
User input fields, each varchar takes 6 bits, so 30 and 60 bits respectively.
The username and eventname are encoded as varchars.
Using entire ascii table is such a waste, there are too many characters that no one would ever ever going to use. who put emojis or emoticons in their stuff anyway? ╮(╯▽╰)╭
For fields that are supposed to take user-input, I've decided to use only [A-Za-z0-9-_] characters, which are:
- 10 digits
- 26 uppercase letters
- 26 lowercase letters
- 2 special characters:
-
and_
That's 64 characters in total, which can be encoded into 6 bits.
table of conversion:
charType | ordinals | encode formula | decode formula |
---|---|---|---|
digit 0-9 | 0-9 | x = c - '0' | c = x + '0' |
upper A-Z | 10-35 | x = c - 'A' + 10 | c = x + 'A' - 10 |
lower a-z | 36-61 | x = c - 'a' + 36 | c = x + 'a' - 36 |
spcl - |
62 | x = 62 | if x == 62 |
spcl _ |
63 | x = 63 | if x == 63 |
That way, we group every 4 characters into 3 bytes, versus 4 ordinary bytes, would be 25% reduction in size.
Because I gets to decide, I'll limit:
username
to 4 characters (original plan was 6, but I changed my mind so date can have 2 extra bits)eventname
to 10 characters.
Yeah, English is a language that is redundant and verbose, The cardinality of the set of English vocabulary is much wasted, if you compare it to the cardinality of the set of all possible words.
where
(╯°□°)╯︵ ┻━┻) I'm not letting you waste my bandwidth, Don't trying to write a novel in the eventname field.
Go get yourself a hash table if you can't manage to fit your event names.
Date is encoded as 16 bits field.
Set epoch to 1900-01-01, we can support dates from 1900 to 2080.
If I have time, it should follow the rules of the Gregorian calendar, including leap years.
For now, just assume every month has 30 days.
This is the fun part.
At most, we'd need 2 time fields: start and end time. (sometimes just 1 time)
To encode HHMM time, it would need
But, there is a catch: the endTime
can only be a time after startTime
.
Thinking about deltaTime
instead of endTime
, then deltaTime
would have
Another thing that makes our life easier is that, since to encode time need
startTime
takes more than 10 bits,
(more than deltaTime
would be
(because this way makes the math eaiser (I won't explain what if the althernative))
startTime
would be encoded in inversedMinutes, that mean, a value of
Use the highest (20th) bit to indicate if that inversedMinutes exceeds 1024 or not.
So, inversedMinutes <=> [20|(20?8:9)-0]
(if 20th bit is 1, then 9 bits (8-0) are used, otherwise 10 bits (9-0) are used)
We conclude that, in case that the inversedMinutes is less than 1024, it would be sufficient to use 10 bits to encode the deltaTime
.
if [20] LOW
, then deltaTime
<=> [19-10]
, startTime
<=> [9-0]
In this case, exceeds
<=> [20] HIGH
and the rest of the bit would be have
values at most [8-0]
.
Which, leaves room for deltaTime
to be encoded in 11 bits [19-9]
.
if [20] HIGH
, then deltaTime
<=> [19-9]
, startTime
<=> [8-0]
inversedMinutes <=> [20|(20?8:9)-0]
deltaTime <=> [19-(20?9:10)]
there are some patterns that would never be used as time, for example,
when exceeds
is HIGH
, then 9-bit low bits field [8-0]
would at most
have a value of [19-9]
to encode
the single time field.
REMOVE date start-time
Need 1 time field.
exceeds
is HIGH
, then [8-0]
set to 1 1111 1000
The time field would be encoded in [19-9]
as numbers of minutes from 00:00.
GET date start-time
Need 1 time field.
exceeds
is HIGH
, then [8-0]
set to 1 1111 1001
The time field would be encoded in [19-9]
as numbers of minutes from 00:00.
GET date
Need no time field.
exceeds
is HIGH
, then [8-0]
set to 1 1111 1010
The bit field [19-9]
needs to be set all set to
GETALL
Need no time field.
exceeds
is HIGH
, then [8-0]
set to 1 1111 1111
The bit field [19-9]
needs to be set all set to
command | [20] |
[19-9] |
[8-0] |
---|---|---|---|
REMOVE | 1 | min-after 00:00 | 1 1111 1000 (504) |
GET1 | 1 | min-after 00:00 | 1 1111 1001 (505) |
GET2 | 1 | fill with 1 |
1 1111 1010 (506) |
GETALL | 1 | fill with 1 |
1 1111 1111 (511) |
to have [20] HIGH
and [8-0]
The opcode is a 1 bit field, which is used to encode command types.
- If
opcode
is0
, then it's anADD
command with 2 time fields. - If
opcode
is1
, and if theclTime
is not a special pattern, then it's anUPDATE
command with 2 time fields. - If
opcode
is1
, andclTime
is a special pattern, then it's a special command.
Therefore, I define each request to be 128 bits (16 bytes) in length.
Lol, the amount of work to implement this would be making me killing myself 💀
Depending on the command, the server would respond with different payloads of data.
For commands that is analogous to a DML command in SQL, the server would simply respond with a single status code.
This would be the case for ADD
, UPDATE
, REMOVE
commands.
The status code would be 1 byte (shortest possible payload).
Including the information about what type of command was executed.
For command that is analogous to a DQL command in SQL, the server would respond with a status code followed by a list of events.
The single status code would have a part of the payload that, if on success, notifies the number of events that are returned (size).
Each event would be 60 bits in length, use 4 additional bits to pad to 64 bits -> 8 bytes.
Since there are only 4 bits for the size data, the client would at most take 15 events at a time. However, this is not anywhere near being useful.
So the end of the last event's padding would be used to indicate if there are more events to be fetched. And so on (kind of like a linked list).
Header (read 32 more) -> ... -> 32th event (read 32 more) -> ... -> last event (no more)
So this way the server can send as many events as it wants (and order is preserved).
We pack each response into a 104 bits (13 bytes) payload.
field | len | desc |
---|---|---|
[103] |
1 | success? |
[102] |
1 | has body? |
[101-81] |
21 | clTime |
[80-65] |
16 | date |
[64-5] |
60 | event |
[4-0] |
5 | more? |
success?: 1 bit, 1 on success, 0 on failure.
has body?: 1 bit, 1 if there is a body in this response. 0 if not
0 is used for res to ADD
, UPDATE
, REMOVE
commands.
In this case, the rest of the payload is required to be 0.
clTime: 21 bits, same as in request.
date: 16 bits, same as in request.
event: 60 bits, same as in request.
more?: 5 bits.
This is useful in the case of GETALL
command, to indicate if there are
more events to be fetched.
These 5 bits can represent up to 31 more events to be fetched, so will
know how many more bytes to read.
If there's more than 31 events to be fetched, then the end of the last
event's padding would be used to indicate to read
In case of a non-tail event of the response, this field is required be 0.
There is a special case for ADD
and UPDATE
commands to handle the case
where the the failure is due to the event has conflicting time with an existing
event. Hence the top 2 bits: success?
and has body?
would be [0|1]
.
This is a failure with body, and the rest of the payload would be the
conflicting event.
yea... An alternative approach would be first send a header of success and size, then send the events in the following payload. But that would limit the size to 127 events, have the it would be quite inconvenient to get the bytes to be aligned. So I'm happy with this approach where each response is guaranteed to be 13 bytes in length.