How to Design a Pascal Cache Around Value Lifecycles
A cached value can be old without being expired. A catalogue might carry a new revision while its one-minute time-to-live still has fifty seconds remaining. Conversely, a value can remain perfectly valid while capacity policy requires removing it. These are different lifecycle decisions and deserve different mechanisms.
Sphere 10 Pascal Framework's cache classes expose fetching, size estimation, expiration, eviction, invalidation, staleness and disposal as explicit responsibilities. We will build a revision-aware product cache. Its output shows a first fetch, a cache hit, a revision-triggered replacement and an explicit invalidation, with a count proving which operations actually fetched.
Contents
Define What Fresh Means
The example's application revision is deliberately simple and consists of an integer supplied by the caller. CheckStaleness compares the cached string with the value expected for that revision. A real provider could use a catalogue version, an immutable content revision or another application-owned freshness token.
Keep the staleness check appropriate to its cost. A check that downloads the entire remote object on every lookup has defeated the intended saving. The hook lets the application select its freshness policy, but the application still needs to supply information about remote changes.
Supply Fetching and Size Estimation
Subclass TCacheBase<Integer, String> and implement Fetch, EstimateSize and the optional stale check. This keeps the example compact while exposing the same lifecycle that callback-based TActionCache uses. Strings manage their own storage, so idpNone does not transfer ownership of separate heap objects.
The constructor selects a capacity, an expiration policy and on-demand staleness checks. Those choices are visible rather than hidden in a global singleton. The estimate covers the string payload chosen by this application rather than every allocation in the process.
program ArticleSample;
{$IFDEF FPC}{$mode Delphi}{$H+}{$ENDIF}
uses SysUtils, UCommon, UMemory, UCache;
type
TRevisionCache = class(TCacheBase<Integer, String>)
public
Revision: Integer;
FetchCount: Integer;
protected
function Fetch(const AKey: Integer): String; override;
function EstimateSize(const AValue: String): SizeInt; override;
function CheckStaleness(const AKey: Integer; AItem: TCachedItem<String>): Boolean; override;
end;
function TRevisionCache.Fetch(const AKey: Integer): String;
begin
Inc(FetchCount);
Result := Format('Product%d/revision%d', [AKey, Revision]);
end;
function TRevisionCache.EstimateSize(const AValue: String): SizeInt;
begin
Result := Length(AValue) * SizeOf(Char);
end;
function TRevisionCache.CheckStaleness(const AKey: Integer; AItem: TCachedItem<String>): Boolean;
begin
Result := AItem.Value <> Format('Product%d/revision%d', [AKey, Revision]);
end;
var Cache: TRevisionCache;
begin
Cache := TRevisionCache.Create(nil, idpNone, TCacheReapPolicy.crpOldest,
TCacheExpirationPolicy.epSinceFetchedTime, TCacheNullValuePolicy.nvpCacheNormally,
4096, TTimeSpan.FromMinutes(1), 0.1, TCacheStaleValuePolicy.svpCheckStaleOnDemand);
try
WriteLn(Cache[7]);
WriteLn(Cache[7]);
if Cache.FetchCount <> 1 then raise Exception.Create('Expected a cache hit');
Cache.Revision := 1;
WriteLn(Cache[7]);
if Cache.FetchCount <> 2 then raise Exception.Create('Staleness did not fetch');
Cache.Invalidate(7);
WriteLn(Cache[7]);
if Cache.FetchCount <> 3 then raise Exception.Create('Invalidation did not fetch');
WriteLn('Fetches=3');
finally
Cache.Free;
end;
end.
Original Delphi/VCL screenshot from the Framework repository, preserved without image editing.
Read the Fetch Count
Product7/revision0
Product7/revision0
Product7/revision1
Product7/revision1
Fetches=3
Two reads at revision zero perform one fetch. Changing the revision makes the next lookup stale and performs a second fetch. Explicitly invalidating the same key makes the following lookup fetch again, even though its revision text is unchanged. A counter is a stronger demonstration than printing the same returned value twice and assuming a cache hit occurred.
The repository demo also shows callback adapters, bulk fetch, preloaded dictionaries and lifecycle events. Use these when the provider naturally returns a complete catalogue or when several consumers need one immutable snapshot. A bulk cache is still a retention policy, not persistent storage.
Distinguish Expiry, Eviction and Ownership
Expiry answers whether a retained value is too old. Staleness answers whether it is no longer valid according to the application. Eviction answers what to remove when retention must make room. Invalidation is an explicit signal from the caller. These policies can coexist without being identical.
When values are objects, choose and document who frees them. A cache that owns admitted objects has a different contract from one borrowing objects owned by a larger catalogue. Entries returned by Get are cache-owned metadata. Leave their destruction to the cache rather than calling Free on them. A reference used after a cache operation may need external lifetime coordination.
Add Observability Before More Policy
Fetching and removal callbacks are useful places to count work, explain capacity decisions or update diagnostics. They run within the cache's documented operation lifecycle. Do not destroy the cache from one of its own callbacks, and keep callback targets alive for as long as they remain registered.
Measure the real workload before claiming a particular eviction strategy is faster. Key popularity, value size and fetch cost can produce different useful policies. A least-used reference-data cache and a large transient-image cache need not choose the same strategy.
Start from the cache API and its worked public examples . The value of the Framework is the coherent lifecycle around the lookup. Your application supplies the domain-specific retrieval and freshness rule, while the cache supplies reusable admission, retention and removal mechanics.
Get the Framework and Run the Example
Get
Sphere 10 Pascal Framework
and follow its
package instructions
. Use the units directly, or build the optional Lazarus .lpk or Delphi runtime package. Pascal uses these source/package workflows. The C# Framework is distributed separately through NuGet.
The attached application sample was compiled and executed with Free Pascal 3.2.0 on Windows against
this public source revision
, with range and overflow checks. Its Build.ps1 accepts the Framework, compiler, Lazarus and output paths so the example does not need machine-specific paths. Lazarus UI examples require the LCL Win32 widgetset. A successful FPC run does not claim a new Delphi build. Follow the repository's separate Delphi/VCL project and resource instructions there.
Download the complete runnable sample.
Continue the Series
How to Page and Sort Data Without Fetching the Same Snapshot Again
How to Give Pascal Resources Automatic Cleanup
For the broader Sphere 10 development toolkit, see the Sphere10 Framework overview .