Writing/Metaprogramming: An Adventure
Chapter 10 · 28 min read
Runtime Serialization
We talked a lot about code manipulation, but in doing so we skipped a small but important step: serialization, the ability to encode something in such a way that we can store it, then fetch, decode, and resume its operation later. It was one of John von Neumann's greatest insights that code can, in fact, be stored as data – so entire programs, not just their inputs and outputs, can be serialized and saved into the same machine memory as everything else.
Before that, in what's known as the Harvard architecture, instructions were conceptualized as a distinct kind of information: programming required wiring plugboards of vacuum tubes or feeding punch cards into a reader. Von Neumann's architecture is what gave rise to modern programming; most of the devices we use today adhere to this new paradigm, because only by making code a modifiable medium could we conceive of such things as compilers, installable software, and general-purpose computing. Needless to say, without it we wouldn't have metaprogramming, either.
Now, Python is an interpreted language, so serializing its programs is as simple as treating their source code as strings. Sometimes, however, a program is more than just its code: whatever it does, it also does it on something, and while it may seem self-evident that data can be stored as data, it's actually not as straightforward as it sounds. You see, because the way information is laid out in memory differs from how it's encoded on a disk, some data types are more serializeable than others.
To wit, numbers can be represented in binary form, as a sequence of bits over a
fixed number of bytes – one byte for small numbers between 0 and 255, also good
enough to represent ASCII characters, and 4 or 8 bytes for ranges that suffice
for most purposes. Strings, however, are trickier, because of their variable
length: given one byte per character, how many of those bytes do we need to
read to reconstruct a particular string? In C, they decided to use a terminator:
a special nul-byte, '\0', which denotes the string is over; in Pascal, a
length prefix is used instead, with the first 4 bytes reserved for a number that
indicates how long this particular string runs. More complex data structures –
from lists and dictionaries to images and 3D videos – are all subject to the
same constraints and considerations; different formats make different decisions
with different trade-offs as to how exactly their objects should be flattened
onto a one-dimensional sequence of bits.
Pickling Objects
Conveniently, Python solves most of these challenges with the standard pickle
module: a serialization protocol uniquely tailored to Python's data model,
which is able to capture and restore most of it without us having to worry about
how. All the native types are handled out of the box:
>>> import pickle
>>> pickle.dumps({"x": [1]})
b'\x80\x04\x95\r\x00\x00\x00\x00\x00\x00\x00}\x94\x8c\x01x\x94]\x94K\x01as.'
>>> pickle.loads(_)
{'x': [1]}
And for custom objects, a simple convention allows classes to articulate their state in simpler, native terms – then reconstruct themselves from such on demand:
>>> class A:
... def __init__(self, x):
... self.x = x
... def __repr__(self):
... return f"A({self.x})"
... def __getstate__(self):
... return {"x": self.x}
... def __setstate__(self, state):
... self.x = state["x"]
>>> a = A(1)
>>> pickle.dumps(a)
b'\x80\x04\x95\x1f\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x01A…'
>>> pickle.loads(_)
A(1)
In fact, since most Python objects are just wrappers around a simple dictionary,
anyway, even this is usually unnecessary; the default implementation of
__getstate__ would just return self.__dict__, and that of __setstate__
would inject it back with self.__dict__.update(state):
>>> class A:
... def __init__(self, x):
... self.x = x
... def __repr__(self):
... return f"A({self.x})"
... # no __getstate__ or __setstate__, and yet…
>>> a = A(1)
>>> pickle.dumps(a)
b'\x80\x04\x95\x1f\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x01A…'
>>> pickle.loads(_)
A(1)
The only catch is that the class has to be importable, so Python can restore
the wrapper as well as its state – which is why you might've glimpsed __main__
and A in the serialized string above. Note that it does so without calling the
constructor directly; something along the lines of:
cls = __import__(f"{module_name}.{class_name}")
obj = object.__new__(cls)
obj.__dict__.update(state)
Advanced Pickling
Still, __getstate__ and __setstate__ can come in handy on some occasions: as
I mentioned, not all data is serializeable out of the box. Files, for instance,
or sockets, or any other "I/O objects" that represent a live, temporary handle
to some external resource – those things can't really be described by a static
sequence of bytes. So if we have:
>>> class A:
... def __init__(self, path):
... self.path = path
... self.file = open(path)
We need to account for the file explicitly:
>>> class A:
... # … same as before
... def __getstate__(self):
... state = self.__dict__.copy()
... del state["file"]
... return state
... def __setstate__(self, state):
... self.__dict__.update(state)
... self.file = open(self.path)
In other cases, object construction is more complicated still: a singleton class, for example, should always have one instance at most:
>>> class A:
... _instance = None
... @classmethod
... def get(cls):
... if cls._instance is None:
... cls._instance = cls()
... return cls._instance
>>> # use a = A.get() instead of a = A()
Pickling-then-unpickling this instance would invariably create a new object,
and hijacking __setstate__ would not help either, because by the time it's
called the rogue object will have already been created. So instead, we can use
__reduce__: it returns a string (to import a global), or a tuple with a
callable and its arguments, to construct the object in a non-standard way. This
tuple can also include a state to be injected afterwards, as well as a couple of
iterables to aid in the creation of list-like and dict-like objects, whose state
is a sequence of items rather than an attributes dictionary. In our case,
however, the basic pair will suffice:
>>> class A:
... # … same as before
... def __reduce__(self):
... return self.get, ()
>>> a1 = A.get()
>>> pickle.dumps(a1)
b'\x80\x04\x956\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x07getattr…'
>>> a2 = pickle.loads(_) # calls A.get()
>>> a1 is a2
True
In fact, an even simpler way to have a singleton is by using a unique, global variable to begin with – so we could have done:
>>> class A:
... def __reduce__(self):
... return "a"
>>> a = A() # only ever use this
>>> pickle.dumps(a)
b'\x80\x04\x95\x12\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x01a…'
>>> a2 = pickle.loads(_)
>>> a is a2
True
Minute details aside, the point I'm trying to illustrate is that Python has a
builtin way to take care of serialization for us, and a few handy hooks ready
for whenever it might fall short. This means almost any Python program can be
flattened into a sequence of bytes that can be written to a file or sent over
the wire, only to resume operation at a different time or on a different
machine – all with very little effort on our part. Incidentally, this is
precisely how the standard multiprocessing library distributes shared
functions and values across separate processes – but, as always, there are some
interesting caveats to address.
Pickle Limitations
One such is a warning that is often mentioned, about how we should never run (that is, "unpickle") payloads from an untrusted source. The protocol's dynamicity, which makes it so simple and flexible all at once, also means that a hacker can craft an object in such a way that, when deserialized, it will execute some arbitrary code in our program.
Refer back to the first singleton example, where we used __reduce__ to replace
the standard object construction with a custom method; see how its pickled data
contains builtins and getattr amidst the noise? That's because its
reconstruction would look like this:
>>> a = builtins.getattr("A", "get")()
That is, to serialize the construction method A.get, Python has to resort to
a more basic mechanism that would let it reconstruct it before it can be used
for further reconstruction. If we were to do this instead:
>>> class A:
... def __reduce__(self):
... return print, ("hacked!",)
>>> a = A()
>>> pickle.dumps(a)
b'\x80\x04\x95$\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x05print…
\x94\x93\x94\x8c\x07hacked!\x94\x85\x94R\x94.'
Then to deserialize our object, Python would run:
>>> a = builtins.print("hacked!")
Which, unsurprisingly, results not in an object, but in:
>>> pickle.loads(_)
hacked!
And that, even if the malicious A definition is not part of our codebase,
because it has been reduced to generic enough constructs as to be
bootstrappable in any Python environment. An actual hack might look like:
>>> class A:
... def __reduce__(self):
... return os.system, ("rm -rf /",)
Which would delegate object construction to the builtin os.system, deleting
our filesystem instead.
Another limitation of the Pickle protocol is that try as they might, the core developers couldn't guarantee that absolutely everything could be reconstructed. Dynamic enough objects, like functions defined in a local scope, don't play along:
>>> def f():
... def g():
... return 1
... print(pickle.dumps(g))
>>> f()
AttributeError: can't get local object 'f.<locals>.g'
Because Python simply doesn't provide hooks deep enough to cleave their definition out of an ephemeral scope. Incidentally, that's the reason we can't distribute such objects across multiple processes:
>>> def distribute():
... def f(i):
... print(i)
... with multiprocessing.Pool() as pool:
... pool.map(f, range(3))
>>> distribute()
AttributeError: can't get local object 'distribute.<locals>.f'
It's not a problem with the multiprocessing library – it's just that it can't
serialize the work it attempts to distribute; or, put more accurately, it can
serialize it, but the workers receiving the payload are not able to reconstruct
it reliably into their own memory. Moving f to the global scope should
resolve this issue, because then they'd be able to find it – at least, if it was
defined in a proper file. In the interpreter, this still wouldn't work, for
reasons that are hopefully starting to make sense: __main__.f is not something
that other processes can find, because __main__ is as ephemeral a namespace as
local scopes.
More interesting, though, are objects that simply can't be serialized, no matter their scope. Take generators:
>>> def gen():
... i = 1
... while True:
... yield i
... i += 1
>>> g = gen()
>>> next(g)
1
>>> pickle.dumps(g)
TypeError: cannot pickle 'generator' object
It would be pretty neat if we could play a generator until a certain point, then take it (in its paused state) and save it for later, so that loading it and playing it again would resume from the point at which it stopped. Unfortunately, that's not something Python can do: the generator's state, as well as the pointer to its next instruction, are part of the program's internal runtime, and as such they can't be flattened into a simple sequence of bytes. Or can they?
Patching Memory
This should be possible, at least in theory: a machine's memory, after all, is also a sequence of bytes, which is why virtual machines can be paused, even mid-process, only to be fully restored and resumed at a later time. So what if some data isn't stored in the exact same format as on disk? As long as we spread the same zeroes and ones that we had before in its memory space, how would the computer know the difference, and why would it care?
In case of a generator, our task is easier still: we don't need to restore the entire memory image, only the parts that pertain to its execution – namely, the local variables and instruction pointer. Both are available via the generator's frame object:
>>> g.gi_frame.f_locals
{'i': 1}
>>> g.gi_frame.f_lasti
10
Where the instruction pointer is simply a bytecode index, keeping track of the position at which the generator has paused. Unsurprisingly, it's a yield statement:
>>> g.gi_frame.f_code.co_code[10]
86
>>> opcode.opname[86]
'YIELD_VALUE'
And note that I'm actually using Python 3.10 here; serializing generators got
considerably harder in later versions, due to all the optimizations we've
mentioned before: 3.11 reworked frame objects almost beyond recognition, and
3.13 went as far as removing PyFrame_LocalsToFast – a function we're about to
lean on – from the public C API altogether (PEP 667). So treat this section as
a version-specific exhibit, and run its code on CPython 3.10, like the examples
insist; the point it makes about memory being just bytes is timeless, even if
the offsets aren't. It's going to be tricky enough even in this version,
because this instruction pointer is not writable:
>>> g.gi_frame.f_lasti = 0
AttributeError: attribute 'f_lasti' of 'frame' objects is not writable
And the locals, while they can be changed, also seem to ignore those changes:
>>> g.gi_frame.f_locals["i"] = 10
>>> next(g)
2
Still, remember how I said that memory is just a sequence of bytes, too? That means we can always brute force our way in by accessing and overwriting it directly; we just need to be careful in how we do it, lest we crash the interpreter. It's fragile, and highly version specific, but it can be done; especially in CPython, where the memory address of an object is revealed to us by its ID:
>>> id(g.gi_frame)
4343682448
Before we can write to it, though, we need to give it a shape: and here we'd
have to replicate the exact structure of the frame object, per our Python
version, so that the values we write land at the right offsets in relation to
everything else. This can be done with ctypes, after rummaging through the
interpreter's source code to unearth the relevant fields and their types:
>>> import ctypes
>>> class PyObject(ctypes.Structure):
... _fields_ = [
... ('ob_refcnt', ctypes.c_ssize_t),
... ('ob_type', ctypes.c_void_p),
... ]
>>> class PyFrameObject(ctypes.Structure):
... _fields_ = [
... ('ob_base', PyObject),
... ('ob_size', ctypes.c_ssize_t),
... ('f_back', ctypes.c_void_p),
... ('f_code', ctypes.c_void_p),
... ('f_builtins', ctypes.c_void_p),
... ('f_globals', ctypes.c_void_p),
... ('f_locals', ctypes.c_void_p),
... ('f_valuestack', ctypes.c_void_p),
... ('f_trace', ctypes.c_void_p),
... ('f_stackdepth', ctypes.c_int),
... ('f_trace_lines', ctypes.c_char),
... ('f_trace_opcodes', ctypes.c_char),
... ('f_gen', ctypes.c_void_p),
... ('f_lasti', ctypes.c_int),
... # we won't write beyond this point, so might as well stop here
... ]
Now, if I got everything right, casting the address of our frame into this mold will allow us to access its memory in such a way that will actually be writable – but first, let's read it to see that the values align correctly:
>>> f = PyFrameObject.from_address(id(g.gi_frame))
>>> f.f_lasti
5
This seems off, since g.gi_frame.f_lasti was 10, but it's actually good news:
internally, Python stores the instruction pointer as an instruction counter,
meaning we're currently at the fifth instruction – and since each instruction
takes two bytes, its offset in co_code is indeed 5 * 2 = 10. To verify it
further, we can check that f_locals points to the same address where the
locals are stored:
>>> f.f_locals
4312924480
>>> id(g.gi_frame.f_locals)
4312924480
And it does indeed. And to test that our mold is writable, we can do:
>>> f.f_lasti = 3
>>> g.gi_frame.f_lasti
6 # changed to 2 * 3!
Generator Genealogy
Our serializer can thus be exceedingly simple:
>>> def serialize_generator(g):
... return {
... "lasti": g.gi_frame.f_lasti,
... "locals": g.gi_frame.f_locals.copy(),
... }
And our deserializer can take these values, recreate the generator, and patch them in:
>>> def deserialize_generator(gen, state):
... g = gen()
... f = PyFrameObject.from_address(id(g.gi_frame))
... f.f_lasti = state["lasti"] // 2
... f.f_locals = id(state["locals"])
... return g
Let's give it a go:
>>> state = serialize_generator(g)
>>> g_restored = deserialize_generator(gen, state)
>>> next(g_restored)
UnboundLocalError: local variable 'i' referenced before assignment
Ugh. What happens here is that Python actually converts its local variables to
an optimized array for high performance access; us just patching them in
bypasses this process, confusing the interpreter as to what was assigned and
what wasn't. Incidentally, this is also the reason our writing to gi_frame.f_locals
was ignored: the original values were already converted, and Python continued to
use them without noticing our change. Luckily, this also gives us a way at a
solution: instead of patching the locals in, we can update the originals after
all, as long as we also manually trigger said conversion:
>>> def deserialize_generator(gen, state, *args):
... # … same as before
... g.gi_frame.f_locals.update(state["locals"])
... ctypes.pythonapi.PyFrame_LocalsToFast(
... ctypes.py_object(g.gi_frame),
... ctypes.c_int(0),
... )
... return g
And there you have it:
>>> g_restored = deserialize_generator(gen, state)
>>> next(g_restored)
3 # right where we stopped!
This is cool – but it's still not exactly what I had promised, because state
is a dictionary, not something we can write to disk. And of course, we can
pickle it with no problem, but to unpickle it – if you check out the
deserializer's signature – we need the original gen; that is, we have to
recreate the generator before applying our clever patch.
In principle, the generator function can be pickled; the problem is to infer
it automatically, because generators don't retain any obvious reference to their
creator. What's worse, if said creator expects some arguments (like, if gen
would accept an initial i to start from), we'd have to somehow retain those,
too; only then can we pickle the whole lot and honestly claim to have serialized
everything properly.
So, how do we find something that has no pointer? Another trick is in order:
while there is nothing as convenient as g.gi_func, we do have g.gi_code –
that is, the generator's code object, which it shares with the function that
created it. This code object doesn't have a pointer to this function, either,
but at least its unique enough that we can ask the garbage collector for its
referrers – all the objects that currently hold a reference to it – and
investigate those. As you can see, this list includes the generator objects as
well as their creator, but only the latter is an actual function, so we can do:
>>> import gc
>>> from types import FunctionType
>>> def find_generator_function(g):
... for obj in gc.get_referrers(g.gi_code):
... if isinstance(obj, FunctionType) and obj.__code__ is g.gi_code:
... return obj
... raise RuntimeError(f"no generator function found for {g}")
>>> find_generator_function(g)
<function gen at 0x…>
Now that we have the function, all that's left is to figure out how to call it –
and here, the key insight is that it doesn't actually matter all that much.
Whatever arguments we pass in, they're going to become part of the local scope,
and that scope will be overwritten, anyway; all we need is for the invocation to
work, and that means we need to match the arity – make sure we pass in the
correct number of, say, Nones, both positionally and by keyword. Since all
arguments can be passed by position except keyword-only ones (and variadic
parameters, if there are any, will default to () and {}), this turns out to
be rather simple:
>>> import inspect
>>> def placeholder_args(f):
... s = inspect.signature(f)
... num_args, kwonly_names = 0, []
... for param in s.parameters.values():
... if param.kind == param.KEYWORD_ONLY:
... kwonly_names.append(param.name)
... elif param.kind not in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
... num_args += 1
... args = [None] * num_args
... kwargs = dict.fromkeys(kwonly_names)
... return args, kwargs
So, for example:
>>> def f(a, /, b, c=1, *d, e, f=2, **g):
... pass
>>> args, kwargs = placeholder_args(f)
>>> args
[None, None, None]
>>> kwargs
{'e': None, 'f': None}
>>> f(*args, **kwargs) # works
Pickling Generators
With that, we're finally ready to pickle a generator:
>>> def pickle_generator(g):
... return pickle.dumps({
... "func": find_generator_function(g),
... "lasti": g.gi_frame.f_lasti,
... "locals": g.gi_frame.f_locals.copy(),
... })
>>> def unpickle_generator(payload):
... state = pickle.loads(payload)
... args, kwargs = placeholder_args(state["func"])
... g = state["func"](*args, **kwargs)
... f = PyFrameObject.from_address(id(g.gi_frame))
... f.f_lasti = state["lasti"] // 2
... g.gi_frame.f_locals.update(state["locals"])
... ctypes.pythonapi.PyFrame_LocalsToFast(
... ctypes.py_object(g.gi_frame),
... ctypes.c_int(0),
... )
... return g
And there you have it:
>>> def gen(i):
... while True:
... yield i
... i += 1
>>> g = gen(10)
>>> next(g)
10
>>> pickle_generator(g)
b'\x80\x04\x95;\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04func\x94\x8c\x08…'
>>> g_restored = unpickle_generator(_)
>>> next(g_restored)
11
Fast-Forward Generators
This is all pretty rad. Not so much because pickling generators is such an urgent need (although it has its uses), but because even experienced Python developers would swear this is impossible, and yet there it is; we just did it. This attitude – that anything can be done, really, because at the end of the day it's all just a sequence of ones and zeros, so if we rearrange them just right they will mean whatever we want them to mean – this attitude is, in a nutshell, the secret to the virtuosity of some of the best hackers I know.
There is, however, a complementary attitude – and this one is the secret to the
lucidity of some of the best system designers I know. It entails an aversion to
complexity, a faith that there must be a better way, and the daring to reimagine
things so radically as to arrive at completely novel solutions. When both
attitudes are mastered, they interplay beautifully: we've already seen it in
action when we figured out a way to rewrite a function's bytecode, then ditched
our complicated procedure in favor of simply rewiring its global scope. Here,
I'd like to call on it again, because upgrading our current method to a newer
version of Python would be a nightmare: 3.11 introduced a massive refactoring
of how frame objects are represented internally, and 3.13 removed
PyFrame_LocalsToFast from the public C API entirely – and while it would
still be possible to patch up (everything is, remember?), it would also get
pretty ugly, and break again on the next release; in other words, there must be
a better way.
What if instead of reconstructing a generator, we'd replay it from the very beginning – but in such a way as to fast-forward to the moment at which it was last paused? This way, we won't have to deal with any murky internals, just keep track of how many yield statements happened, and breeze through them when our generator is restored. Suppose, then, that we have:
>>> def serializeable(func):
... def call(*args, **kwargs):
... return serializeable_generator(func, *args, **kwargs)
... return call
And we use it like so:
>>> g = serializeable(gen)(10)
This would give us a new object, which retains all the relevant information:
>>> class serializeable_generator:
... def __init__(self, func, *args, **kwargs):
... self.func = func
... self.args = args
... self.kwargs = kwargs
... self.steps = 0
... self.g = self.func(*self.args, **self.kwargs)
And emulates an iterator as well:
>>> class serializeable_generator:
... # … same as before
... def __iter__(self):
... return self
... def __next__(self):
... self.steps += 1
... return next(self.g)
This way, serialization is as simple as:
>>> class serializeable_generator:
... # … same as before
... def serialize(self):
... return pickle.dumps({
... "func": self.func,
... "args": self.args,
... "kwargs": self.kwargs,
... "steps": self.steps,
... })
And deserialization fast-forwards through as many steps as were advanced:
>>> class serializeable_generator:
... # … same as before
... def fast_forward(self, steps):
... self.steps = steps
... for _ in range(self.steps):
... next(self.g)
>>> def deserialize(payload):
... state = pickle.loads(payload)
... g = serializeable_generator(
... state["func"],
... *state["args"],
... **state["kwargs"],
... )
... g.fast_forward(state["steps"])
... return g
Once again, read it and weep:
>>> next(g)
10
>>> g.serialize()
b'\x80\x04\x95?\x00\x00\x00\x00\x00\x00\x00}\x94(\x8c\x04func\x94\x8c\x08…'
>>> g_restored = deserialize(_)
>>> next(g_restored)
11
Snapshots
Needless to say, this is much simpler and much more robust than what we had before – but it's also not exactly the same, is it? If our generator would take a long time to compute certain values, or generate certain side-effects as it runs along, all of this would recur every time we replay it, fast-forward or no. Take this contrived example:
>>> def gen():
... x = random.randint(1, 100)
... print(f"selected {x}")
... yield # pause
... if x % 2 == 0:
... print("calculating...")
... time.sleep(10) # emulate heavy calculation
... print(f"calculated {x + 1}")
... else:
... print("sending request...")
... response = requests.post(URL, data=str(x)).json()
... print(f"received {response['result']}")
... yield # pause
It has all the problems described above, and then some: it might take 10
seconds, it might send a request (I/O is always a side-effect we wouldn't want
to repeat), and on top of everything, it's indeterministic – it might choose one
branch or another every time we re-run it. Fortunately, all of this can be
mitigated with one simple convention: that we put any heavy, non-idempotent, or
non-deterministic behavior in separate functions, and decorate those with
@snapshot.
The snapshot decorator, in turn, will keep track of the index of each function
call in a given run and cache its results. This way, when we replay a generator,
we won't have to recalculate any of its problematic values: they should be
available instantly, predictably, and with no side-effects from that cache,
without us having to re-invoke any functions that had already run.
To make sure each cache is matched to the particular generator in whose context
it is considered, let's use the standard contextvars module, which lets us
manage values per synchronous flow, be it a thread or a task. Why not a plain
module attribute? Because generators nest and interleave: the moment one
snapshotted generator drives another, or two of them alternate on the same
thread, a single global "current generator" would be overwritten mid-flight – a
ContextVar, set on entry and reset on exit, keeps the bookkeeping correct per
flow. Before the serializeable generator delegates to the original one, then,
it should do:
>>> from contextvars import ContextVar
>>> current_generator = ContextVar("current_generator", default=None)
>>> class serializeable_generator:
... # … same as before
... def __next__(self):
... self.steps += 1
... return self._step()
... def _step(self):
... token = current_generator.set(self)
... try:
... return next(self.g)
... finally:
... current_generator.reset(token)
That is, set itself as the "current generator" for this particular step, then roll back to the previous one when it's over; this way, nested calls will replace and restore each other seamlessly. Now, if we add a couple caches:
>>> class serializeable_generator:
... # … same as before
... def __init__(self, func, *args, **kwargs):
... # … same as before
... self.rcache = []
... self.wcache = []
We can write the snapshot decorator to read values from rcache if it isn't
empty, or compute and write them to wcache if it is:
>>> def snapshot(func):
... def wrapper(*args, **kwargs):
... g = current_generator.get()
... if g and g.rcache:
... return g.rcache.pop(0)
... value = func(*args, **kwargs)
... if g:
... g.wcache.append(value)
... return value
... return wrapper
All that's left is to include the full cache (that's wcache) in the serialized
payload, and restore both when we fast-forward, so wcache keeps accumulating,
and rcache can be consumed by calls for which a snapshot already exists:
>>> class serializeable_generator:
... # … same as before
... def serialize(self):
... return pickle.dumps({
... # … same as before
... "cache": self.wcache,
... })
... def fast_forward(self, cache, steps):
... self.rcache = cache.copy()
... self.wcache = cache.copy()
... self.steps = steps
... for _ in range(self.steps):
... self._step()
>>> def deserialize_generator(payload):
... # … same as before
... g.fast_forward(state["cache"], state["steps"])
... return g
It's a bit mind-bending, so let's recap: a generator sets itself as the "current
generator" before each step, then runs it – so if this step calls any
@snapshot-decorated function, one of two things will happen. If this function
is called for the first time, as is always the case on the generator's initial
run, rcache will be empty, so the function will execute fully and stash its
result in wcache. If we serialize our state at this point, it will include
this result – so when we deserialize and replay the generator, it will be ready
for us (in rcache and wcache both). Which brings us to the second scenario:
when we fast-forward, one result per each function called should be available in
rcache, so this time around it won't be recalculated, simply popped out of the
cache. Thus, all the steps for which values were already computed will complete
instantly, and we'll fast forward to the point where we last paused without any
disturbance. As the execution continues, more values will be added to wcache,
so if we serialize our state again, everything necessary to replay it up to our
new position will have accumulated just the same.
To demonstrate, let's extract our problematic behaviors:
>>> @snapshot
... def random_number():
... return random.randint(1, 100)
>>> @snapshot
... def heavy_calculation(x):
... print("calculating...")
... time.sleep(10)
... return x + 1
>>> @snapshot
... def http_request(x):
... print("sending request...")
... return requests.post(URL, data=str(x)).json()["result"]
And change the original generator to make use their snapshot-producing versions:
>>> def gen():
... x = random_number()
... print(f"selected {x}")
... yield
... if x % 2 == 0:
... result = heavy_calculation()
... print(f"calculated {result}")
... else:
... result = http_request(x)
... print(f"received {result}")
... yield
Now, if we serialize and replay it, nothing will repeat; whatever random number was selected will be retained when the run is resumed:
>>> g = serializeable(gen)()
>>> next(g)
selected 42 # an even number, so…
>>> g_restored = deserialize_generator(g.serialize())
>>> next(g_restored)
calculating... # and 10 seconds later:
calculated 43
Or:
>>> g = serializeable(gen)()
>>> next(g)
selected 75 # an odd number, so…
>>> g_restored = deserialize_generator(g.serialize())
>>> next(g_restored)
sending request... # suppose the server increments its input by one
received 76
What's more, if our generator would have further yields, serializing,
deserializing, and replaying it again would not repeat either the heavy
calculation or the request: both the random number and the subsequent result
would be cached and reused:
>>> g_restored_again = deserialize_generator(g.serialize())
>>> next(g_restored_again)
selected 75
received 76 # no request sent
...
Durable Execution
I realize this exercise might seem a bit academic; both our silly generator, and the overall purpose of serializing one such, call for some additional context – like, why is any of this even useful?
Turns out, there are quite a few startups whose core value proposition is being
able to do exactly that – and the associated buzzword they use is "durable
execution". You see, generators are the backbone on which coroutines are built,
and asynchronous programming is all the rage in modern Python programming. It
works great for small, quick tasks that can be densely interlaced while waiting
for I/O, like a web app serving files or database records with some minimal
processing, which is what most web apps do. However, in certain scenarios one
needs to support long-running tasks: do something, then wait for an email, or a
slack message, or a user's manual review. In such situations, the coroutine can
await for hours, even days – and if the server happens to crash, update, or
otherwise reboot during this time, its state would be lost. Hence durable
execution: such use cases require some mechanism to serialize the runtime and
persist it, so it can be restored if necessary.
Incidentally, this is exactly what we've been doing; the only difference,
really, is that a coroutine's await statement gives back a value. To be fair,
so does a generator's yield – if we'd use g.send(value) instead of
next(g), the only change we'd have to implement is to swap steps for a list
of such values to inject on each step, rather than a simple counter. Of course,
we'd also have to rewrite our serializeable_generator to adhere to the
interface expected by an event loop that's running tasks, rather than its
current schtick of pretending to be an iterator – but fleshing out such a system
is quite technical and quite boring, so it's left as an exercise to the reader
(this chapter's examples include a small sketch of such an event loop, restoring
its tasks from a durable store, if you'd like a head start).
Our core insights, of how to fast-forward a piece-wise flow from its beginning
and snapshot any turbulent steps so as to restore their results without
recomputation – those were the fun part, anyway, and they remain pretty much the
same.