How to manage stored state

Data stored on a charm instance will not persist beyond the current Juju event, because a new charm instance is created to handle each event. In general, charms should be stateless, but in some situations storing state is required. There are two approaches (outside of using a database or Juju storage): storing state in the charm machine or (for Kubernetes charms) container - for state that should have the same lifetime as the machine or container, and storing state in a Juju peer relation - for state that should have the same lifetime as the application.

Storing state for the lifetime of the charm container or machine

Where some state is required, and the state should share the same lifetime as the machine or (for Kubernetes charms) container, ops provides ops.StoredState where data is persisted to the ops unit database in the charm machine or container.

[caution] Note that for Kubernetes charms, container recreation is expected: even if there are no errors that require the container to be recreated, the container will be recreated with every charm update. [/caution]

[note] In Kubernetes charms that use the older ‘podspec’ model, rather than the sidecar pattern, or when the use_juju_for_storage option is set, this data will be stored in Juju instead, and will persist for the life of the application. Avoid using StoredState objects in these situations. [/note]

A StoredState object is capable of persisting simple data types, such as integers, strings, or floats, and lists, sets, and dictionaries containing those types. For more complex data, serialise the data first, for example to JSON.

Implement the feature

To store data in the unit state database, in your src/charm.py file, add a StoredState object to your charm class – this is typically called _stored. You then need to use set_default to set an initial value; for example:

class MyCharm(ops.CharmBase):

    _stored = ops.StoredState()

    def __init__(self, framework):
        super().__init__(framework)
        self._stored.set_default('expensive_value', None)

See more: ops.StoredState

Now, in any event handler, you can read or write data to the object you are storing, and it will be persisted across Juju events.

def _on_start(self, event: ops.StartEvent):
    if self._stored.expensive_value is None:
        self._stored.expensive_value = self._calculate_expensive_value()

def _on_install(self, event: ops.InstallEvent):
    # We can use self._stored.expensive_value here, and it will have the value
    # set in the start event.
    logger.info("Current value: %s", self._stored.expensive_value)

Test the feature

You’ll want to add unit tests.

For integration tests: stored state isn’t a feature, it’s functionality that enables features, so your integration tests that make use of the stored state will verify that it works correctly. There are no special constructs to use in an integration test: just trigger multiple Juju events.

Write unit tests

Add StoredState objects to the State with any content that you want to mock having persisted from a previous event. For example, in your tests/unit/test_charm.py file provide a _stored attribute that has a ‘expensive_value’ key:

def test_charm_sets_stored_state():
    ctx = testing.Context(MyCharm)
    state_in = testing.State()
    state_out = ctx.run(ctx.on.start(), state_in)
    ss = state_out.get_stored_state("_stored", owner_path="mycharm")
    assert ss.content["expensive_value"] == 42

def test_charm_logs_stored_state():
    ctx = testing.Context(MyCharm)
    state_in = testing.State(stored_states={
        testing.StoredState(
            "_stored",
            owner_path="MyCharm",
            content={
                'expensive_value': 42,
            })
    })
    state_out = ctx.run(ctx.on.install(), state_in)
    assert ctx.juju_log[0].message == "Current value: 42"

Storing state for the lifetime of the application

To store state for the lifetime of the application, add a peer relation and store the data in the relation databag.

Implement the feature

Define a peer relation

Update the charmcraft.yaml file to add a peers block, as below:

peers:
  charm-peer:
    interface: my_charm_peers

Set and get data from the peer relation databag

In your src/charm.py file, set and get the data from the peer relation databag. For example, to store an expensive calculation:

def _on_start(self, event: ops.StartEvent):
    peer = self.model.get_relation('charm-peer')
    peer.data[self.app]['expensive-value'] = self._calculate_expensive_value()

def _on_stop(self, event: ops.StopEvent):
    peer = self.model.get_relation('charm-peer')
    logger.info('Value at stop is: %s', peer.data[self.app]['expensive-value'])

[caution] Peer relations are not available early in the Charm lifecycle, so you’ll need to wait until later events, like start, to store and retrieve data. [/caution]

Test the feature

You’ll want to add unit tests.

For integration tests: stored state isn’t a feature, it’s functionality that enables features, so your integration tests that make use of the stored state will verify that it works correctly. There are no special constructs to use in an integration test: just trigger multiple Juju events.

Write unit tests

In your tests/unit/test_charm.py file, add tests that have an initial state that includes a ops.testing.PeerRelation object.

def test_charm_sets_stored_state():
    ctx = testing.Context(MyCharm)
    peer = testing.PeerRelation('charm-peer')
    state_in = testing.State(relations={peer})
    state_out = ctx.run(ctx.on.start(), state_in)
    rel = state_out.get_relation(peer.id)
    assert rel.local_app_data["expensive_value"] == "42"