Databases are great for data processing and storage. However, in many cases it is better or easier to work with data in files on a file system, some tools even cannot access the data in any other way. When a database (DB) is created in a database management system (DBMS) using a file system as its data storage, it of course uses files on the given file system to store the data. But working with those files outside of the DBMS, even for read-only access to the data stored in the DB, is practically impossible. So what can be done if some setup requires data in files while at the same time, the data processing and storage requires a use of a DB(MS)? The answer is synchronization between two storage places – a DB and files. It can either be from the DB to the files where the files are then treated as read-only for the parties working with the data, or with modifications of the files being synchronized to the DB. In the former setup, the DB is the single source of truth – the data in the files may be out of sync, but the DB has the up to date version. In the latter setup, the DB provides a backup or alternative read-only access to the data that is primarily stored in the files or the files provide an alternative write-only access to the DB. A two-way synchronization and thus a combination of read and write access in both places, the DB and the files, should be avoided because it's very hard (one could even say impossible) to properly implement mechanisms ensuring data consistency. Both between the two storages, but even in each of them alone.
Synchronizing data to JSON files
In CFEngine Enterprise, we needed to provide file-based access to
host-specific data stored in our PostgreSQL database. Users define the
host-specific data in our web user interface (Mission Portal), but the
individual hosts expect their specific host-specific.json
files on the
CFEngine hub's file system. This could have been achieved by extending
the web UI code to write the data to both places, but we enforce
privilege separation and don't want the web UI code to have access to
CFEngine policy, the part of which the host-specific data is. And
because we already needed a daemon reacting to changes and events in the
PostgreSQL database, we used techniques described in a previous blog
post
to implement a synchronization of the host-specific data between the DB
and the respective JSON files. The daemon is called cf-reactor and it
is written in C. Let's take a look at how it works and what it does.
Listening to notifications from PostgreSQL
The cf-reactor daemon needs to know when some JSON file with
host-specific data needs to be updated with the new data from the
PostgreSQL database. The data in question is in the following __cmdb
table:
CREATE TABLE IF NOT EXISTS __cmdb (
hostkey text PRIMARY KEY,
value jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
epoch bigint NOT NULL DEFAULT 0
);
cf-reactor could check and compare the last modification timestamp of
each of the JSON files with the last modification timestamp of the
respective row (updated_at
) in the table storing the host-specific
data. Another thing that it would need to do is to check if there is
some new data, for hosts that had no host-specific data before. And this
is actually how things used to be implemented before cf-reactor was
introduced – CFEngine policy on the hub periodically compared the
timestamps, updated the files that were out of date, and created new
files for hosts which the user defined host-specific data for since the
last check. It was a working solution, but since an agent run evaluating
the policy only happens every 5 minutes (by default), it took up to 5
minutes for the changes made in the __cmdb
table to propagate to the
respective host-specific JSON files.
Instead of using the same approach as described above, potentially with
a shorter interval for the periodic checks, cf-reactor uses the
technique described in a previous blog
post
– asynchronous notifications sent by a trigger. Whenever a new row is
added to the __cmdb
table (INSERT
) or an existing row is updated
(UPDATE
), it receives a notification through the cmdb_refresh
channel and knows it needs to synchronize the host-specific.json
file
for the particular host (identified by the hostkey
column) with the
data in the database. This is done by the following SQL commands in our
schema:
CREATE FUNCTION update_cmdb_data_file()
RETURNS TRIGGER AS $$
BEGIN
EXECUTE 'NOTIFY cmdb_refresh, ''' || NEW.hostkey || '''';
RETURN NULL; -- result is ignored since this is an AFTER trigger
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_cmdb_data_file
AFTER INSERT OR UPDATE ON __cmdb
FOR EACH ROW EXECUTE FUNCTION update_cmdb_data_file();
To be able to listen to the notifications sent by the trigger defined above and react on them, cf-reactor does this: (in pseudo-code)
conn = PQ_Connection(db="cfdb")
while not terminate:
wait_for_new_notifications()
while PQ_have_notifications():
notif = PQ_next_notification()
hostkey = notif.payload
UpdateHostSpecificJSONFile(hostkey)
C code to work with notifications
The wait_for_new_notifications()
function call above may look like
cf-reactor is constantly polling PostgreSQL for new notifications
creating a heavy load on the system. However, the real implementation
using C actually uses the poll()
function (system call) which,
contradictory to its name, provides an efficient way of waiting for resources
being available. Simply put, the poll()
function takes a set of file
descriptors plus a timeout specification and only returns when one or
more of the given file descriptors are ready for the specified operation
(read, write), if an error occurs, or if the given timeout expires.
While waiting, the process making the poll()
system call is put out of
the queue of processes waiting to be executed on the CPUs and so such
processes consume little to no resources.
The real code then looks similar to the following snippet (with error and result checking omitted):
#define TIMEOUT (60 * 1000) /* milliseconds */
bool terminate = false;
PGconn *conn = PQconnectdb("dbname=cfdb");
int sock = PQsocket(conn);
PGresult *res = PQexec(conn, "LISTEN cmdb_refresh");
PQclear(res);
struct pollfd sock_poll = { .fd = sock, .events = POLLIN, .revents = 0 };
int ret;
while (!terminate) {
ret = poll(&sock_poll, 1, TIMEOUT);
if (ret > 0) {
if ((sock_poll.revents & (POLLERR|POLLHUP|POLLNVAL)) != 0) {
/* some error on the socket, try to reconnect */
PQfinish(conn);
conn = PQconnectdb("dbname=cfdb");
sock = PQsocket(conn);
sock_poll.fd = sock;
continue;
}
PQconsumeInput(conn);
PGNotify *notify;
while ((notify = PQnotifies(conn)) != NULL) {
assert(StringEqual(notify->relname, "cmdb_refresh");
printf("CMDB refresh request received from PID %d: '%s'\n",
notify->be_pid, notify->extra ? notify->extra : "");
PQfreemem(notify);
PQconsumeInput(conn);
}
} else if (ret == 0) {
/* handle timeout */
}
else {
/* handle error (can be EINTR because of signal received) */
}
}
PQfinish(conn);
Of course, instead of just using printf()
to inform about the received
CMDB refresh request, the real code extracts the hostkey from the
notification's payload (notify->extra
), executes an SQL query to get
the JSON data (value
) for the given host and stores the data in the
respective file and directory. The data is already stored as JSON in the
DB so it’s ready to be written, but this code could easily be expanded
to transform the data or even combine data from multiple tables.
Timing of the events
Our previous blog post describes in which cases and when notifications are sent by triggers like the one defined above and emphasizes that the notifications are asynchronous. So synchronization of the data from the DB to files implemented using such notifications is not synchronous. What does this beautiful sentence mean in practice? First of all, there's no guarantee that when something reads the respective JSON file after a change of the data in the DB, it will get the same data. In other words, there are periods of time when the data in the DB differs from the data in the respective files. And when the data for a host is added to the table (or deleted from it), even the existence of the respective file is not guaranteed. On the other hand, as we can see in the following screencast, the timing is in reality very fast, just not guaranteed to be fast enough for real-time restrictions. For our use case in CFEngine Enterprise it is good enough because we only need the future agent runs on the respective hosts to have their host-specific data files up to date and available on the CFEngine hub.
The above screencast shows a couple things. First, when cf-reactor
starts, it checks whether the data in the JSON files is up to date
because some modifications of the data in the DB could be done (and
were done, in this case) while it was not running. It sees that the
JSON file's last modification timestamp is older than the updated_at
timestamp in the DB so it updates the file contents. Then, when another
modification of the data in the DB is done by changing the value of a
CMDB variable in Mission Portal (web UI), it gets a notification and
updates the contents of the file again. As the timestamps demonstrate,
the reaction is very fast. The Updating CMDB data file… log message
has the same timestamp in seconds as the updated_at
timestamp in the
DB and as the last modification timestamp of the respective JSON file.
So the update of the file happens in the same second in which the user
submits the new value. And if we look closer at the updated_at
and the
file's last modification timestamps we can see that the update of the
file's contents happens in less than 20 ms after the change in the DB.
The update is asynchronous and window of time when the file contents
are out of date, is less than 20 ms, which is much more than good
enough for our use case.
Timeouts
The C code example above has the following block:
} else if (ret == 0) {
/* handle timeout */
}
A timeout from the poll()
function/system call should
be handled. It actually doesn't have to be handled and the timeout
interval given to the poll()
function can be a negative number meaning
infinite timeout. However, this timeout is actually a very useful
mechanism for throttling – controlling when and how often things
happen. For example, if we knew that there would be many changes of the
data in the DB in a short sequence, it would make more sense to only
synchronize the data into the respective file after a quiet period – a
period of time with no changes of the data. Or if we knew that nothing
will read the data file until a specific time, we could avoid updating
the file on every change of the data in the DB and only do it right
before the data file is read. If the reactions to the events
(notifications) are resource-intensive, they could even be held off
until the system load drops below a certain level. And so on, there are
many options. And since the implementation of the code handling the
notifications can use an arbitrary language and run in an arbitrary
environment, there are basically no limits. However, all these
approaches require that the code knows that there were no notifications
for some time and that's what the timeout argument given to the
poll()
function ensures. The value of it doesn't have to be a static
hard-coded one, it can be a dynamic value set by the code processing the
notifications because that code knows when it will need to continue
doing things due to past notifications.
Conclusions
This blog post describes how PostgreSQL's asynchronous notifications sent by triggers can be used to synchronize data from a database into a set of JSON files. In CFEngine Enterprise, this is a job of the recently introduced cf-reactor daemon which is also responsible for other tasks, reacting to other types of events and requests. Generally, asynchronous notifications can be used for replacing mechanisms using periodic checks and updates with more event-driven implementations saving resources while making changes propagate faster, more interactively and more efficiently. We definitely plan to extend the area of tasks and mechanisms in CFEngine Enterprise using this approach to make our product better, both regarding the improved more interactive user experience, and regarding lower system resource usage.