As an implementation of the ServantLocator interface (see
Section 32.7), a Freeze evictor takes advantage of the fundamental separation between Ice object and servant to activate servants on demand from persistent storage, and to deactivate them again using customized eviction constraints. Although an application may have thousands of Ice objects in its database, it is not practical to have servants for all of those Ice objects resident in memory simultaneously. The application can conserve resources and gain greater scalability by setting an upper limit on the number of active servants, and letting a Freeze evictor handle the details of servant activation, persistence, and deactivation.
The persistent state of servants managed by a Freeze evictor must be described in Slice. Specifically, every servant must implement a Slice class, and a Freeze evictor automatically stores and retrieves all the (Slice-defined) data members of these Slice classes. Data members that are not specified in Slice are not persistent.
A Freeze evictor relies on the Ice object factory facility to load persistent servants from disk: the evictor creates a brand new servant using the registered factory and then restores the servant’s data members. Therefore, for every persistent servant class you define, you need to register a corresponding object factory with the Ice communicator. (See the relevant language mapping chapter for more details on object factories.)
With a Freeze evictor, each <object identity,
facet> pair is associated with its own dedicated persistent object (servant). Such a persistent object cannot serve several identities or facets. Each servant is loaded and saved independently of other servants; in particular, there is no special grouping for the servants that serve the facets of a given Ice object.
Like an object adapter, the Freeze evictor provides operations named add,
addFacet,
remove, and
removeFacet. They have the same signature and semantics, except that with the Freeze evictor, the mapping and the state of the mapped servants is stored in a database.
Freeze provides two types of evictors, a background save evictor and a transactional evictor, with corresponding local interfaces:
BackgroundSaveEvictor and
TransactionalEvictor. These two local interfaces derive from the
Freeze::Evictor local interface, which defines most evictor operations, in particular
add,
addFacet,
remove, and
removeFacet.
Furthermore, the on-disk format of these two types of evictors is the same: you can switch from one type of evictor to the other without any data transformation.
A background-save evictor keeps all its servants in a map and writes the state of newly-created, modified, and deleted servants to disk asynchronously, in a background thread. You can configure how often servants are saved; for example you could decide to save every three minutes, or whenever ten or more servants have been modified. For applications with frequent updates, this allows you to group many updates together to improve performance.
The downside of the background-save evictor is recovery from a crash. Because saves are asynchronous, there is no way to force an immediate save to preserve a critical update. Moreover, you cannot group several related updates together: for example, if you transfer funds between two accounts (servants) and a crash occurs shortly after this update, it is possible that, once your application comes back up, you will see the update on one account but not on the other. Your application needs to handle such inconsistencies when restarting after a crash.
Similarly, a background-save evictor provides no ordering guarantees for saves. If you update servant 1, servant 2, and then servant 1 again, it is possible that, after recovering from a crash, you will see the latest state for servant 1, but no updates at all for servant 2.
A transactional evictor maintains a servant map as well, but only keeps read-only servants in this map. The state of these servants corresponds to the latest data on disk. Any servant creation, update, or deletion is performed within a database transaction. This transaction is committed (or rolled back) immediately, typically at the end of the dispatch of the current operation, and the associated servants are then discarded.
With such an evictor, you can ensure that several updates, often on different servants (possibly managed by different transactional evictors) are grouped together: either all or none of these updates occur. In addition, updates are written almost immediately, so crash recovery is a lot simpler: few (if any) updates will be lost, and you can maintain consistency between related persistent objects.
However, an application based on a transactional evictor is likely to write a lot more to disk than an application with a background-save evictor, which may have an adverse impact on performance.
Both background-save and transactional evictors associate a queue with their servant map and manage this queue using a “least recently used” eviction algorithm: if the queue is full, the least recently used servant is evicted to make room for a new servant.
Here is the sequence of events for activating a servant as shown in Figure 39.2. Let us assume that we have configured the evictor with a size of five, that the queue is full, and that a request has arrived for a servant that is not currently active. (With a transactional evictor, we also assume this request does not change any persistent state.)
5.
The queue’s length now exceeds the configured maximum, so the evictor removes servant 6 from the queue as soon as it is eligible for eviction. With a background save evictor, this occurs once there are no outstanding requests pending on servant 6, and once the servant’s state has been safely stored in the database. With a transactional save, the servant is removed from the queue immediately.
A Freeze evictor considers that a servant’s persistent state has been modified when a read-write operation on this servant completes. To indicate whether an operation is read-only or read-write, you add metadata directives to the Slice definitions of the objects:
•
The ["freeze:write"] directive informs the evictor that an operation modifies the persistent state of the target servant.
•
The ["freeze:read”] directive informs the evictor that an operation does not modify the persistent state of the target.
interface Example {
["freeze:read"] string readonlyOp();
["freeze:write"] void writeOp();
};
This marks readonlyOp as an operation that does not modify its target, and marks
writeOp as an operation that does modify its target. Because, without any directive, an operation is assumed to
not modify its target, the preceding definition can also be written as follows:
interface Example {
string readonlyOp(); // ["freeze:read"] implied
["freeze:write"] void writeOp();
};
The metadata directives can also be applied to an interface or a class to establish a default. This allows you to mark an interface as
["freeze:write"] and to only add a
["freeze:read"] directive to those operations that are read-only, for example:
["freeze:write"]
interface Example {
["freeze:read"] string readonlyOp();
void writeOp1();
void writeOp2();
void writeOp3();
};
This marks writeOp1,
writeOp2, and
writeOp3 as read-write operations, and
readonlyOp as a read-only operation.
Note that it is important to correctly mark read-write operations with a ["freeze:write"] metadata directive—without the directive, Freeze will not know when an object has been modified and may not store the updated persistent state to disk.
Also note that, if you make calls directly on servants (so the calls are not dispatched via the Freeze evictor), the evictor will have no idea when a servant’s persistent state is modified; if any such direct call modifies the servant’s data members, the update may be lost.
A Freeze evictor iterator provides the ability to iterate over the identities of the objects stored in an evictor. The operations are similar to Java iterator methods:
hasNext returns true while there are more elements, and
next returns the next identity:
The new iterator is specific to a facet (specified by the facet parameter). Internally, this iterator will retrieve identities in batches of
batchSize objects; we recommend to use a fairly large batch size to get good performance.
A Freeze evictor supports the use of indexes to quickly find persistent servants using the value of a data member as the search criteria. The types allowed for these indexes are the same as those allowed for Slice dictionary keys (see
Section 4.9.4).
The slice2freeze and
slice2freezej tools can generate an
Index class when passed the
‑‑index option:
•
‑‑index CLASS,TYPE,MEMBER
[,case‑sensitive|case‑insensitive]
CLASS is the name of the class to be generated.
TYPE denotes the type of class to be indexed (objects of different classes are not included in this index).
MEMBER is the name of the data member in
TYPE to index. When
MEMBER has type
string, it is possible to specify whether the index is case-sensitive or not. The default is case-sensitive.
The generated Index class supplies three methods whose definitions are mapped from the following Slice operations:
Returns up to firstN objects of
TYPE whose
MEMBER is equal to
index. This is useful to avoid running out of memory if the potential number of objects matching the criteria can be very large.
Indexes are associated with a Freeze evictor during evictor creation. See the definition of the
createBackgroundSaveEvictor and
createTransactionalEvictor functions for details.
Indexed searches are easy to use and very efficient. However, be aware that an index adds significant write overhead: with Berkeley DB, every update triggers a read from the database to get the old index entry and, if necessary, replace it.
If you add an index to an existing database, by default existing facets are not indexed. If you need to populate a new or empty index using the facets stored in your Freeze evictor, set the property
Freeze.Evictor.env‑name.filename.PopulateEmptyIndices to a value other than 0, which instructs Freeze to iterate over the corresponding facets and create the missing index entries during the call to
createBackgroundSaveEvictor or
createTransactionalEvictor. When you use this feature, you must register the object factories for all of the facet types before you create your evictor.
In some applications, it may be necessary to initialize a servant after the servant is instantiated by the evictor but before an operation is dispatched to the servant. The Freeze evictor allows an application to specify a servant initializer for this purpose.
With a background-save evictor, the servant initializer is called before the object is inserted into the evictor’s internal cache, and without holding any internal lock, but in such a way that when the servant initializer is called, the servant is guaranteed to be inserted in the evictor cache.
There is only one restriction on what a servant initializer can do: it must not make a remote invocation on the object (facet) being initialized. Failing to follow this rule will result in deadlocks.
You create a background-save evictor in C++ with the global function Freeze::createBackgroundSaveEvictor, and in Java with the static method
Freeze.Util.createBackgroundSaveEvictor.
BackgroundSaveEvictorPtrcreateBackgroundSaveEvictor(
const ObjectAdapterPtr& adapter,
const string& envName,
const string& filename,
const ServantInitializerPtr& initializer = 0,
const vector<IndexPtr>& indexes = vector<IndexPtr>(),
bool createDb = true);
BackgroundSaveEvictorPtr
createBackgroundSaveEvictor(
const ObjectAdapterPtr& adapter,
const string& envName,
DbEnv& dbEnv,
const string& filename,
const ServantInitializerPtr& initializer = 0,
const vector<IndexPtr>& indexes = vector<IndexPtr>(),
bool createDb = true);
public static BackgroundSaveEvictorcreateBackgroundSaveEvictor(
Ice.ObjectAdapter adapter,
String envName,
String filename,
ServantInitializer initializer,
Index[] indexes,
boolean createDb);
public static BackgroundSaveEvictor
createBackgroundSaveEvictor(
Ice.ObjectAdapter adapter,
String envName,
com.sleepycat.db.Environment dbEnv,
String filename,
ServantInitializer initializer,
Index[] indexes,
boolean createDb);
Both C++ and Java provide two overloaded functions: in one case, Freeze opens and manages the underlying Berkeley DB environment; in the other case, you provide a
DbEnv object that represents a Berkeley DB environment you opened yourself. (Usually, it is easiest to let Freeze take care of all interactions with Berkeley DB.)
The envName parameter represents the name of the underlying Berkeley DB environment, and is also used as the default Berkeley DB home directory. (See
Freeze.Evictor.env-name.DbHome in the Ice properties reference.)
The filename parameter represents the name of the Berkeley DB database file associated with this evictor. The persistent state of all your servants is stored in this file.
The initializer parameter represents the servant initializer. It is an optional parameter in C++; in Java, pass
null if you do not need a servant initializer.
The indexes parameter is a vector or array of evictor indexes. It is an optional parameter in C++; in Java, pass
null if your evictor does not define an index.
Finally, the createDb parameter tells Freeze what to do when the corresponding Berkeley DB database does not exist. When true, Freeze creates a new database; when false, Freeze raises a
Freeze::DatabaseException.
All persistence activity of a background-save evictor is handled in a background thread created by the evictor. This thread wakes up periodically and saves the state of all newly-registered,
modified, and destroyed servants in the evictor’s queue.
For applications that experience bursts of activity that result in a large number of modified servants in a short period of time, you can also configure the evictor’s thread to begin saving as soon as the number of modified servants reaches a certain threshold.
When the saving thread takes a snapshot of a servant it is about to save, it is necessary to prevent the application from modifying the servant’s persistent data members at the same time.
The Freeze evictor and the application need to use a common synchronization to ensure correct behavior. In Java, this common synchronization is the servant itself: the Freeze evictor synchronizes the servant (a Java object) while taking the snapshot. In C++, the servant is required to inherit from the class
IceUtil::AbstractMutex: the background-save evictor locks the servant through this interface while taking a snapshot. On the application side, the servant’s implementation is required to use the same mechanism to synchronize all operations that access the servant’s Slice-defined data members.
Occasionally, automatically evicting and reloading all servants can be inefficient. You can remove a servant from the evictor’s queue by locking this servant “in memory” using the
keep or
keepFacet operation on the evictor:
keep and
keepFacet are recursive: you need to call
release or
releaseFacet for this servant the same number of times to put it back in the evictor queue and make it eligible again for eviction.
Servants kept in memory (using keep or
keepFacet) do not consume a slot in the evictor queue. As a result, the maximum number of servants in memory is approximately the number of kept servants plus the evictor size. (It can be larger while you have many evictable objects that are modified but not yet saved.)
You create a transactional evictor in C++ with the global function Freeze::createTransactionalEvictor, and in Java with the static method
Freeze.Util.createTransactionalEvictor.
typedef map<string, string> FacetTypeMap;TransactionalEvictorPtr
createTransactionalEvictor(
const ObjectAdapterPtr& adapter,
const string& envName,
const string& filename,
const FacetTypeMap& facetTypes = FacetTypeMap(),
const ServantInitializerPtr& initializer = 0,
const vector<IndexPtr>& indexes = vector<IndexPtr>(),
bool createDb = true);
TransactionalEvictorPtr
createTransactionalEvictor(
const ObjectAdapterPtr& adapter,
const string& envName,
DbEnv& dbEnv,
const string& filename,
const FacetTypeMap& facetTypes = FacetTypeMap(),
const ServantInitializerPtr& initializer = 0,
const vector<IndexPtr>& indexes = vector<IndexPtr>(),
bool createDb = true);
public static TransactionalEvictorcreateTransactionalEvictor(
Ice.ObjectAdapter adapter,
String envName,
String filename,
java.util.Map facetTypes,
ServantInitializer initializer,
Index[] indexes,
boolean createDb);
public static TransactionalEvictor
createTransactionalEvictor(
Ice.ObjectAdapter adapter,
String envName,
com.sleepycat.db.Environment dbEnv,
String filename,
java.util.Map facetTypes,
ServantInitializer initializer,
Index[] indexes,
boolean createDb);
Both C++ and Java provide two overloaded functions: in one case, Freeze opens and manages the underlying Berkeley DB environment; in the other case, you provide a
DbEnv object that represents a Berkeley DB environment you opened yourself. (Usually, it is easier to let Freeze take care of all interactions with Berkeley DB.)
The envName parameter represents the name of the underlying Berkeley DB environment, and is also used as the default Berkeley DB home directory. (See
Freeze.Evictor.env-name.DbHome in the Ice properties reference.)
The filename parameter represents the name of the Berkeley DB database file associated with this evictor. The persistent state of all your servants is stored in this file.
The facetTypes parameter allows you to specify a single class type (Ice type ID string) for each facet in your new evictor (see below). Most applications use only the default facet, represented by an empty string. This parameter is optional in C++; in Java, pass
null if you do not want to specify such a facet-to-type mapping.
The initializer parameter represents the servant initializer. It is an optional parameter in C++; in Java, pass
null if you do not need a servant initializer.
The indexes parameter is a vector or array of evictor indexes. It is an optional parameter in C++; in Java, pass
null if your evictor does not define an index.
Finally, the createDb parameter tells Freeze what to do when the corresponding Berkeley DB database does not exist. When true, Freeze creates a new database; when false, Freeze raises a
Freeze::DatabaseException.
When a transactional evictor processes an incoming request without an associated transaction, it first needs to find out whether the corresponding operation is read-only or read-write (as specified by the
"freeze:read" and
"freeze:write" operation metadata). This is straightforward if the evictor knows the target’s type; in this case, it simply instantiates and keeps a “dummy” servant to look up the attributes of each operation.
However, if the target type can vary, the evictor needs to look up and sometimes load a read-only servant to find this information. For read-write requests, it will then load the servant from disk a second time (within a new transaction). Once the transaction commits, the read-only servant—sometimes freshly loaded from disk—is discarded.
When you create a transactional evictor with createTransactionalEvictor, you can pass a facet name to type ID map to associate a single servant type with each facet and speed up the operation attribute lookups.
Without a distributed transaction service, it is not possible to invoke several remote operations within the same transaction. Nevertheless, Freeze supports transaction propagation for collocated calls: when a request is dispatched within a transaction, the transaction is associated with the dispatch thread and will propagate to any other servant reached through a collocated call. If the target of a collocated call is managed by a transactional evictor associated with the same database environment, Freeze reuses the propagated transaction to load the servant and dispatch the request. This allows you to group updates to several servants within a single transaction.
You can also control how a transactional evictor handles an incoming transaction through optional metadata added after
"freeze:write" and
"freeze:read". There are six valid directives:
When a transactional evictor processes an incoming read-write request, it starts a new database transaction, loads a servant within the transaction, dispatches the request, and then either commits or rolls back the transaction depending on the outcome of this dispatch. If the dispatch does not raise an exception, the transaction is committed just before the response is sent back to the client. If the dispatch raises a system exception, the transaction is rolled back. If the dispatch raises a user exception, by default, the transaction is committed. However, you can configure Freeze to rollback on user-exceptions by setting
Freeze.Evictor.env-name.fileName.RollbackOnUserException to a value other than 0.
When reading and writing in separate concurrent transactions, deadlocks are likely to occur. For example, one transaction may lock pages in a particular order while another transaction locks the same pages in a different order; the outcome is a deadlock. Berkeley DB automatically detects such deadlocks, and “kills” one of the transactions.
With a Freeze transactional evictor, the application does not need to catch any deadlock exceptions or retry when deadlock occurs because the transactional evictor automatically retries its transactions whenever it encounters a deadlock situation.
However, this can affect how you implement your operations: for any operation called within a transaction (mainly read-write operations), you must anticipate the possibility of several calls for the same request, all in the same dispatch thread.
When a transactional evictor dispatches a read-write operation implemented using AMD, it starts a transaction before dispatching the request, and commits or rolls back the transaction when the dispatch is done. Two threads are involved here: the
dispatch thread and the
callback thread. The dispatch thread is a thread from an Ice thread pool tasked with dispatching a request, and the callback thread is the thread that invokes the AMD callback to send the response to the client. These threads may be one and the same if the servant invokes the AMD callback from the dispatch thread.
A transactional evictor uses the same transaction objects as Freeze maps, which allows you to update a Freeze map within a transaction managed by a transactional evictor.
You can get the current transaction created by a transactional evictor by calling getCurrentTransaction. Then, you would typically retrieve the associated Freeze connection (with
getConnection) and construct a Freeze map using this connection:
A transactional evictor also gives you the ability to associate your own transaction with the current thread, using
setCurrentTransaction. This is useful if you want to perform many updates within a single transaction, for example to add or remove many servants in the evictor. (A less convenient alternative is to implement all such updates within a read-write operation on some object.)
The Freeze evictor creates a snapshot of a servant’s state for persistent storage by marshaling the servant, just as if the servant were being sent “over the wire” as a parameter to a remote invocation. Therefore, the Slice definitions for an object type must include the data members comprising the object’s persistent state.
However, without data members, there will not be any persistent state in the database for objects of this type, and hence there is little value in using the Freeze evictor for this type.
Obviously, Slice object types need to define data members, but there are other design considerations as well. For example, suppose we define a simple application as follows:
class Account { ["freeze:write"] void withdraw(int amount);
["freeze:write"] void deposit(int amount);
int balance;
};
interface Bank {
Account* createAccount();
};
In this application, we would use a Freeze evictor to manage Account objects that have a data member
balance representing the persistent state of an account.
From an object-oriented design perspective, there is a glaring problem with these Slice definitions: implementation details (the persistent state) are exposed in the client–server contract. The client cannot directly manipulate the
balance member because the
Bank interface returns
Account proxies, not
Account instances. However, the presence of the data member may cause unnecessary confusion for client developers.
interface Account { ["freeze:write"] void withdraw(int amount);
["freeze:write"] void deposit(int amount);
};
interface Bank {
Account* createAccount();
};
class PersistentAccount implements Account {
int balance;
};
Now the Freeze evictor can manage PersistentAccount objects, while clients interact with
Account proxies. (Ideally,
PersistentAccount would be defined in a different source file and inside a separate Slice module.)