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.

Cached values with deliberate creation, use and retirement lifecycles.
Cached values with deliberate creation, use and retirement lifecycles.

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.

Existing Delphi/VCL Framework demonstration
Existing Delphi/VCL Framework demonstration

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.

⚠️ Pins and borrowed entries do not make every use outside the cache's lock safe. Coordinate callers that retain objects while another operation can remove or replace those values.

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.

sample.zip
Download the complete worked example and build instructions (ZIP).

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 .