Refactor contract initialization and finalization #280
Labels
A-ink_storage
[ink_storage] Work Item
B-design
Designing a new component, interface or functionality.
Tasks
u128
FlushAt
trait to flush at a givenKey
and deprecate or removeFlush
traitFlush
derive macro to match the new definitionsFlush
traits for primitive types and Rust prelude typesstorage::Box<T>
: Has an indirectionstorage::Packed<T>
: Packs all ofT
into a single cell.storage::Lazy<T>
: Loads lazily(..) -> Self
or some other receiver returnAllocateUsing
andInitialize
traits to newFlush
designNew System
Deploy
FlushAt
trait.Call
FetchAt
trait.M
.M
is able to mutate the contract storage flush the storage viaFlushAt
triat.Traits
Current State
The
ink_core::storage::Flush
trait is defined as:Flushing is the process of storing back all intermediate computed and cached values back into the real contract storage. We do this to prevent loading and storing from and to the contract storage for every operation. Instead we try to cache things in the memory because it is much faster and we do not have to encode or decode it all the time.
So the process behind flushing is very important for performance and to not waste gas.
Problem & Proposal
The problem with the current flushing system and trait are:
Because
fn flush(&mut self)
has noat: Key
argument all flushable storage entities must know their location in the storage. This prevent us from implementing a useful implementation ofFlush
for a variety of types such as all primitive types etc.We propose to redesign the
Flush
trait in a way that types no longer necessarily have to know their exact location in storage. Types that do know their storage location shall experience no obvious downsides due to the new redesignedFlush
trait.The
flush
method takesself
by exclusive reference (&mut self
) which has been done to make the interface friendly towards cache implementations because caches generally have to mutate under the trait implementation since they are flushed and emptied. Types that do not have such a cache generally do not need to be mutated. We propose to change it to just use&self
and don't go the extra path to satisfy types with caches since caches in Rust have to have interior mutability anyways.Storage Mapping
It isn't enough to simply redesign the
Flush
trait. We also need to redesign how storage entities are mapped into the storage if they are no longer explicitly aware of their storage locations.Looking at the following example:
We see that currently this storage struct is layed out in the contract storage in the following way:
| val | vec::len | ... vec::elems ... |
Where
| foo |
represents a single cell and| foo | bar |
would represent two.| ... foo ... |
represents a chunk of cells.So we see that right now we store all those elements in their respective cell in the root.
Note that also each element in the
vec
has its own cell that is contiguously aligned.But if we are always in need of all the elements anyways it makes sense to store them under the same cell. This is done by:
However the storage outline that we receive by doing this is:
| val | key to vec::len | key to vec::elems | vec::len | ... vec::elems ... |
So we end up having indirections where we have previously used the
storage::Vec
.This is because internally the
storage::Vec
looks like this:Potential Solution
Another problem of the current system is that our storage abstractions are not fine grained enough. Taking the
storage::Value
:key
So actually the
storage::Value
does two things at the same time.This means we could break it up into two different components with different jobs.
storage::Box<T>
: Provides an indirection. Useful for splitting things apart into their own cells.storage::Cached<T>
: Provides a cache to cache the internal storage entity.To emulate the old behavior one would simply use:
storage::Box<storage::Cached<T>>
Note that since
storage::Cached<T>
doesn't have its own key we are back at our original proposal to makeFlush
key aware.Why this works
The new design differentiates between two classes of storage entities.
Note that only the Lazy storage entities are in need of their own storage keys to be able to load from their mapped storage key when needed. The Eager storage entities only ever have to load and store from and to contract storage upon contract storage allocation and upon flushing. During the time of contract execution they solely operate on their memory mapped values.
One could say that
memory::Vec
is an eager vector type that eagerly loads all vector elements upon allocation andstorage::Vec
is a lazy vector type that loads an element only if needed. Also another distinction between them is thatmemory::Vec
stores all elements under a single cell whereasstorage::Vec
provides every element its own cell.The
storage::Value
type no longer exists but instead we havestorage::Box
which is a lazy storage value because of its additional indirection (however it itself is loaded eagerly) andstorage::Cache
which is lazy because it really loads the underlying value only when needed but requires a storage key as well for doing so.Values such as plain
i32
can be used as storage entities with the new design. They are Eager storage entities since they have no notion of storage keys. During contract execution the contract operates on their plain values and upon flushing the contract provides them with their correct storage locations to make them able to flush there.Initialization
Due to the introduction of Eager storage entities we have a need to change the way a contract initializes itself since Eager types under the current initialization scheme would always result in a panic upon contract deployment. This is because they would expect values at their mapped storage locations even though the contract storage hasn't been touched at that point in time yet. So we are in need of changing the contract initialization scheme to make it possible to allow for mapped Lazy storage entities as well as unmapped Eager storage entities.
One way forward is to finally declare
#[ink(constructor)]
to be of signature:In other words: Make them just like any other Rust constructors.
Instead of automating the whole allocation and try-default-initialization machinery, contract writers would instead simply write their initialized values just as if it was a normal Rust constructor. Since flush no longer requires storage entities to know where they are mapped in storage we can simply do this.
Concrete Proposal
Introduce three new traits and remove the former old
Flush
trait:Proposal 1
Users should only ever interact with the main
Flush
trait still and still only call that.FlushAt
is used to flush the entity at hand itself.FlushPropagate
is used to flush nested components of the entity.We do the separation to guarantee that the order in which the flush happens can be relied upon.
Advantages
FlushAt
andFlushForward
Flush
implementation that can be relied upon (propagate then flush)Downsides
Proposal 2
Just go with one trait as usual.
Advantages
Downsides
Rename
We should generally think about renaming our traits.
Flushing has no proper english opposite but if we use
Push
instead ofFlush
we could introduceAllocateUsing
as the newPull
.So when executing a contract the first thing we do is to
Pull
from storage and the last thing we do is toPush
back to storage.With the refactored
Flush
(orPush
) trait we'd have to also change semantics ofAllocateUsing
so that the renaming is justified.Downside
By introducing
at: Key
into the newFlush
trait we would be no longer able to move all of these computations into compilation time since the oppositePull
from storage (that would then be required to make all of this work) is directly connected with the live contract storage because of Eager storage entities.This is the biggest downside I see because we'd have to do this for every ink! contract execution all over. We could introduce some speedups by using
u128
or evenu64
instead of a whole[u8; 32]
key, however, especially for large static storage entities (e.g. an#[ink(storage)]
struct with lots of nested fields) this computation might be compute intense when scaling it up to the point that it is required for every contract execution.Before & After
BEFORE
AllocateUsing
Flush
storage::Value<T>
storage::Vec<T>
storage::BTreeMap<K, V>
storage::Value<storage::Value<i32>>
is certainly not what you wantstorage::Value<storage::Vec<u8>>
as wellstorage::Vec<storage::Value<i32>>
is pretty useless, tooAFTER
Pull
, eager storage entities will load immediately throughPull
Flush
(orPush
) by theat: Key
parameterat
storage::Box<storage::Cached<T>>
orstorage::Cached<T>
is comparable do the same as today'sstorage::Value
storage::Cached<T>
is most places where we usedstorage::Value<T>
before, removing some indirections in nested combinations such as:storage::Value<storage::Vec<T>>
where thelen
field ofstorage::Vec<T>
has been indirected twice and would now bestorage::Cached<storage::Vec<T>>
without additional indirection.Proposal 3
This proposal goes a completely different approach.
We now base our whole computation on top of the
StorageSize
trait:It needs to be implemented for all storage entities and can also be default implemented by primitive types such as
u32
,bool
, etc.The
SIZE
constant value describes how many storage cells it requires in order to operate.For example a single storage cell requires one cell, whereas a storage chunk requires 2^32 cells and a
storage::Vec
consisting of astorage::SyncCell
and astorage::SyncChunk
thus requires 1 + 2^32 cells.The dynamic allocator requires 2^64 cells.
By introducing the new concept for the
Key
type based onu128
we can now take advantage of the fact thatu128
can compute many operations at compile time.Coupled with
const_fn
functions we declare that all storage entities must be constructible given a singleKey
as offset using the following signature:Unfortunately we cannot decode this as trait at the moment since Rust support for
const_fn
traits is not implemented, yet.Using these key components we can guarantee
const
construction of our storage entities so no more runtime computation is required for constructing the contract's storage entities.Note that we still need to support
Flush
as described above with the additionalat: Key
component to make it work for all Rust types.Problems
The problems with the 3rd approach are:
pub const fn from_offset(offset: Key) -> Self
is problematic for Eager storage types such asu32
,bool
, etc. since they cannot be properly initialized by a storage value at compile-time. We instead have the need to encode this into the type system and instead provide a return type such asMaybeUninit<Self>
orResult<Self, Err>
, some combinationResult<MaybeUninit<Self>, Err>
or something unique:The text was updated successfully, but these errors were encountered: