DEV Community

Cover image for Time traveling with Fluree
Trey Botard for fluree

Posted on

Time traveling with Fluree

image

Intro

When you get down to it, if you are building an app with Fluree as the backend, it is simplest to think of Fluree as a database. This can be a useful way to think about working with Fluree, but by doing so there is a lot being left on the table. The unique combination of technologies which make Fluree what it is enable some extremely powerful functionality and unlock ways of working with data which are either uncommon or simply not possible with other data stores. Let's talk about what some of those are, how to use them, and what this type of functionality could enable in your Fluree-backed application.

Background

In addition to a graph database for querying data, Fluree is built with an immutable ledger as the backbone which holds the dataset. This part of Fluree is what enables some really interesting and particularly unique functionality. 'Immutable ledger' is one of those terms which I had to Google in order to understand when I started at Fluree, so let's break that down a bit.

Fluree associates related data elements, called subjects. Each subject has an _id which is used to correlate the attributes (called predicates) and the values of those predicates together to form the "facts" about that subject.

Fluree is based on an extended version of the W3C standard for RDF, which is where this notion of SPO (Subject, Predicate, Object) comes from.

You can think of it like a row in a db table with _id being the unique identifier for the row, predicates are the columns, and the values are the fields. Each field makes up a fact about the instance of data stored in that particular row in the db. For example, in a Dog table with a Breed column, each row corresponds to a unique Dog who is described by the attributes and fields. The same idea holds in Fluree. An _id groups the related predicates, which point to values, in order to make up the "facts" of that subject. So, the dog/breed predicate would point at an object, "french bulldog", for example. At the point in time when that fact was written to the ledger, that specific subject's breed was french bulldog.

Each of these facts are stored in an immutable data structure. Immutable means that those data structures are not available to be modified or changed in any way. Instead of simply changing a value or updating a "row" in the data, Fluree will make a new true fact in the ledger and associate it with the appropriate subject. If this is a value which is being "modified" then Fluree will make two new facts; one where the old fact is false and the second a new, true fact, both of these facts are then associated with the subject and written to the ledger.

This is part of the "extension" to RDF. Each flake contains a boolean which indicates whether it is true or has been falsified. You can read more about this in the flakes page in the docs.

This brings us to what a ledger is. You can think of a ledger as discrete units or "blocks" which contain the history of the data as it is transacted. These blocks are made of groups of immutable facts which are sent to an instance of Fluree. Each block is linked to the one which came before it so there is a chain of blocks from when the ledger was created to the current block. In Fluree, this chain is queryable, which means once some data has been transacted to Fluree you have the history of every data element in that data set!

image

Time travel

So, back to those powerful pieces of functionality I mentioned at the beginning.
There are two ways of querying the data which enable what we call time travel in the Fluree world. There are block queries and history queries, both unlock elements of Fluree which are only possible because of the immutable data structures and the ledger. Block queries enable querying the data state at specific moments in time and history queries allow you to get an overview of all of the modifications to a particular subject.

image

Why

We'll get into how each of these types of queries work, but first, why does this even matter?
One of the primary benefits to having this type of view into your data is the ability to correlate events with the data state at the time that event happened. For example, say you are tracking prices for flights and you want to see what effect the weather had on flight prices or which day of the week prices tended to be the cheapest. The sky's the limit for these types of analytical queries.
You also may want to enable your users to see the state of some piece of data when it was updated. I saw a comment on a LinkedIn post once and was pretty sure that the commenter worked for the company who's post he was commenting on, but his current job title was recently updated so I couldn't tell where he worked when the comment was added, only where he currently worked - the current state of the data.
This type of functionality can be useful in a wide range of circumstances or situations. Having a way to view not only the current state of the data (table stakes for any database), but a way to see the state of a piece of data at a specified time OR for a specified range of time, can be extremely useful. Fluree goes a step further though. When you are querying some data a point in time, you are also seeing all of the facts which were true at that time as well. This includes all of the relationships which existed at the time. This is something which is not possible in any other database or data store that I am aware of. You are able to query not only the historical values of something in your data but also all of the context associated with that data as well. That is huge.

Now, think about how you would go about making something like this in your db of choice. Building out a historical view of a table in a traditional database, whether in a relational or NoSQL db, is a large and expensive maintenance burden, the size of your db will explode because of data duplication without significant optimization, and querying these db tables or documents can become relatively complex; specifically what happens to references? Does the reference point to the current table or is there a way to manage the reference such that it points to the correct row in the historical table? What happens when you want to do a join to with another table? There isn't an expedient or simple way to do either of those things, to my knowledge. One or two other data stores enable historical views, but are not able to pull in all of the context and maintain relationships as well.

Lying_down_working

Putting it all together

Both of these operations are exposed via an API within a Fluree db instance. Simply passing a JSON to the /block or /history endpoint is all that is needed to query this type of data. Let's get into how to use each of these queries. I will be using the Fluree Query Language (FlureeQL), which is a JSON-based way to query the backend. Fluree also supports querying via GraphQL, SPARQL, SQL or calling these endpoints directly from Clojure, but we'll use FlureeQL to illustrate this functionality. If you want to read more about our query surfaces, check out the query pages for more details.

Blocks

Block Queries

There are two ways to query a block in Fluree. You can either issue a query against the /block endpoint which returns the flakes in that particular block or range of blocks, or you can add a "block" key to a basic query issued to the /query endpoint. This basic query method of querying can, and probably will, pull in facts which were transacted to the ledger before the specified block. When you issue a regular query with a block key, you are issuing a query as if the specified block were the current block.
Each of these types of query is beneficial, and can be useful depending on how you need to view your data.

Let's start with a query issued to the /block endpoint. This type of query currently supports 2 keys:

  "block": number,
  "prettyPrint": boolean
Enter fullscreen mode Exit fullscreen mode

"prettyPrint" is a boolean, which if true, prints the results in a pretty printed, aka styled format, for easier reading, as well as separating the asserted and retracted flakes into their own arrays in order to make them easier to parse. The "block" key is much more interesting. It can take a number, a string in the form of an ISO-8601 formatted date-time or duration, or an array which specifies a range of block for the query.

For example, to query a specific block:

{
  "block": 3
}
Enter fullscreen mode Exit fullscreen mode

You can query via a time stamp. This will return the first block which was transacted before this timestamp. In other words, it will give you the facts which were true at that time.

{
  "block": "2017-11-14T20:59:36.097Z"
}
Enter fullscreen mode Exit fullscreen mode

You can also use an ISO-8601 formatted duration:

{
  "block": "PT5M"
}
Enter fullscreen mode Exit fullscreen mode

This will return the state of the data as of 5 minutes ago.

If you would like to query a range of blocks, you can pass an array containing the blocks you would like to see. This range is inclusive, meaning the data returned will include both blocks you put in the array.

{
  "block": [5, 18]
}
Enter fullscreen mode Exit fullscreen mode

You can also pass an array with a single block which will specify a lower, also inclusive, block and return the facts from that block up to the current block.

Using the /block endpoint will return an array of flakes, each of which is a fact stored in Fluree at that block or range of blocks. While this is useful, it is probably more realistic that you would want to see a specific set of data using a normal query, but have the results returned as if they had been issued at some point in the past. This is also enabled in Fluree by issuing a query to the /query endpoint which contains the "block" key-value pair. This key expects the value to be structured in the same way as the examples above, with the value being one of a number, a formatted string, or an array of block numbers. So the main difference is that this type of query will pull in data which is not limited to a specific block, it returns data as if the query had been issued when that block was the current block. For example, if you had a Dog collection of subjects in your ledger, you could issue this query to see all of the dogs which had been transacted and not deleted as of block 7:

{
  "select": ["*"],
  "from": "Dog",
  "block": 7
}
Enter fullscreen mode Exit fullscreen mode

To read more on querying blocks, check out the docs pages for block queries and querying with the block key

Brain Graphic

History Queries

The way a /history query is structured and issued is relatively similar to /block queries, but are fairly different in what results are returned. As I mentioned above, a history query returns all of the modifications to a subject. I like to think of a block query showing the breadth of the data at a specific time and the history query as looking down the timeline of a specific piece of data.
For example, if you had a customer in your dataset who has connections to other customers, you could see the history of that customer's connections from when they first joined your application up to the current block. If you wanted to see the connections that customer had at a specific block or over a range of blocks, that is possible, as is using the ISO-8601 date-times or durations.

You can build a /history query using FlureeQL in JSON the same way you would with a /block query. For example, if you know the subject's _id you can simply hit the /history endpoint like this:

{
  "history": 351843720888320
}
Enter fullscreen mode Exit fullscreen mode

This query will return an array of objects, each object containing the block and t numbers for that block and an array of flakes for that subject.

Another option is to issue a history query with a block key in order to constrain the results of the query to a specific timeframe in your data. That looks like this:

{
  "history": 369435906932737,
  "block": 4
}
Enter fullscreen mode Exit fullscreen mode

This query will return the flakes for this _id up to block 4. You can also use a block range or use the ISO-8601 formatted string similar to the /block queries.

Using a flake format is another way you can issue a history query. This means that you can use pieces of data to identify the subject you want to query. This works via the subject, predicate, object structure of a flake. You pass the elements you want to use to query in an array as the value of the "history" key in the query JSON. The array needs to be passed as ["subject", "predicate", "object"], but you do not have to use all 3 elements in the array for the query to resolve.

Please note that the order of these within the array is important and either a subject or a predicate is required.

For example, if you want to query for the history of all subjects matching the predicate object pair dog/breed "french bulldog" in your collection, you could query the ledger like this:

{
  "history": [
    null, 
    "dog/breed", 
    "french bulldog"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Another way this could be done is using either a subject _id with a predicate, or substitute a two-tuple which uniquely identifies a subject for the _id.
That would look like this:

{
  "history": [
    351843720888320, 
    "dog/favFoods"
  ]
}
Enter fullscreen mode Exit fullscreen mode

or with a two-tuple

{
  "history": [
    ["dog/name" "Jacques"], 
    "dog/favFoods"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Both of these queries will return the history of the predicate "dog/favFoods" for the dog specified, with either the subject _id or the unique identifier of ["dog/name" "Jacques"] used to identify the subject you want to inspect.
Similar to the "/block" queries, a "/history" query can also accept a "prettyPrint" key-value pair. When true this will return the history of the subject or predicate as indicated, but will separate out the retracted and asserted flakes per block into their own arrays. That looks like this:

{
  "history": 351843720888320,
  "prettyPrint": true
}
Enter fullscreen mode Exit fullscreen mode

which will return something in this type of structure:

{
  "4": {
    "asserted": [
      {
        "_id": 351843720888320,
        "dog/breed": "french bulldog"
      }
    ],
    "retracted": []
  }
}
Enter fullscreen mode Exit fullscreen mode

In the return JSON, each block containing data which matches the query is its own labeled object containing a named array for asserted and retracted.

"showAuth"

There is one other extremely powerful way to use "/history" queries to audit the history of who transacted the data. You can issue a "showAuth" boolean key-value pair or an array of _auth/id or _auth subject _id's in order to filter the history query to specific auth record's transactions. Because each transaction is signed by a private key which is associated cryptographically with the _auth/id, every flake in Fluree contains a record of who issued that transaction. This is the way to view that data. It looks like this:

{
  "history": 351843720888320,
  "showAuth": true
}
Enter fullscreen mode Exit fullscreen mode

This will return an array of block objects, each of which will contain a named array of "auth" which consists of the auth's subject _id and the "_auth/id of the individual (man or machine) which signed that block. Which will look something like this:

[
  {
    "block": 2,
    "flakes": [
      [ 17592186044436, 40, "dog", -3, true, null ],
      [ 17592186044437, 40, "cat", -3, true, null ],
      [ 17592186044438, 40, "ferret", -3, true, null ]
    ],
    "t": -3,
    "auth": [
      105553116266496,
      "TexTgp1zpMkxJq1nThrgwkU5dp9wzaXA7BX"
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

For more information on how Fluree stores and interacts with identity and authorization, please take a look at the identity section in the docs.

Wrap it up

So that's how you can go about time traveling in Fluree. There are powerful tools which come out-of-the box which enable you to do things like query as of a specific moment in time, see how a subject evolved over time in your dataset, or get all of the data which was transacted by a specific auth record. You can read more about it in our docs site or if you would prefer to engage with our community, come join us on Slack.

For more detail about this subject, you can watch our Time and Immutability Webinar on YouTube:
This has video has a publicly available demo which you can review here:

GitHub logo fluree / time-webinar

Demo app which uses the create-react-app template for Fluree to embed a webworker with the application. This demo showcases functionality around issuing block and history queries.

Time and Immutability Webinar Demo

This is the repository used for the demo in the Time and Immutability Webinar.

Set up

To begin working with this demo app, you will need to have Fluree running locally on your machine For detailed instruction on getting Fluree installed, please visit the installation page on the docs site.

You will also need to have Node.js installed on your machine.

The data folder contains the seed data for using this application as it is shown in the webinar To get the data loaded into your Fluree instance, follow these steps:

  1. Open the Admin UI and create a ledger called time/webinar.
  2. Using either the Admin UI or a REST client of your choosing (Postman, Insomnia, etc.) transact the files in the data/ folder, in order, to your ledger
    • This will transact the schema
    • The airports and tags for the statuses
    • The flight.json files…

Top comments (0)