Creating and using relational automata

We're now going to see how relational automata are created and used. While we will focus here on how to do that in Cell, as opposed to doing it from the host language in mixed-language applications, all the commands we're going to describe have close equivalents in the interface of the generated code.

Automata of either type can only be created inside procedures and passed as arguments to other procedures, but not functions. A procedure can declare any number of automaton variables, but it cannot create them dynamically: all automaton variables are instantiated when a procedure is called, and automatically destroyed when it returns.

In order to see how to use relational automata, we'll write a tiny command line tool that is actually useful in practice. Our application will create a relational automaton, read an initial state for it from a file and a list of messages from another, send each message to the automaton instance in the given order, and save its final state in another file. We'll make use of the Counter automaton we saw in the previous chapters, but the code can be made to work with any automaton type with only trivial changes.

Int Main(String* args) {
  instance : Counter;

  // Checking the argument list
  if |args| != 3 {
    Print("Invalid arguments\n");
    return 1;
  }
  init_state_fname, msg_list_fname, final_state_fname = args;

  // Loading the initial state
  ok = Load(instance, init_state_fname);
  if not ok {
    err = Error(instance);
    Print("Attempt to load the initial state of the automaton failed\n");
    if err != ""
      Print(err & "\n");
    return 1;
  }

  // Reading and checking the message list
  res = ReadValueFromFile(msg_list_fname);
  return 1 if res == nothing;
  msg_list = value(res);
  if not msg_list :: CounterMsg* {
    Print("Invalid message list\n");
    return 1;
  }

  // Sending all messages in the list
  for msg @ i <- msg_list {
    ok = Send(instance, msg);
    if not ok {
      err = Error(instance);
      Print("Processing of message number " & _print_(i) & " failed\n");
      if err != ""
        Print(err & "\n");
      return 1;
    }
  }

  // Saving the final state
  ok = Save(instance, final_state_fname);
  if not ok {
    err = Error(instance);
    Print("Could not write to file " & final_state_fname & "\n");
    if err != ""
      Print(err & "\n");
    return 1;
  }

  return 0;
}

Maybe[Any] ReadValueFromFile(String fname) {
  res = FileRead(fname);
  if res == nothing {
    Print("Cannot read file " & fname & "\n");
    return nothing;
  }

  res = _parse_(string(value(res)));
  if failed(res) {
    Print("File " & fname & " does not contain a valid Cell value\n");
    return nothing;
  }

  return just(result(res));
}

The first line in the body of Main(..) is the declaration of the automaton variable instance, which looks like an ordinary variable declaration. Automaton variables are instantiated and initialized as soon as the procedure that hosts them is called, and their initial state is the one that is provided with the schema declaration, which in the case of Counter is just (value: 0, updates: 0).

The first step is to load an arbitrary initial state for counter from the given file, using the built-in procedure Load(..):

ok = Load(instance, init_state_fname);

Load(..) takes two arguments, an automaton variable and the name of the file to load the new state from. The file has to contain a valid state for the automaton in question in the standard text format that is used to represent any type of value in Cell. It returns a boolean value indicating whether it was successful or not. It can fail for a variety of reasons, such as the failure to open or read the content of the file, the fact that its content is not a valid (textual representation of a) Cell value or the fact that such a value is not a valid state for the automaton in question.

If loading the new state fails, the automaton retains whatever state it had before, and remains fully functional. Also note that Load(..) can be called at any time (and any number of times) during the lifespan of the automaton instance.

The next step is to load the list of messages from another file and send them in the given order to instance using a third built-in procedure, Send(..):

ok = Send(instance, msg);

Just as before, the return value of Send(..) indicates whether the message was processed successfully. Also note the msg_list :: CounterMsg* check, which is required to verify that whatever data was loaded from the input file was indeed a valid sequence of messages for Counter.

When either Load(..) or Send(..) fails, you may need to figure out what went wrong. That's what the Error(..) builtin procedure is for:

err_msg = Error(instance);

Error takes as argument an automaton instance, and returns a string explaining why the last operation performed on it failed (if the last operation was successful, it just returns the empty string).

Once all messages have been processed, the final state of the automaton is saved to a third file using another built-in procedure, Save(..) which is the opposite of Load(..):

ok = Save(instance, final_state_fname);

Sometimes you need to take a snapshot of the state of an automaton, but you want to have it returned as an ordinary value, instead of saving it to a file. That's what Copy(..) is for:

state_copy = Copy(instance);

Similarly, you may want to set the state of an automaton by directly providing the new state, instead of loading it to a file, which can be done using another builtin procedure, Set(..):

ok = Set(instance, new_state);

If new_state is not a valid state for the automaton in question, instance will be left untouched and the return value will be false, just like with Load(..).

Note that both Copy(..) and Set(..) are expensive operations, as they need to make a physical copy of most of the data structures involved, as opposed to (in the case of Copy(..)) just returning a reference to them.

If you're not concerned about efficiency, the following snippet of code can be used to check if sending a particular message will succeeds without permanently altering the state of the automaton instance it is sent to:

// Saving the initial state of the automaton
state_copy = Copy(instance);

// Sending the message to see if it succeeds or fails
succeeded = Send(instance, msg);

// Restoring the initial state. This call always succeeds
ok = Set(instance, state_copy);

Persistence and schema changes

One advantage of having a structural data model and type system is that it becomes a lot easier to manipulate data whose exact structure is unknown. An example is the _parse_(..) built-in function used in ReadValueFromFile(..) above: it can reconstruct any value from its textual representation, even if its type is unknown. Such a function cannot be implemented in most statically typed languages, which usually don't provide a way to create a value of a type (or an object of a class) that doesn't exist, or doesn't exist anymore.

That comes in handy when trying to reconstruct a copy of an automaton instance from its serialized form. As your application evolves, and the schemas it defines change with it, sooner or later you'll have to load the serialized state of an old version of a relational automaton into the new one. The two versions will be incompatible, in the sense that a value that is a valid state for either of them will not be a valid state for the other.

How do you deal with this problem in Cell? The first line of defense here is the structural data model. Even if the schema definition has changed or it has been renamed, and even if some of the types inside it are gone you'll still be able to easily reconstruct the value of its state.

Then you'll need to convert the old state into a new one. There's no magic bullet here unfortunately, but there are a number of schema changes the persistence layer of Cell can deal with automatically:

  • If the new version of the schema contains a member variable that was not present in the old one, that variable is automatically initialized to its default value provided with its declaration.
  • A new mutable relation variable will be left empty. That means, for example, that you can add new entities, relationships and optional attributes to existing schemas without losing backward compatibility with your saved data
  • Any member variable or mutable relation variable that was removed from the schema is simply ignored.

Another type of change that is not yet supported but which poses no conceptual problem and will be implemented soon(-ish) is the addition of a mandatory attribute that has a default value, which is almost the same thing as an optional attribute.

Other types of changes cannot be dealt with automatically and require the developer to provide the compiler with some extra information. The only option right now is to write a function that takes the value of the old state and converts into the new one. Other conversion mechanisms will be added at some point in the future: they probably won't save much effort compared to writing an explicit conversion function, but they might make the process more efficient in terms of memory usage, which would be an important advantage when dealing with large dataset or when operating in memory-constrained environments.