Scope
Core data model
segment_membership Django app around the AtomBitmap model.
- PK-as-ordinal:
Identity.id is the bitmap ord.
- One blob per atom (no sharding).
- Atom canonical key:
(environment_id, kind, property, operator, operand_canonical, segment_key). segment_key is populated only for atoms whose operator is % Split (the engine salts that operator by segment id).
Maintenance dispatch (self-hosted core envs)
All listed signals are gated, in order, by:
settings.SEGMENT_MEMBERSHIP_ENABLED (Django global).
segment_membership_index Flagsmith on Flagsmith flag (per-organisation; defaults true in self-hosted).
If either gate is off, the signal handler returns early without enqueuing a task.
| Signal source |
Task enqueued |
Task body |
Identity.post_save(created=True) |
process_identity_created(identity_id, environment_id) |
Set bits at identity.id in every "identifier" / "identity_key" atom in the env. One-shot per identity (those properties are immutable). |
Trait.post_save (single-row write) |
process_traits_changed(identity_id, environment_id, changed_keys=[trait_key]) |
For each "trait" atom whose property is in changed_keys, re-evaluate against the identity's current trait state and flip the bit at identity.id. |
Trait.post_delete (single-row delete) |
process_traits_changed(identity_id, environment_id, changed_keys=[trait_key]) |
Same as above; absence of the trait flips operators like is_set and equals. |
Custom traits_changed signal emitted by Identity.update_traits (the bulk path) |
process_traits_changed(identity_id, environment_id, changed_keys=[…]) |
Same as above with every key touched by the bulk write. |
Identity.post_delete |
process_identity_deleted(identity_id, environment_id) |
Clear the bit at identity.id from every atom in the env. |
Segment.post_save, gated on version_of_id == id (canonical rows only — non-canonical revision snapshots created by the segment serialiser are skipped) |
process_segment_canonical_changed(segment_id) |
ensure_atoms for the segment, then backfill any new atoms across every env in the project. |
Tasks are idempotent: each reads current state and recomputes the affected bits. Out-of-order delivery and at-least-once retries converge.
Other
- Async maintenance through
flagsmith-task-processor.
- Management command
backfill_segment_membership for ops use (full-env or single-segment rebuilds).
- Tests covering the operator vocabulary with a differential check against
get_evaluation_result.
Acceptance criteria
Gating
- With
SEGMENT_MEMBERSHIP_ENABLED=False, no signal handler enqueues a task.
- With
segment_membership_index=False for an organisation, no signal handler enqueues a task for projects in that organisation.
- With both gates on, signals enqueue tasks per the dispatch table.
Per-signal behaviour (asserted via integration tests against a fresh env with one or more atoms backfilled)
- Creating an
Identity enqueues process_identity_created. After drain, the identity's PK is set in every "identifier" and "identity_key" atom for the env.
- Creating a
Trait enqueues process_traits_changed with changed_keys=[trait_key]. After drain, every "trait" atom whose property equals trait_key agrees with get_evaluation_result at that identity's PK.
- Deleting a
Trait enqueues process_traits_changed. After drain, the bit reflects the trait's absence (e.g. is_set=false flips true, equals flips false).
Identity.update_traits with N changed keys enqueues exactly one process_traits_changed task whose changed_keys is the union of those keys. After drain, every affected atom is correct.
- Deleting an
Identity enqueues process_identity_deleted. After drain, no bit remains set at that PK in any atom for the env.
- Saving live
Segment version enqueues process_segment_canonical_changed. Saving a non-live version does not.
- After
process_segment_canonical_changed drains, every atom newly required by the segment has a bitmap that matches get_evaluation_result for every identity in every env in the project.
Idempotency
- Re-running any task with the same arguments leaves the bitmap unchanged (no double-flips, no resurrection of cleared bits).
- Tasks enqueued out of order (e.g. a delete observed before its create due to retry) converge to the same bitmap as in-order delivery once both have drained.
Differential correctness
backfill_segment_membership --environment <id> --segment <id|all> produces a bitmap that matches get_evaluation_result for every identity over the operator vocabulary.
Scope
Core data model
segment_membershipDjango app around theAtomBitmapmodel.Identity.idis the bitmap ord.(environment_id, kind, property, operator, operand_canonical, segment_key).segment_keyis populated only for atoms whose operator is% Split(the engine salts that operator by segment id).Maintenance dispatch (self-hosted core envs)
All listed signals are gated, in order, by:
settings.SEGMENT_MEMBERSHIP_ENABLED(Django global).segment_membership_indexFlagsmith on Flagsmith flag (per-organisation; defaults true in self-hosted).If either gate is off, the signal handler returns early without enqueuing a task.
Identity.post_save(created=True)process_identity_created(identity_id, environment_id)identity.idin every"identifier"/"identity_key"atom in the env. One-shot per identity (those properties are immutable).Trait.post_save(single-row write)process_traits_changed(identity_id, environment_id, changed_keys=[trait_key])"trait"atom whose property is inchanged_keys, re-evaluate against the identity's current trait state and flip the bit atidentity.id.Trait.post_delete(single-row delete)process_traits_changed(identity_id, environment_id, changed_keys=[trait_key])is_setandequals.traits_changedsignal emitted byIdentity.update_traits(the bulk path)process_traits_changed(identity_id, environment_id, changed_keys=[…])Identity.post_deleteprocess_identity_deleted(identity_id, environment_id)identity.idfrom every atom in the env.Segment.post_save, gated onversion_of_id == id(canonical rows only — non-canonical revision snapshots created by the segment serialiser are skipped)process_segment_canonical_changed(segment_id)ensure_atomsfor the segment, then backfill any new atoms across every env in the project.Tasks are idempotent: each reads current state and recomputes the affected bits. Out-of-order delivery and at-least-once retries converge.
Other
flagsmith-task-processor.backfill_segment_membershipfor ops use (full-env or single-segment rebuilds).get_evaluation_result.Acceptance criteria
Gating
SEGMENT_MEMBERSHIP_ENABLED=False, no signal handler enqueues a task.segment_membership_index=Falsefor an organisation, no signal handler enqueues a task for projects in that organisation.Per-signal behaviour (asserted via integration tests against a fresh env with one or more atoms backfilled)
Identityenqueuesprocess_identity_created. After drain, the identity's PK is set in every"identifier"and"identity_key"atom for the env.Traitenqueuesprocess_traits_changedwithchanged_keys=[trait_key]. After drain, every"trait"atom whose property equalstrait_keyagrees withget_evaluation_resultat that identity's PK.Traitenqueuesprocess_traits_changed. After drain, the bit reflects the trait's absence (e.g.is_set=falseflips true,equalsflips false).Identity.update_traitswith N changed keys enqueues exactly oneprocess_traits_changedtask whosechanged_keysis the union of those keys. After drain, every affected atom is correct.Identityenqueuesprocess_identity_deleted. After drain, no bit remains set at that PK in any atom for the env.Segmentversion enqueuesprocess_segment_canonical_changed. Saving a non-live version does not.process_segment_canonical_changeddrains, every atom newly required by the segment has a bitmap that matchesget_evaluation_resultfor every identity in every env in the project.Idempotency
Differential correctness
backfill_segment_membership --environment <id> --segment <id|all>produces a bitmap that matchesget_evaluation_resultfor every identity over the operator vocabulary.