Property Lists
Why
Use property lists for ordered collections that can grow beyond DynamoDB’s 400KB item limit. Items are stored individually with list metadata for scalable operations.
Basics
notes = actor.property_lists.notes
notes.append("First note")
notes.append({"title": "Meeting", "content": "Team sync"})
first = notes[0]
count = len(notes) # see "Storage Format" below -- not free under v2
for item in notes:
print(item)
all_items = notes.to_list()
Metadata
notes.set_description("Personal notes")
notes.set_explanation("User‑generated notes and reminders")
desc = notes.get_description()
expl = notes.get_explanation()
Common Operations
append(item)insert(index, item)pop(index=-1)remove(value)clear()delete()(delete entire list)slice(start, end)(efficient range load)index(value, start=0, stop=None)count(value)
Use Cases
# Blog posts
blog_posts = actor.property_lists.blog_posts
blog_posts.append({"title": "Getting Started", "tags": ["tutorial"]})
# Webhooks
webhooks = actor.property_lists.webhook_endpoints
webhooks.append({"url": "https://api.example.com/webhook", "events": ["property_change"]})
# Activity log
activity = actor.property_lists.activity_log
activity.append({"timestamp": "2024-01-15T14:30:00Z", "action": "property_updated"})
When to Use
Regular properties: small key–value data, under ~50KB
Property lists: growing collections, list ops, complex items, or large datasets
Namespace Collision Detection
Property names and list property names share the same namespace. Attempting to create a property when a list with the same name exists, or vice versa, will raise a ValueError:
# This will raise ValueError if a list named 'notes' already exists
actor.properties.notes = "some value"
# This will raise ValueError if a property named 'tags' already exists
tags = actor.property_lists.tags
tags.append("python")
To resolve collisions:
Delete the existing property/list first, or
Use a different name for the new property/list
Migration Example
# Old: large JSON array (risk hitting 400KB limit)
actor.properties.user_notes = ["Note 1", "Note 2"]
# New: scalable list
notes = actor.property_lists.user_notes
for n in ["Note 1", "Note 2"]:
notes.append(n)
Storage Format (v1 / v2)
List properties have two internal storage formats. Both are fully supported, and which one a given list uses does not change any REST response or any value the API returns.
It is not entirely invisible in one respect: reading items one at a time
by index costs more under v2, and the cost is per item, not a fixed
factor. lst[i] re-derives the position from the list’s key ordering
before resolving it, because a cached ordering can be stale and resolving
against a stale one returns the wrong item, and that re-derivation is a
whole-list range query – the same query to_list() issues to read
every item. So a for i in range(len(lst)): lst[i] loop over an
n-item v2 list issues n whole-list queries – O(n) total capacity for
the loop, not “two queries per item” – where the same loop under v1 costs
one point read per item. On a 2026-08-19 production incident this made a
10-item delete cost roughly 1.19M RCU. Use to_list(),
to_indexed_list() or plain iteration instead – each is a single
query for the whole list regardless of length, in both formats, and
to_indexed_list() returns the (index, item) pairs such a loop is
usually after.
append()/extend() do not share that shape: as of 3.14, each
reads only the list’s current LAST rank (one item’s read capacity, via
get_last_in_range) rather than the whole ordering – a fixed cost
regardless of list length. Before 3.14, append() did re-read the whole
key ordering before writing, matching the positional-read cost above;
this is the one exception the “positional access costs the whole list”
rule used to have no exception for. extend() of n items pays that
one read ONCE for the whole batch, not once per item.
v1 (dense integers): items stored as
list:{name}-{index}, with an authoritativelengthin metadata. Every list created before this format existed.v2 (fractional rank keys): items stored as
list:{name}-#{rank}, where position is derived from sorting the rank keys – there is no separate stored length to disagree with, so the corruption classverify()/compact()exist to repair (holes and orphans from an interrupted delete/insert) cannot occur. Every new list is created in this format.
New list names may not contain # (reserved for internal storage
keys); creating one raises ValueError immediately.
Counting items without paying for the whole list
len(lst) is always exact, on both formats – under v2 it counts the
rank-key range, which is the same whole-list query to_list() issues.
When you only need an approximate count – a UI badge, a rough quota
check – lst.get_metadata()["length"] avoids that query entirely
under v2, where it is served from count_hint: an item count list
mutations maintain as a side effect, not counted fresh on every call.
(Under v1 the two are identical – length has always been an
authoritative stored field there.)
count_hint is advisory, and the drift bound is a documented
contract: at any moment, |count_hint - len(lst)| is at most the
number of mutations currently in flight against the list, plus – during
a rolling deploy only – mutations applied by pre-3.14 writers since the
last rank-counting 3.14 mutation, plus one per mutation whose advisory
metadata touch failed since the last rank-counting mutation. It never
accumulates beyond those three terms, and it self-corrects: the next
insert()/pop()/remove()/del lst[i]/compact() call
overwrites the hint with the counted truth (append()/extend()
merge stored-plus-delta and never re-count on their own). A quiesced list
whose mutations all landed cleanly reports an exact hint.
A quota check that must never over-admit should trust the hint while
strictly below its limit, and confirm with the exact len() only once
the hint reaches the limit – the whole-list read is then paid at the
quota boundary, not on every save:
def within_quota(lst, limit: int) -> bool:
hint = lst.get_metadata()["length"]
if hint < limit:
return True # trust the advisory count
return len(lst) < limit # at the boundary: pay for the exact count
verify() reports drift beyond what a healthy list should show as
count_hint_drift (informational, not part of healthy – expected
drift under concurrent mutation is not corruption), and compact()
always rewrites the hint to the counted truth as part of its rebalance.
Reading with `consistent=False`
to_list(), slice() and to_indexed_list() accept a
consistent keyword, default True – the default does not change,
so nothing about an existing call changes on upgrade. On DynamoDB, an
eventually consistent range read costs half the read capacity of a
strongly consistent one (measured on an 81-row list: 241 RCU -> 120.5
RCU). PostgreSQL accepts and ignores the parameter – its reads are
consistent by construction.
Whether a stale read is acceptable is an application question, not a
library one, which is why the library does not second-guess the choice:
consistent=False on an instance that just wrote to this list may not
see that write. It is only correct where the caller cannot have just
written the rows it is about to read – a background report, a
read-mostly cache refresh, a different actor’s read of data it did not
just produce:
recent_notes = actor.property_lists.notes.to_list(consistent=False)
Positional access (lst[i], __setitem__, __delitem__,
insert(), pop(), remove()) always reads strongly consistent
and takes no such parameter – a stale rank feeding a positional write
touches the wrong row, which is a correctness bug, not a cost trade.
__iter__ (plain for item in lst) also takes no parameter for the
same reason iteration exists as a convenience over to_list(); use
to_list(consistent=False) directly when the saving matters for a full
scan.
Note
A list created before this restriction existed may legitimately contain
# in its name. Such a list keeps working as v1 forever (migration
refuses it, see below), and the library keeps it isolated from any v2
list whose name is a prefix of it: a list named foo-#bar stores rows
that fall inside the byte range a v2 list named foo reads, so the
range read additionally requires a well-formed rank key. Renaming such
lists is still the cleaner long-term answer, and it is what unblocks
migrating them.
Migrating existing v1 lists to v2
Existing v1 lists keep working indefinitely – migration is optional and gradual, never required for a list to keep functioning:
Lazy (off by default): set
ACTINGWEB_LAZY_MIGRATION_MAX_LENGTHto a positive number and a v1 list with at most that many items migrates automatically the next time it’s mutated (append,insert, item assignment, or item deletion). 50 is a reasonable value. A failed lazy migration is logged and the mutation still succeeds against the v1 storage – migration never turns an ordinary write into a failure.It defaults to off because it is a rollback-safety control: see the danger box below. Leaving it off means the upgrade changes no stored data at all, so rolling back stays a pure code rollback. Turn it on once the release has been live long enough that rollback is off the table – or skip it and use the bulk script, which is the same operation on a rate limiter and at a time you choose.
This does not make v2 opt-in: every list created from now on is v2 regardless of this setting. Only the conversion of lists that already exist is deferred.
Two further things lazy migration deliberately will not do, whenever it is enabled:
It never migrates a damaged list. If
verify()reports the list unhealthy, migration is skipped with an operator-actionable warning. Migration closes holes in flight, which is correct when an operator runs the script and reads its report – but silently, under an ordinary write, it would erase the evidence: the hole disappears, the lost item stays lost, duplicate residue becomes indistinguishable from real data, andverify()starts reporting the list healthy. Repair is an explicit decision. Runcompact()(orverify_property_lists.py --repair), then migrate.It runs inline, in the request. One
append()to a 40-item v1 list performs the whole migration before the append itself – dozens of sequential writes plus two full-partition reads. That is the second reason to leave it at0on Lambda or any latency-sensitive deployment, after the rollback hazard below.
When enabled, the size threshold is checked at the moment of the mutation, which is not the same as “large lists stay v1 until I run the script”. A
clear()followed byextend()– a whole-list rewrite, the shape a prune or a re-sync usually takes – migrates a list of any original size, because the firstappend()insideextend()sees a list of length 0. If you are planning a controlled rollout, that is the case to know about;ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH=0– the default – is the only setting that makes “no list migrates without me” true.Bulk: larger or idle lists are migrated with the operator script:
actingweb-migrate-property-lists # dry run actingweb-migrate-property-lists --migrate
The script reports lists it refuses to migrate – names containing
#(rename first) and lists with holes or orphans (repair first) – and any duplicate-value residue it preserves as-is. The dry run exits1if anything would be refused, so0means the migration has nothing to trip over; treat that as the gate rather than reading the log.Programmatic:
actor.property_lists.<name>._list_prop.migrate_to_v2()migrates one list directly. Idempotent – safe to call again (a no-op once the list is already v2) and safe to re-run after an interruption.
Warning
Migration refuses a damaged list, and this is worth understanding
rather than working around. Migrating a list with a hole in it is not
merely lossy – it is unreportably lossy. The surviving rows are
renumbered, so afterwards the hole is gone, the list verifies healthy,
and nothing is left to say an item was ever destroyed. Repair first
(actingweb-verify-property-lists --repair, or compact()), which
closes the hole while leaving the duplicate evidence intact, and the
question does not arise.
--migrate-damaged (or migrate_to_v2(allow_damaged=True)) exists
for the operator who has looked at the damage and decided to move on. It
logs what it is giving up.
Duplicate residue does not block migration, because it survives the
conversion visibly – a v2 list’s verify() reports duplicates the
same way a v1 list’s does. Only holes and orphans gate.
Danger
No list may become v2 until every process that serves it can read v2 – and that includes the release you might roll back to.
An older process does not error on a v2 list. It reads it as empty,
silently: the metadata row still exists (so the list “exists”), but a v2
list stores no length field and a pre-v2 reader takes the absence as
zero. Worse, a write from that process lands in v1 storage and the list
forks – two versions, two disjoint views of one list, neither
reporting anything wrong. --downgrade cannot reconcile a forked list
afterwards; it overwrites v1 storage with the v2 content, destroying
whatever the older process wrote there.
The dangerous direction is rollback, not deployment. A deploy leaves
at most a brief mixed-version window. A rollback does not: deploy, let
lazy migration convert lists for hours or days, then roll back for an
unrelated reason, and every list that migrated now reads as empty in
production. There is no timing to be lucky about, and recovery is
--downgrade one list at a time, from a v2-capable checkout, against a
database being served by the code you just rolled back to.
Migration forward is automatic, fleet-wide and inline. Recovery back is manual and per-list. Size your caution to that asymmetry.
This is why ``ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH`` defaults to 0.
Out of the box, no existing list changes format, so an upgrade stays a
pure code change with no data to reconcile if you roll back. Convert
later, once the release has been live long enough that rollback is off
the table, as a deliberate rate-limited step via
scripts/migrate_property_lists.py --migrate – or by setting the
variable to a positive number and letting ordinary writes do it. Either
way it is your decision and your timing, which is the point.
Warning
There is no supported forward path from v1 to v2 other than migration,
and downgrading a v2 list back to v1 is an emergency-only operation
(scripts/migrate_property_lists.py --downgrade ACTOR_ID/list_name),
intended for rolling back to a release that predates v2 support. It
takes no lock against concurrent writes and is not part of the normal
operational flow – do not script it into routine tooling.
Order matters. Where lazy migration has been enabled, a downgraded
list at or under ACTINGWEB_LAZY_MIGRATION_MAX_LENGTH is, by
definition, a lazy-migration candidate again – if the current
application (the one with v2 support) is still running against it, its
very next mutation (append/insert/item __setitem__/
__delitem__) migrates it straight back to v2, silently undoing the
downgrade. At the default of 0 that particular race is off, but the
ordering rule is the same either way, because a v2-aware application
still writes v2 to any list it creates or converts.
Roll the application back to the pre-v2 release first,
then run --downgrade against the database from a checkout that
still has v2 support (the tool itself needs the v2 code to read the
list it’s converting) – never the other way around.
Finding Items by Value
Most lists are addressed by position (lst[i]), but items are often
looked up by an identifying field instead – the same identity_key
verify() already checks for duplicates:
task = tasks.find("id", "task-42") # first match, or None
matches = tasks.find_all("id", "task-42") # every match
Both are a single whole-list read (to_list()), matched in memory –
the same cost as any hand-written scan loop, just without every consumer
writing one. An item that is not a dict, or that lacks identity_key
entirely, never matches and never raises. Both accept the same
consistent keyword to_list() does (see above).
Handles: reading for a later write
items_with_handles() (v2 lists only – raises on a v1 list, naming
migrate_to_v2()) returns [(handle, item), ...] for the whole list
in one read:
for handle, item in tasks.items_with_handles():
if item.get("status") == "done":
... # a later phase's update_by_handle()/delete_by_handle()
# write conditioned on this exact handle
A handle is an opaque, single-use read receipt – not a durable identifier. It carries the item’s rank key and the exact raw stored string the read returned, and is only valid against the SAME list instance, within the same request or process that read it. Two things make it unsafe to persist or transmit:
It is not wire-stable across a list “generation”. A rank is unique only until the list is emptied:
delete()followed byappend()starts a brand new rank sequence from the same deterministic starting point, so a fresh list’s first item gets the identical rank the original list’s first item had. A handle read before the delete would silently address the wrong (new) item after one.It is always read strongly consistent, regardless of any
consistent=Falseused elsewhere against the same list – a stale handle’s conditional write would fail against a row nobody actually touched.
Writing by handle, or by value
delete_by_handle(handle) and update_by_handle(handle, item) (v2
lists only) condition a single write on the exact bytes a handle read:
for handle, item in tasks.items_with_handles():
if item.get("status") == "done":
tasks.delete_by_handle(handle)
Both are single-shot – there is no retry loop. A handle pins the
exact stored bytes, so a failed condition (the row changed or vanished
since the read) simply returns False; there is nothing to re-resolve,
and retrying would mean “delete/overwrite whatever is there now” rather
than the item the handle addressed. Check the return value if the answer
matters to the caller.
For the common case of “every item matching a field”, remove_where()
and update_where() wrap the scan-and-mutate loop above, and work on
both storage formats:
removed = tasks.remove_where("status", "archived")
updated = tasks.update_where("status", "open", {"status": "in_progress"})
# stop after the first match instead of every match
tasks.remove_where("id", "task-42", first_only=True)
Through actor.property_lists.<name> (as above), both return the
number of items actually affected; on the core ListProperty layer
they instead return the affected items themselves (the removed values /
the pre-update values), which is what the notifying wrapper builds its
per-item subscription diffs from – len() of that list is the same
count. Under v2 they are
built on items_with_handles() plus the handle mutators above – one
whole-list read, then one conditional write per match, same single-shot
semantics per match (a match lost to a concurrent mutation between the
read and its write is simply not counted, never retried). Under v1 they
fall back to positional access; remove_where() applies multi-match
deletes in descending index order specifically, since deleting
ascending would shift every later match onto the wrong row as each
earlier delete closes a hole.
A multi-match remove_where()/update_where() call against a list
with subscribers registers one diff per affected item – see
Subscription Manager for what that means for callback fan-out, and for
the old_item field update_where()/update_by_handle() diffs
carry.
Reading Many Lists Cheaply
If your code needs the contents of several of an actor’s lists at once – rendering a dashboard, say – fetching each list separately means one database read per list. Two methods return the names and the raw rows in one read, so you can fill in each list from data you already have.
All of them, when you want everything the actor has:
names, rows = actor.property_lists.list_all_with_rows()
for name in names:
lst = getattr(actor.property_lists, name)
lst.prime_from_rows(rows) # uses `rows`, no extra read
items = lst.to_list_from_rows(rows)
One namespace of them, when your page only renders lists sharing a
name prefix – memory_personal, memory_travel, memory_food:
names, rows = actor.property_lists.list_prefix_with_rows("memory_")
for name in names: # only the memory_* lists
lst = getattr(actor.property_lists, name)
items = lst.to_list_from_rows(rows)
prefix is a prefix, not a namespace. It also matches a list named
exactly memory, and siblings such as memory-old. Pass the
delimiter if you mean a namespace: "memory_", not "memory".
Both halves of the return are scoped: names holds only the matching
lists. That is worth reading twice, because it is the way a switch from
list_all_with_rows() goes wrong quietly – code that keeps iterating
names simply stops seeing every list outside the prefix, and nothing
raises.
The two also differ on errors, deliberately.
list_all_with_rows() returns ([], {}) if the read fails;
list_prefix_with_rows() raises DbError. For a scoped read an
empty result is a perfectly ordinary answer, so swallowing a failure
would present a throttled query as “you have no memories”. An empty
prefix raises ValueError rather than silently becoming the
whole-partition read.
When the scoped read actually pays
Not universally, and the naming invites the wrong assumption. On a
measured production account, list_all_with_rows() cost 1,361.0 read
units across 11 chained pages; the five scoped reads covering the same
lists cost 1,363.5 across 15 queries. Summing several scoped reads over
everything is marginally worse than one whole-partition read. The
saving comes entirely from the lists you skip: reading one namespace
out of five cost 685.0 units across 8 queries, half the dump.
The latency win is yours to take, not the library’s. Each call is one synchronous query; the library issues no concurrent reads and spawns no threads. What makes several scoped reads faster than one dump is issuing them from independent request handlers or an async gather – and the floor is not one round trip but the deepest pagination chain among them, since a large list still pages.
One consequence of reading each namespace separately: two lists returned by two different calls reflect two different instants. There is no snapshot isolation across calls, and none across lists within one call either.
For both methods, treat rows as a snapshot from the moment you
fetched it – it won’t reflect a change made a moment later – and pass
it straight into prime_from_rows()/to_list_from_rows() rather
than inspecting it yourself; its internal shape isn’t part of the public
API and may change in a future release. Neither changes the cost of
position-based access covered above (lst[i], pop(),
remove()) – those still check the list’s current state directly, on
purpose, so they can’t return or destroy the wrong item using data that
might already be out of date.
There is deliberately no names-only list_prefix(). A keys-only
projection saves no read capacity on DynamoDB – it still pays for the
whole item – so it would break the list_all()/list_all_with_rows()
pairing for nothing.
REST API
List properties integrate with the standard /properties endpoints:
Create an empty list:
POST /{actor_id}/properties
Content-Type: application/json
{"notes": {"_type": "list"}}
# Returns 201: {"notes": "[Empty list property created]"}
# The list must exist before the /items endpoint below will accept
# anything for it -- POST /items on an unknown list is a 404.
GET all items:
GET /{actor_id}/properties/{list_name}
# Returns: [item1, item2, ...]
GET all properties (default / format=short):
GET /{actor_id}/properties
# Returns: {"name": "Alice", "notes": {"_list": true, "count": 2}}
GET all properties with full list data (format=full):
GET /{actor_id}/properties?format=full
# Returns: {"name": "Alice", "notes": {"_list": true, "count": 2, "description": "...", "items": [...]}}
GET metadata only (metadata=true):
GET /{actor_id}/properties?metadata=true
# Returns: {"simple": {"properties": [...], "total_bytes": N}, "lists": {...}}
GET/POST items (an implementation extension, not part of the ActingWeb
spec – the spec addresses items by path index,
/properties/{list_name}/{index}):
GET /{actor_id}/properties/{list_name}/items
# Returns: {"items": [{"index": 0, "item": item0}, {"index": 1, "item": item1}, ...], "count": N}
# "index" is the STORAGE index -- the same index accepted by the
# update/delete actions below, so the two are always consistent.
POST /{actor_id}/properties/{list_name}/items
Content-Type: application/json
{"action": "add", "item_value": {...}} # append to end
{"action": "update", "item_index": N, "item_value": {...}}
{"action": "delete", "item_index": N}
# "update"/"delete" on a row that changed since the request read it:
# 503 with Retry-After, same as PUT ?index=N below -- as of 3.14 these
# are conditional writes, not unconditional overwrites.
Bulk update items (an implementation extension, like /items above):
POST /{actor_id}/properties
Content-Type: application/json
{"{list_name}": {"items": [
{"index": 0, ...item data...}, # update -- any keys besides "index"
{"index": 3}, # delete -- ONLY the "index" key
{"index": 5, ...item data...} # append -- index == current length
]}}
# Every "index" is interpreted against the list as it stood BEFORE the
# batch. Updates apply first, in the given order; deletes apply last,
# in descending index order.
As of 3.14, a batch that concurrently races another writer no longer silently overwrites or misapplies what it finds – each item is reported individually rather than failing the whole request: the response summary counts only the items that actually applied, and a skipped item is logged, not raised. Two consequences worth knowing before relying on batch semantics:
A
{"index": N, ...}update whose row changed since the batch’s own read is not applied and is not counted – the other items in the batch still are. Retry the batch (or just that item) to see the current content and decide again.Same-index update + delete, in one batch, is now well-defined: for an index that existed before the batch, the update applies and the delete is reported as skipped (“concurrently modified”) rather than deleting the row the update just wrote; for an index the batch itself creates (index >= the pre-batch length), the pair is a net no-op – nothing is appended and nothing is reported skipped. Before 3.14 this raced against whatever the positional delete pass found by then, most often deleting the updated row – if your integration relied on that, address the same index only once per batch.
Duplicate indices in one batch resolve to one write, later entry wins: two updates addressing the same index – pre-existing or batch-created – store the second value in a single row (the response still counts each request entry). Two deletes at the same pre-existing index remove one row, with the second reported as skipped – unlike pre-3.14, where position shift between the two deletes could take a neighbouring row with it.
PUT item at index:
PUT /{actor_id}/properties/{list_name}?index=0
Content-Type: application/json
{...item data...}
# index == current list length: creates (appends) the item.
# index > current list length: 404 Not Found (no padding is created).
# index within range but the row changed since this request read it:
# 503 with Retry-After (same signal a contended list metadata write
# already used) -- as of 3.14 this replace is a conditional write, not
# an unconditional overwrite of whatever it finds.
DELETE entire list:
DELETE /{actor_id}/properties/{list_name}
GET/PUT metadata:
GET /{actor_id}/properties/{list_name}/metadata
PUT /{actor_id}/properties/{list_name}/metadata
Content-Type: application/json
{"description": "...", "explanation": "..."}
Corrupted list (409 Conflict)
Every list-serving path above (GET on the list, /items, format=full
and metadata=true on the properties root) returns structured 409 if it
finds an item missing from storage within the list’s recorded length –
the residue an interrupted delete/insert can leave. This can only happen
on a v1 (dense-integer) list – see Storage Format (v1 / v2) above;
a v2 list has no separate stored length for a row to disagree with, so
this failure mode is structurally impossible there:
{"error": "list_corrupted", "list": "notes", "detail": "...", "remedy": "compact"}
Note
verify() has two duplicate checks and each is blind to what the other
catches. adjacent_duplicates compares raw stored bytes of neighbouring
rows – exactly the residue an interrupted shift leaves, but it stops
finding a duplicate once either copy is edited. Pass
identity_key="id" (or whatever field identifies your items) to also
get duplicate_identities, which compares that field across the whole
list: it survives later edits, and it does not assume the copies stayed
neighbours. Duplicates from a different mechanism – a failed read turning
an upsert into an append, say – are under no obligation to be adjacent.
Both sweep tools take --identity-key. Check
identity_checked_count in the report before trusting an empty
result: rows without the field are excluded from the comparison, so a
mistyped key produces a report shaped exactly like a clean one having
compared nothing.
The tools ship with the library as actingweb-verify-property-lists
and actingweb-migrate-property-lists, so they are available from an
installed wheel; scripts/ keeps thin wrappers for repo checkouts.
There is no HTTP repair endpoint. Repair through the library API –
actor.property_lists.notes.verify() to inspect, .compact() to fix
– or the operator sweep script, scripts/verify_property_lists.py.
verify()/compact() also work on v2 lists, for a different purpose:
detecting and rebalancing rank keys that have grown long from repeated
inserts at the same position, before they approach the internal length
cap.
Warning
compact() is not crash-safe end to end, in either storage format.
It writes every item to its new location before retiring any old row, so
an interruption partway leaves a copy at both. Re-running compact()
does not undo that – it treats every copy as a genuine item, and under
v1 it explicitly declines to touch duplicate residue at all. Prefer
running it when the actor is not taking writes, and re-run verify()
afterwards rather than assuming success.
v1 (dense integers): interrupting the rewrite of a 4-slot list with
one hole leaves [a, c, c, d] or [a, c, d, d] with the length
still reading 4 – readable with no error, because nothing is
structurally inconsistent. verify() catches it through the adjacent
byte-identical heuristic, but repair will not remove it: duplicates are
preserved by design, including the one repair itself created. Resolving
it is manual.
v2 (rank keys): the same window, as a rank rebalance. The deliberate trade here is that the alternative – retiring each old row as its replacement is written – would leave interrupted states silently reordered instead, which nothing detects at all. Recovery is manual; the stale copies are the ones whose rank keys are not part of the evenly spaced sequence a fresh rebalance produces.
Concurrency during a whole-list rewrite
compact(), migrate_to_v2() and the operator sweep scripts rewrite a
whole list. None of them takes a lock, and nothing excludes an application
write while one runs. “Run repair when the actor is not taking writes” is
therefore a requirement, not a suggestion: a concurrent write during a
rewrite can be lost.
Two things bound how bad that gets.
A concurrent write can no longer revert the storage format from a stale
cache. Every metadata write names the fields it is changing and merges them
into a freshly read metadata row, so an append() running against a list
that was migrated a moment ago updates a timestamp and nothing else. In
3.13.0rc6 and earlier it wrote a whole cached dictionary back, restoring
format: 1 over a completed migration and leaving metadata claiming v1
while every item lived in v2 rows nothing read.
Be precise about what that buys, because the difference matters
operationally. The old window was unbounded in time: a ListProperty
instance an application held on to kept its cached metadata until something
explicitly invalidated it, so a write minutes or hours after a migration could
still revert it. As of 3.14, that window is closed rather than merely
narrowed: every metadata write conditions on the exact bytes it read, via a
bounded compare-and-swap retry loop built on a per-row compare-and-set
primitive (added in 3.14 for exactly this). A migration that completes in
the gap between another writer’s read and its write no longer gets
overwritten — the write’s condition fails, the loop re-reads the row the
migration just produced, and merges onto that instead. Sustained
contention (the row keeps changing across every retry) raises
ListMetadataContentionError rather than corrupting the row or retrying
forever; PropertiesHandler, PropertyMetadataHandler and
PropertyListItemsHandler all map it to 503 with a ``Retry-After``
header, since a contended row is a retryable condition, not a server
fault. Dispatch (which storage format a mutation writes into) is decided
from the same fresh read, closing the companion gap where a retained
instance wrote into the format a migrated list no longer has — see
verify()’s foreign_format_rows field.
This closes the metadata read-modify-write window specifically. It does
not make compact()/migrate_to_v2()’s bulk item-row copying
crash-atomic — an interruption mid-rewrite can still leave duplicate or
reordered item rows, tracked separately in
thoughts/todo/whole-list-rewrite-atomicity.md. Quiescing writes during a
migration remains the operational recommendation for that part.
Two concurrent migrations resolve to last-writer-wins at whole-list
granularity, and this is accepted rather than prevented. Each migration
clears the v2 range before writing, so the loser’s snapshot never mixes with
the winner’s — you get one coherent list, not an interleaving. What is not
guaranteed is that the winner is the newer snapshot: a migration that read
the list first can finish last, and items added in between are then absent
from the migrated list. Mutual exclusion would close this, and was designed
and rejected for 3.13 (the designs are recorded in
thoughts/plans/2026-08-15-property-list-metadata-integrity.md). Run one
migration at a time, against a quiesced actor, and the question does not
arise.
Interrupted format changes clean themselves up on the next run: leftover
rows from the format the list is no longer in are swept when the migration
or downgrade is re-run, and by clear() and delete(). verify()
reports them as foreign_format_rows — informational only, because they
are inert to every reader of the current format.
See the ActingWeb specification and Properties handler documentation for complete API details.
Web UI
The UI detects list properties and provides dedicated list pages with item and metadata editing. See WWW Handler and Templates for template customization.